Skip to content

Commit 98f72cc

Browse files
committed
fix(realtime): serialize debounced subblock saves
1 parent 26dc6fc commit 98f72cc

2 files changed

Lines changed: 281 additions & 5 deletions

File tree

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
/** @vitest-environment node */
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3+
import type { IRoomManager } from '@/rooms'
4+
5+
const { mockSelect, mockSet } = vi.hoisted(() => ({
6+
mockSelect: vi.fn(),
7+
mockSet: vi.fn(),
8+
}))
9+
10+
vi.mock('@sim/db', () => {
11+
const tx = { select: mockSelect, update: () => ({ set: mockSet }) }
12+
return {
13+
db: {
14+
...tx,
15+
transaction: async (callback: (value: typeof tx) => Promise<void>) => callback(tx),
16+
},
17+
}
18+
})
19+
vi.mock('@sim/db/schema', () => ({
20+
workflow: { id: 'workflow.id' },
21+
workflowBlocks: { id: 'block.id' },
22+
}))
23+
vi.mock('@sim/platform-authz/workflow', () => ({
24+
assertWorkflowMutable: vi.fn().mockResolvedValue(undefined),
25+
WorkflowLockedError: class extends Error {},
26+
}))
27+
vi.mock('@/middleware/permissions', () => ({
28+
checkWorkflowOperationPermission: vi.fn().mockResolvedValue({ allowed: true }),
29+
}))
30+
31+
import { setupSubblocksHandlers } from '@/handlers/subblocks'
32+
33+
type Handler = (payload: unknown) => Promise<void>
34+
35+
function setup() {
36+
const handlers: Record<string, Handler> = {}
37+
const emit = vi.fn()
38+
const delivery = { emit, except: vi.fn() }
39+
delivery.except.mockReturnValue(delivery)
40+
const socket = {
41+
id: 'socket-1',
42+
on: (event: string, handler: Handler) => {
43+
handlers[event] = handler
44+
},
45+
emit: vi.fn(),
46+
}
47+
const roomManager = {
48+
io: { to: vi.fn().mockReturnValue(delivery) },
49+
isReady: () => true,
50+
getRoomForSocket: vi.fn().mockResolvedValue({ id: 'workflow-1' }),
51+
getUserSession: vi.fn().mockResolvedValue({ userId: 'user-1' }),
52+
hasRoom: vi.fn().mockResolvedValue(true),
53+
getRoomUsers: vi.fn().mockResolvedValue([{ socketId: 'socket-1', role: 'write' }]),
54+
updateUserActivity: vi.fn().mockResolvedValue(undefined),
55+
}
56+
setupSubblocksHandlers(
57+
socket as unknown as Parameters<typeof setupSubblocksHandlers>[0],
58+
roomManager as unknown as IRoomManager
59+
)
60+
return { handlers, emit }
61+
}
62+
63+
const value = [{ usageControl: 'force' }]
64+
const update = { blockId: 'agent-1', subblockId: 'tools', value, operationId: 'op-1', timestamp: 1 }
65+
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+
77+
describe('debounced subblock writes', () => {
78+
beforeEach(() => {
79+
vi.useFakeTimers()
80+
vi.clearAllMocks()
81+
mockSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
82+
mockSelect.mockImplementation(() => ({
83+
from: () => ({
84+
where: () =>
85+
Object.assign(
86+
Promise.resolve([
87+
{
88+
id: 'agent-1',
89+
type: 'agent',
90+
subBlocks: { tools: { value: [] } },
91+
data: {},
92+
locked: false,
93+
},
94+
]),
95+
{ limit: async () => [{ id: 'workflow-1' }] }
96+
),
97+
}),
98+
}))
99+
})
100+
afterEach(() => vi.useRealTimers())
101+
102+
it('persists subblock edits and confirms completion', async () => {
103+
const { handlers, emit } = setup()
104+
await handlers['subblock-update'](update)
105+
await vi.advanceTimersByTimeAsync(25)
106+
expect(mockSet).toHaveBeenCalledWith(
107+
expect.objectContaining({ subBlocks: { tools: { value } } })
108+
)
109+
expect(emit).toHaveBeenCalledWith(
110+
'operation-confirmed',
111+
expect.objectContaining({ operationId: 'op-1' })
112+
)
113+
})
114+
115+
it('treats a database failure as retryable and does not confirm a save', async () => {
116+
const { handlers, emit } = setup()
117+
mockSet.mockReturnValueOnce({ where: vi.fn().mockRejectedValue(new Error('connection reset')) })
118+
await handlers['subblock-update'](update)
119+
await vi.advanceTimersByTimeAsync(25)
120+
expect(mockSet).not.toHaveBeenCalledWith(
121+
expect.objectContaining({ subBlocks: expect.anything() })
122+
)
123+
expect(emit).toHaveBeenCalledWith(
124+
'operation-failed',
125+
expect.objectContaining({ retryable: true })
126+
)
127+
})
128+
129+
it('keeps the next debounced edit separate while the first database write is pending', async () => {
130+
const { handlers, emit } = setup()
131+
let resolveFirst: () => void = () => {}
132+
const first = new Promise<void>((resolve) => {
133+
resolveFirst = resolve
134+
})
135+
mockSet.mockReturnValueOnce({ where: () => first })
136+
await handlers['subblock-update'](update)
137+
await vi.advanceTimersByTimeAsync(25)
138+
await handlers['subblock-update']({
139+
...update,
140+
operationId: 'op-2',
141+
value: [{ usageControl: 'none' }],
142+
})
143+
resolveFirst()
144+
await vi.advanceTimersByTimeAsync(25)
145+
expect(mockSet).toHaveBeenCalledWith(
146+
expect.objectContaining({
147+
subBlocks: { tools: { value: [{ usageControl: 'none' }] } },
148+
})
149+
)
150+
expect(emit).toHaveBeenCalledWith(
151+
'operation-confirmed',
152+
expect.objectContaining({ operationId: 'op-1' })
153+
)
154+
expect(emit).toHaveBeenCalledWith(
155+
'operation-confirmed',
156+
expect.objectContaining({ operationId: 'op-2' })
157+
)
158+
})
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 = [{ usageControl: '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 = [{ usageControl: '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: [{ usageControl: '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+
})
249+
})

apps/realtime/src/handlers/subblocks.ts

Lines changed: 32 additions & 5 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-
await flushSubblockUpdate(workflowId, existing, roomManager)
198-
pendingSubblockUpdates.delete(debouncedKey)
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-
await flushSubblockUpdate(workflowId, pending, roomManager)
207-
pendingSubblockUpdates.delete(debouncedKey)
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,
@@ -282,6 +306,9 @@ async function flushSubblockUpdate(
282306
let updateSuccessful = false
283307
let blockLocked = false
284308
await db.transaction(async (tx) => {
309+
/** Serialize with workflow operations before reading and updating the block. */
310+
await tx.update(workflow).set({ updatedAt: new Date() }).where(eq(workflow.id, workflowId))
311+
285312
const allBlocks = await tx
286313
.select({
287314
id: workflowBlocks.id,
@@ -309,7 +336,7 @@ async function flushSubblockUpdate(
309336
return
310337
}
311338

312-
const subBlocks = (block.subBlocks as any) || {}
339+
const subBlocks = { ...((block.subBlocks as Record<string, Record<string, unknown>>) || {}) }
313340
if (!subBlocks[subblockId]) {
314341
subBlocks[subblockId] = { id: subblockId, type: 'unknown', value }
315342
} else {

0 commit comments

Comments
 (0)