Skip to content

Commit 8cfcfd4

Browse files
committed
fix(realtime): keep debounced subblock saves in order
Allow one flush per subblock while newer edits coalesce in a separate pending batch. Start the next ready batch after the active save completes, including failures, so a slow older lookup cannot overwrite a newer edit. Cover delayed lookups, coalescing, failure recovery, confirmation order, and independent subblock saves with regression tests.
1 parent acac91d commit 8cfcfd4

2 files changed

Lines changed: 129 additions & 4 deletions

File tree

apps/realtime/src/handlers/subblocks.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,17 @@ function setup() {
6363
const value = [{ usageControlExpression: 'force' }]
6464
const update = { blockId: 'agent-1', subblockId: 'tools', value, operationId: 'op-1', timestamp: 1 }
6565

66+
function holdNextWorkflowLookup() {
67+
let finish: (error?: Error) => void = () => {}
68+
const result = new Promise<Array<{ id: string }>>((resolve, reject) => {
69+
finish = (error) => (error ? reject(error) : resolve([{ id: 'workflow-1' }]))
70+
})
71+
mockSelect.mockReturnValueOnce({
72+
from: () => ({ where: () => ({ limit: () => result }) }),
73+
})
74+
return finish
75+
}
76+
6677
describe('debounced variable permission writes', () => {
6778
beforeEach(() => {
6879
vi.useFakeTimers()
@@ -145,4 +156,94 @@ describe('debounced variable permission writes', () => {
145156
expect.objectContaining({ operationId: 'op-2' })
146157
)
147158
})
159+
160+
it('preserves save and confirmation order when the older workflow lookup stalls', async () => {
161+
const { handlers, emit } = setup()
162+
const finishLookup = holdNextWorkflowLookup()
163+
const newerValue = [{ usageControlExpression: 'none' }]
164+
165+
await handlers['subblock-update'](update)
166+
await vi.advanceTimersByTimeAsync(25)
167+
await handlers['subblock-update']({ ...update, operationId: 'op-2', value: newerValue })
168+
await vi.advanceTimersByTimeAsync(25)
169+
const writesBeforeRelease = mockSet.mock.calls.length
170+
finishLookup()
171+
await vi.advanceTimersByTimeAsync(0)
172+
173+
expect(writesBeforeRelease).toBe(0)
174+
expect(
175+
mockSet.mock.calls
176+
.filter(([fields]) => fields.subBlocks)
177+
.map(([fields]) => fields.subBlocks.tools.value)
178+
).toEqual([value, newerValue])
179+
expect(
180+
emit.mock.calls
181+
.filter(([event]) => event === 'operation-confirmed')
182+
.map(([, payload]) => payload.operationId)
183+
).toEqual(['op-1', 'op-2'])
184+
})
185+
186+
it.each([false, true])(
187+
'coalesces waiting edits and continues after an older failure: %s',
188+
async (failOlder) => {
189+
const { handlers, emit } = setup()
190+
const finishLookup = holdNextWorkflowLookup()
191+
const newestValue = [{ usageControlExpression: 'auto' }]
192+
193+
await handlers['subblock-update'](update)
194+
await vi.advanceTimersByTimeAsync(25)
195+
await handlers['subblock-update']({
196+
...update,
197+
operationId: 'op-2',
198+
value: [{ usageControlExpression: 'none' }],
199+
})
200+
await vi.advanceTimersByTimeAsync(25)
201+
await handlers['subblock-update']({ ...update, operationId: 'op-3', value: newestValue })
202+
await vi.advanceTimersByTimeAsync(25)
203+
finishLookup(failOlder ? new Error('connection reset') : undefined)
204+
await vi.advanceTimersByTimeAsync(0)
205+
206+
expect(
207+
mockSet.mock.calls
208+
.filter(([fields]) => fields.subBlocks)
209+
.map(([fields]) => fields.subBlocks.tools.value)
210+
).toEqual(failOlder ? [newestValue] : [value, newestValue])
211+
expect(
212+
emit.mock.calls
213+
.filter(([event]) => event === 'operation-confirmed')
214+
.map(([, payload]) => payload.operationId)
215+
).toEqual(failOlder ? ['op-2', 'op-3'] : ['op-1', 'op-2', 'op-3'])
216+
if (failOlder) {
217+
expect(emit).toHaveBeenCalledWith(
218+
'operation-failed',
219+
expect.objectContaining({ operationId: 'op-1', retryable: true })
220+
)
221+
}
222+
}
223+
)
224+
225+
it('allows a different subblock to save while one subblock is stalled', async () => {
226+
const { handlers, emit } = setup()
227+
const finishLookup = holdNextWorkflowLookup()
228+
await handlers['subblock-update'](update)
229+
await vi.advanceTimersByTimeAsync(25)
230+
await handlers['subblock-update']({
231+
...update,
232+
subblockId: 'systemPrompt',
233+
operationId: 'op-other',
234+
value: 'hello',
235+
})
236+
await vi.advanceTimersByTimeAsync(25)
237+
const confirmedBeforeRelease = emit.mock.calls
238+
.filter(([event]) => event === 'operation-confirmed')
239+
.map(([, payload]) => payload.operationId)
240+
finishLookup()
241+
await vi.advanceTimersByTimeAsync(0)
242+
243+
expect(confirmedBeforeRelease).toEqual(['op-other'])
244+
expect(emit).toHaveBeenCalledWith(
245+
'operation-confirmed',
246+
expect.objectContaining({ operationId: 'op-1' })
247+
)
248+
})
148249
})

apps/realtime/src/handlers/subblocks.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@ const DEBOUNCE_INTERVAL_MS = 25
1919
type PendingSubblock = {
2020
latest: { blockId: string; subblockId: string; value: any; timestamp: number }
2121
timeout: NodeJS.Timeout
22+
ready: boolean
2223
// Map operationId -> socketId to emit confirmations/failures to correct clients
2324
opToSocket: Map<string, string>
2425
}
2526

2627
// Keyed by `${workflowId}:${blockId}:${subblockId}`
2728
const pendingSubblockUpdates = new Map<string, PendingSubblock>()
29+
const flushingSubblockUpdates = new Set<string>()
2830

2931
/**
3032
* Cleans up pending updates for a disconnected socket.
@@ -192,24 +194,26 @@ export function setupSubblocksHandlers(socket: AuthenticatedSocket, roomManager:
192194
if (existing) {
193195
clearTimeout(existing.timeout)
194196
existing.latest = { blockId, subblockId, value, timestamp }
197+
existing.ready = false
195198
if (operationId) existing.opToSocket.set(operationId, socket.id)
196199
existing.timeout = setTimeout(async () => {
197-
pendingSubblockUpdates.delete(debouncedKey)
198-
await flushSubblockUpdate(workflowId, existing, roomManager)
200+
existing.ready = true
201+
await flushReadySubblockUpdates(workflowId, debouncedKey, roomManager)
199202
}, DEBOUNCE_INTERVAL_MS)
200203
} else {
201204
const opToSocket = new Map<string, string>()
202205
if (operationId) opToSocket.set(operationId, socket.id)
203206
const timeout = setTimeout(async () => {
204207
const pending = pendingSubblockUpdates.get(debouncedKey)
205208
if (pending) {
206-
pendingSubblockUpdates.delete(debouncedKey)
207-
await flushSubblockUpdate(workflowId, pending, roomManager)
209+
pending.ready = true
210+
await flushReadySubblockUpdates(workflowId, debouncedKey, roomManager)
208211
}
209212
}, DEBOUNCE_INTERVAL_MS)
210213
pendingSubblockUpdates.set(debouncedKey, {
211214
latest: { blockId, subblockId, value, timestamp },
212215
timeout,
216+
ready: false,
213217
opToSocket,
214218
})
215219
}
@@ -236,6 +240,26 @@ export function setupSubblocksHandlers(socket: AuthenticatedSocket, roomManager:
236240
})
237241
}
238242

243+
/** Keep one save in progress per subblock while newer edits coalesce in a separate batch. */
244+
async function flushReadySubblockUpdates(
245+
workflowId: string,
246+
debouncedKey: string,
247+
roomManager: IRoomManager
248+
) {
249+
if (flushingSubblockUpdates.has(debouncedKey)) return
250+
flushingSubblockUpdates.add(debouncedKey)
251+
try {
252+
let pending = pendingSubblockUpdates.get(debouncedKey)
253+
while (pending?.ready) {
254+
pendingSubblockUpdates.delete(debouncedKey)
255+
await flushSubblockUpdate(workflowId, pending, roomManager)
256+
pending = pendingSubblockUpdates.get(debouncedKey)
257+
}
258+
} finally {
259+
flushingSubblockUpdates.delete(debouncedKey)
260+
}
261+
}
262+
239263
async function flushSubblockUpdate(
240264
workflowId: string,
241265
pending: PendingSubblock,

0 commit comments

Comments
 (0)