@cat-factory/app 0.277.1 → 0.277.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -10
- package/app/components/board/nodes/TaskCard.vue +5 -4
- package/app/components/board/nodes/TaskLane.vue +1 -2
- package/app/composables/api/workspaces.ts +4 -2
- package/app/composables/useFrameLanes.ts +35 -32
- package/app/composables/useWorkspaceStream.ts +15 -29
- package/app/composables/workspaceStream/coarseRefresh.spec.ts +146 -0
- package/app/composables/workspaceStream/coarseRefresh.ts +121 -0
- package/app/stores/environmentTest.spec.ts +33 -0
- package/app/stores/environmentTest.ts +24 -0
- package/app/stores/execution.spec.ts +50 -0
- package/app/stores/execution.ts +30 -7
- package/app/stores/notifications.ts +16 -0
- package/app/stores/pipelines.ts +9 -2
- package/app/stores/recurringPipelines.ts +15 -1
- package/app/stores/workspace/refreshFunnel.spec.ts +242 -0
- package/app/stores/workspace/refreshFunnel.ts +156 -0
- package/app/stores/workspace.spec.ts +30 -24
- package/app/stores/workspace.ts +17 -26
- package/app/utils/laneIdentity.spec.ts +119 -0
- package/app/utils/laneIdentity.ts +102 -0
- package/app/utils/laneSort.ts +35 -3
- package/package.json +1 -1
|
@@ -62,14 +62,38 @@ export const useEnvironmentTestStore = defineStore('environmentTest', () => {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Runs whose point-read is already out, and whether a later hydrate asked again while it was.
|
|
67
|
+
* Overlapping refreshes preserve the same still-running runs and would each re-issue the same
|
|
68
|
+
* GET, so the reads multiply with refresh frequency exactly when the board is busiest.
|
|
69
|
+
*
|
|
70
|
+
* Dropping the later ask outright would be wrong for the same reason plain single-flight is wrong
|
|
71
|
+
* for `workspace.refresh()`: the outstanding read may have been ISSUED before the run reached
|
|
72
|
+
* terminal, and it is the later ask that would have observed the outcome. Nothing asks again after
|
|
73
|
+
* that (terminal runs emit no event and the snapshot omits them), so the inspector would sit on
|
|
74
|
+
* "testing" for the rest of the session. One queued follow-up per run keeps the dedupe while
|
|
75
|
+
* leaving the newest ask an answer: N overlapping hydrates cost one extra read between them.
|
|
76
|
+
*/
|
|
77
|
+
const reconciling = new Map<string, { again: boolean }>()
|
|
78
|
+
|
|
65
79
|
/** Best-effort point-read of one run, folded in through the monotonic {@link upsert}. */
|
|
66
80
|
async function reconcileRun(workspaceId: string, id: string) {
|
|
81
|
+
const outstanding = reconciling.get(id)
|
|
82
|
+
if (outstanding) {
|
|
83
|
+
outstanding.again = true
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
const state = { again: false }
|
|
87
|
+
reconciling.set(id, state)
|
|
67
88
|
try {
|
|
68
89
|
upsert(await api.getEnvironmentTest(workspaceId, id))
|
|
69
90
|
} catch {
|
|
70
91
|
// Best-effort: a transient fetch failure just leaves the cached state; the next
|
|
71
92
|
// snapshot/event reconciles it.
|
|
93
|
+
} finally {
|
|
94
|
+
reconciling.delete(id)
|
|
72
95
|
}
|
|
96
|
+
if (state.again) await reconcileRun(workspaceId, id)
|
|
73
97
|
}
|
|
74
98
|
|
|
75
99
|
/**
|
|
@@ -273,3 +273,53 @@ describe('execution store echoAfter (optimistic-echo guard)', () => {
|
|
|
273
273
|
expect(returned).toBe('body')
|
|
274
274
|
})
|
|
275
275
|
})
|
|
276
|
+
|
|
277
|
+
describe('execution store per-block index', () => {
|
|
278
|
+
let store: ReturnType<typeof useExecutionStore>
|
|
279
|
+
beforeEach(() => {
|
|
280
|
+
store = useExecutionStore()
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Named apart from the module-level `run()` on purpose: that one's second argument is a `rev`
|
|
285
|
+
* and this one's is a `blockId`, so one name for both would let a test moved between the two
|
|
286
|
+
* describes build a nonsense instance that still typechecks at the call site.
|
|
287
|
+
*/
|
|
288
|
+
function blockRun(id: string, blockId: string, status: string): ExecutionInstance {
|
|
289
|
+
return { id, blockId, status, steps: [] } as unknown as ExecutionInstance
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
it('indexes one run per block', () => {
|
|
293
|
+
store.hydrate([blockRun('e1', 'b1', 'running'), blockRun('e2', 'b2', 'done')], 'ws1')
|
|
294
|
+
expect(store.getByBlock('b1')?.id).toBe('e1')
|
|
295
|
+
expect(store.getByBlock('b2')?.id).toBe('e2')
|
|
296
|
+
expect(store.getByBlock('missing')).toBeUndefined()
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
// The case the index has to keep answering the way the scan it replaced did: a stale reconnect
|
|
300
|
+
// snapshot re-listing a retry's now-deleted terminal predecessor beside the live successor.
|
|
301
|
+
it('prefers the live run over a terminal predecessor on the same block, in either order', () => {
|
|
302
|
+
store.hydrate([blockRun('old', 'b1', 'failed'), blockRun('new', 'b1', 'running')], 'ws1')
|
|
303
|
+
expect(store.getByBlock('b1')?.id).toBe('new')
|
|
304
|
+
|
|
305
|
+
store.hydrate([blockRun('new2', 'b2', 'running'), blockRun('old2', 'b2', 'failed')], 'ws1')
|
|
306
|
+
expect(store.getByBlock('b2')?.id).toBe('new2')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('answers the LAST run when a block holds only terminal ones', () => {
|
|
310
|
+
store.hydrate([blockRun('first', 'b1', 'done'), blockRun('second', 'b1', 'failed')], 'ws1')
|
|
311
|
+
expect(store.getByBlock('b1')?.id).toBe('second')
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('keeps the first live run when a block holds several', () => {
|
|
315
|
+
store.hydrate([blockRun('a', 'b1', 'running'), blockRun('b', 'b1', 'blocked')], 'ws1')
|
|
316
|
+
expect(store.getByBlock('b1')?.id).toBe('a')
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('re-indexes when an event upserts a run', () => {
|
|
320
|
+
store.hydrate([blockRun('e1', 'b1', 'running')], 'ws1')
|
|
321
|
+
expect(store.getByBlock('b1')?.status).toBe('running')
|
|
322
|
+
store.upsert({ ...blockRun('e1', 'b1', 'done'), rev: 2 } as unknown as ExecutionInstance)
|
|
323
|
+
expect(store.getByBlock('b1')?.status).toBe('done')
|
|
324
|
+
})
|
|
325
|
+
})
|
package/app/stores/execution.ts
CHANGED
|
@@ -179,14 +179,37 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
179
179
|
return id ? byId.value.get(id) : undefined
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Each block's run, indexed once per change to `instances` instead of scanned per lookup.
|
|
184
|
+
*
|
|
185
|
+
* A block only holds several runs transiently: a stale reconnect snapshot re-listing a retry's
|
|
186
|
+
* now-deleted terminal predecessor alongside the live successor. Prefer the live one so this
|
|
187
|
+
* projection agrees with `agentRuns.byBlock` (whose last-write-wins already resolves to it):
|
|
188
|
+
* the failed predecessor is dead and about to fall out on the next read.
|
|
189
|
+
*
|
|
190
|
+
* The single pass states that rule as "replace whatever is held whenever it is TERMINAL", which
|
|
191
|
+
* is the array form (`runs.find(live) ?? runs.at(-1)`) exactly: the first live run wins and is
|
|
192
|
+
* never displaced, and with no live run the LAST terminal one wins. Keep the two in step, since
|
|
193
|
+
* this is the only place the rule is written now.
|
|
194
|
+
*
|
|
195
|
+
* WHY AN INDEX. {@link getByBlock} was a full `instances` scan per call on three per-event hot
|
|
196
|
+
* paths: a computed on every mounted task card (`TaskPipelineMini`), `classify` inside the
|
|
197
|
+
* swimlane assembly of every mounted frame (`useFrameLanes`), and the board's expansion
|
|
198
|
+
* measurement pass (`useTaskExpansion`, per hover probe and per task when deep-zoomed). Each
|
|
199
|
+
* execution event invalidated all of them, so the board paid O(cards x runs) per event where one
|
|
200
|
+
* shared Map pays O(runs).
|
|
201
|
+
*/
|
|
202
|
+
const byBlockLive = computed(() => {
|
|
203
|
+
const map = new Map<string, ExecutionInstance>()
|
|
204
|
+
for (const e of instances.value) {
|
|
205
|
+
const held = map.get(e.blockId)
|
|
206
|
+
if (!held || isTerminal(held.status)) map.set(e.blockId, e)
|
|
207
|
+
}
|
|
208
|
+
return map
|
|
209
|
+
})
|
|
210
|
+
|
|
182
211
|
function getByBlock(blockId: string) {
|
|
183
|
-
|
|
184
|
-
if (runs.length <= 1) return runs[0]
|
|
185
|
-
// A block only holds several runs transiently: a stale reconnect snapshot re-listing a
|
|
186
|
-
// retry's now-deleted terminal predecessor alongside the live successor. Prefer the live
|
|
187
|
-
// one so this projection agrees with `agentRuns.byBlock` (whose last-write-wins already
|
|
188
|
-
// resolves to it) — the failed predecessor is dead and about to fall out on the next read.
|
|
189
|
-
return runs.find((e) => !isTerminal(e.status)) ?? runs.at(-1)
|
|
212
|
+
return byBlockLive.value.get(blockId)
|
|
190
213
|
}
|
|
191
214
|
|
|
192
215
|
// What across every cached run is awaiting a human (open decisions + approval gates, their
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { computed, ref } from 'vue'
|
|
3
|
+
import { collectReviewDebt } from '@cat-factory/contracts'
|
|
3
4
|
import type { Notification } from '~/types/domain'
|
|
4
5
|
import type {
|
|
5
6
|
NotificationRoutingMatrix,
|
|
@@ -90,6 +91,20 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
|
|
90
91
|
return map
|
|
91
92
|
})
|
|
92
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Per-block "waiting since", derived from the open review-wait cards by the same
|
|
96
|
+
* `collectReviewDebt` the backend's friction check uses. It is the fallback source for the park
|
|
97
|
+
* surfaces that stamp no `step.pausedAt`.
|
|
98
|
+
*
|
|
99
|
+
* Derived HERE rather than in the reader because the reader is per FRAME: `useFrameLanes` runs
|
|
100
|
+
* one instance per mounted service frame and each was re-deriving the same reduction over the
|
|
101
|
+
* WHOLE open list, so a board with n frames paid O(frames x open notifications) on every
|
|
102
|
+
* notification change for one workspace-wide fact. One store computed serves every frame.
|
|
103
|
+
*/
|
|
104
|
+
const reviewDebtByBlock = computed(
|
|
105
|
+
() => new Map(collectReviewDebt(open.value).map((d) => [d.blockId, d.waitingSince])),
|
|
106
|
+
)
|
|
107
|
+
|
|
93
108
|
/** Total open count, for the toolbar badge. */
|
|
94
109
|
const count = computed(() => open.value.length)
|
|
95
110
|
|
|
@@ -170,6 +185,7 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
|
|
170
185
|
hydrateBaseline,
|
|
171
186
|
upsert,
|
|
172
187
|
byBlock,
|
|
188
|
+
reviewDebtByBlock,
|
|
173
189
|
count,
|
|
174
190
|
act,
|
|
175
191
|
dismiss,
|
package/app/stores/pipelines.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
|
-
import { ref } from 'vue'
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
3
|
import type { Pipeline } from '~/types/domain'
|
|
4
4
|
import type {
|
|
5
5
|
GateConfigForm,
|
|
@@ -126,8 +126,15 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
126
126
|
gateConfigForms.value = forms
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* The library indexed by id. Indexed rather than scanned because every task card resolves its
|
|
131
|
+
* own default pipeline through {@link getPipeline}, so a `find` there is O(cards x catalog) on
|
|
132
|
+
* every catalog or card change.
|
|
133
|
+
*/
|
|
134
|
+
const pipelineById = computed(() => new Map(pipelines.value.map((p) => [p.id, p])))
|
|
135
|
+
|
|
129
136
|
function getPipeline(id: string) {
|
|
130
|
-
return
|
|
137
|
+
return pipelineById.value.get(id)
|
|
131
138
|
}
|
|
132
139
|
|
|
133
140
|
/**
|
|
@@ -32,9 +32,23 @@ export const useRecurringPipelinesStore = defineStore('recurringPipelines', () =
|
|
|
32
32
|
return map
|
|
33
33
|
})
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Schedules indexed by the block they reuse. Indexed rather than scanned because `byBlock` is
|
|
37
|
+
* read from a computed on every mounted task card: a `find` there is O(cards x schedules) on
|
|
38
|
+
* every change, the same shape the execution store's `byBlockLive` index replaced.
|
|
39
|
+
*
|
|
40
|
+
* First wins, matching the `find` this replaced: a block backs at most one schedule, so a second
|
|
41
|
+
* row for it is transient state a refresh resolves, not a choice to make here.
|
|
42
|
+
*/
|
|
43
|
+
const scheduleByBlock = computed(() => {
|
|
44
|
+
const map = new Map<string, PipelineSchedule>()
|
|
45
|
+
for (const s of schedules.value) if (!map.has(s.blockId)) map.set(s.blockId, s)
|
|
46
|
+
return map
|
|
47
|
+
})
|
|
48
|
+
|
|
35
49
|
/** The schedule whose reused block is `blockId`, if any. */
|
|
36
50
|
function byBlock(blockId: string): PipelineSchedule | undefined {
|
|
37
|
-
return
|
|
51
|
+
return scheduleByBlock.value.get(blockId)
|
|
38
52
|
}
|
|
39
53
|
|
|
40
54
|
async function create(input: Parameters<typeof api.createRecurringPipeline>[1]) {
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { createRefreshFunnel } from '~/stores/workspace/refreshFunnel'
|
|
3
|
+
import type { WorkspaceSnapshot } from '~/types/domain'
|
|
4
|
+
import type { LiveWriteBaselines } from '~/stores/workspace/hydrate'
|
|
5
|
+
|
|
6
|
+
interface Harness {
|
|
7
|
+
readonly funnel: ReturnType<typeof createRefreshFunnel>
|
|
8
|
+
/** Resolve the oldest pending fetch with a snapshot naming `label`. */
|
|
9
|
+
readonly settle: (label: string) => Promise<void>
|
|
10
|
+
/** Reject the oldest pending fetch. */
|
|
11
|
+
readonly fail: (message?: string) => Promise<void>
|
|
12
|
+
readonly fetches: () => number
|
|
13
|
+
/** The workspace ids the funnel actually fetched, in order. */
|
|
14
|
+
readonly fetched: () => string[]
|
|
15
|
+
readonly applied: () => string[]
|
|
16
|
+
/** Whether the oldest pending fetch has been aborted. */
|
|
17
|
+
readonly aborted: () => boolean
|
|
18
|
+
setWorkspaceId: (id: string | null) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function harness(initialId: string | null = 'ws1', deadlineMs?: number): Harness {
|
|
22
|
+
let workspaceId = initialId
|
|
23
|
+
const pending: {
|
|
24
|
+
resolve: (s: WorkspaceSnapshot) => void
|
|
25
|
+
reject: (e: Error) => void
|
|
26
|
+
signal: AbortSignal
|
|
27
|
+
}[] = []
|
|
28
|
+
const fetched: string[] = []
|
|
29
|
+
const applied: string[] = []
|
|
30
|
+
const baselines = {} as LiveWriteBaselines
|
|
31
|
+
|
|
32
|
+
const funnel = createRefreshFunnel({
|
|
33
|
+
currentWorkspaceId: () => workspaceId,
|
|
34
|
+
fetchSnapshot: (id, signal) => {
|
|
35
|
+
fetched.push(id)
|
|
36
|
+
return new Promise<WorkspaceSnapshot>((resolve, reject) =>
|
|
37
|
+
pending.push({ resolve, reject, signal }),
|
|
38
|
+
)
|
|
39
|
+
},
|
|
40
|
+
captureBaselines: () => baselines,
|
|
41
|
+
apply: (snapshot) => applied.push(snapshot.workspace.id),
|
|
42
|
+
deadlineMs,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
/** Let the funnel's internal continuations run before the assertions read its state. */
|
|
46
|
+
const drain = () => new Promise<void>((r) => setTimeout(r, 0))
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
funnel,
|
|
50
|
+
settle: async (label) => {
|
|
51
|
+
pending.shift()!.resolve({ workspace: { id: label } } as unknown as WorkspaceSnapshot)
|
|
52
|
+
await drain()
|
|
53
|
+
},
|
|
54
|
+
fail: async (message = 'offline') => {
|
|
55
|
+
pending.shift()!.reject(new Error(message))
|
|
56
|
+
await drain()
|
|
57
|
+
},
|
|
58
|
+
fetches: () => fetched.length,
|
|
59
|
+
fetched: () => fetched,
|
|
60
|
+
applied: () => applied,
|
|
61
|
+
aborted: () => pending[0]!.signal.aborted,
|
|
62
|
+
setWorkspaceId: (id) => {
|
|
63
|
+
workspaceId = id
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('refresh funnel', () => {
|
|
69
|
+
it('fetches and hydrates a single refresh', async () => {
|
|
70
|
+
const h = harness()
|
|
71
|
+
const done = h.funnel.refresh()
|
|
72
|
+
expect(h.fetches()).toBe(1)
|
|
73
|
+
await h.settle('snap1')
|
|
74
|
+
await done
|
|
75
|
+
expect(h.applied()).toEqual(['snap1'])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The coalescing rule. Callers arriving during a fetch share ONE follow-up, so N of them cost
|
|
80
|
+
* one extra fetch between them rather than N.
|
|
81
|
+
*/
|
|
82
|
+
it('collapses callers arriving during a fetch into one follow-up', async () => {
|
|
83
|
+
const h = harness()
|
|
84
|
+
const first = h.funnel.refresh()
|
|
85
|
+
const a = h.funnel.refresh()
|
|
86
|
+
const b = h.funnel.refresh()
|
|
87
|
+
const c = h.funnel.refresh()
|
|
88
|
+
expect(a).toBe(b)
|
|
89
|
+
expect(b).toBe(c)
|
|
90
|
+
expect(h.fetches()).toBe(1)
|
|
91
|
+
|
|
92
|
+
await h.settle('snap1')
|
|
93
|
+
await first
|
|
94
|
+
expect(h.fetches()).toBe(2)
|
|
95
|
+
await h.settle('snap2')
|
|
96
|
+
await Promise.all([a, b, c])
|
|
97
|
+
expect(h.applied()).toEqual(['snap1', 'snap2'])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The reason plain single-flight is wrong here. A caller that mutated and then refreshed must
|
|
102
|
+
* observe a snapshot READ AFTER its call: handing it the in-flight fetch (issued before the
|
|
103
|
+
* mutation committed) would show it the pre-mutation world.
|
|
104
|
+
*/
|
|
105
|
+
it('never hands a late caller the result of a fetch that was already in flight', async () => {
|
|
106
|
+
const h = harness()
|
|
107
|
+
const early = h.funnel.refresh()
|
|
108
|
+
const late = h.funnel.refresh()
|
|
109
|
+
await h.settle('before-mutation')
|
|
110
|
+
await early
|
|
111
|
+
// The late caller is still waiting: its own fetch has only just been issued.
|
|
112
|
+
expect(h.applied()).toEqual(['before-mutation'])
|
|
113
|
+
await h.settle('after-mutation')
|
|
114
|
+
await late
|
|
115
|
+
expect(h.applied()).toEqual(['before-mutation', 'after-mutation'])
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('still serves the queued caller when the in-flight fetch fails', async () => {
|
|
119
|
+
const h = harness()
|
|
120
|
+
const rejects = expect(h.funnel.refresh()).rejects.toThrow('offline')
|
|
121
|
+
const queued = h.funnel.refresh()
|
|
122
|
+
await h.fail()
|
|
123
|
+
await rejects
|
|
124
|
+
expect(h.fetches()).toBe(2)
|
|
125
|
+
await h.settle('recovered')
|
|
126
|
+
await queued
|
|
127
|
+
expect(h.applied()).toEqual(['recovered'])
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('propagates a failure to the caller that issued the fetch', async () => {
|
|
131
|
+
const h = harness()
|
|
132
|
+
// Attach the expectation BEFORE rejecting: `fail` yields to the microtask queue, and an
|
|
133
|
+
// unobserved rejection in that window is reported as unhandled.
|
|
134
|
+
const rejects = expect(h.funnel.refresh()).rejects.toThrow('boom')
|
|
135
|
+
await h.fail('boom')
|
|
136
|
+
await rejects
|
|
137
|
+
expect(h.applied()).toEqual([])
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('does nothing before a workspace is open', async () => {
|
|
141
|
+
const h = harness(null)
|
|
142
|
+
await h.funnel.refresh()
|
|
143
|
+
expect(h.fetches()).toBe(0)
|
|
144
|
+
expect(h.applied()).toEqual([])
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('discards a snapshot whose board was switched away from mid-fetch', async () => {
|
|
148
|
+
const h = harness()
|
|
149
|
+
const done = h.funnel.refresh()
|
|
150
|
+
h.setWorkspaceId('ws2')
|
|
151
|
+
await h.settle('stale')
|
|
152
|
+
await done
|
|
153
|
+
expect(h.applied()).toEqual([])
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The queued follow-up is queued FOR a board. A switch while it waits makes it pointless, and
|
|
158
|
+
* issuing it anyway would read the NEW board's snapshot on behalf of a caller that asked about
|
|
159
|
+
* the old one, race the switch's own hydrate, and resolve as though the old board had refreshed.
|
|
160
|
+
*/
|
|
161
|
+
it('does not fetch the new board on behalf of a follow-up queued for the old one', async () => {
|
|
162
|
+
const h = harness()
|
|
163
|
+
const first = h.funnel.refresh()
|
|
164
|
+
const queued = h.funnel.refresh()
|
|
165
|
+
h.setWorkspaceId('ws2')
|
|
166
|
+
await h.settle('stale')
|
|
167
|
+
await first
|
|
168
|
+
await queued
|
|
169
|
+
expect(h.fetched()).toEqual(['ws1'])
|
|
170
|
+
expect(h.applied()).toEqual([])
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The slot is bounded, because serializing every refresh through it makes ONE stalled request
|
|
175
|
+
* everyone's problem: the client sets no timeout, so a dropped connection would otherwise leave
|
|
176
|
+
* the funnel holding a fetch that never settles and every later caller queued behind it forever.
|
|
177
|
+
*/
|
|
178
|
+
describe('deadline', () => {
|
|
179
|
+
it('fails the caller, aborts the request and frees the slot when a fetch never settles', async () => {
|
|
180
|
+
const h = harness('ws1', 5)
|
|
181
|
+
const stalled = expect(h.funnel.refresh()).rejects.toThrow(/timed out/)
|
|
182
|
+
await stalled
|
|
183
|
+
expect(h.aborted()).toBe(true)
|
|
184
|
+
|
|
185
|
+
// The funnel is usable again: a fresh caller issues its own fetch rather than joining the
|
|
186
|
+
// hang, and the abandoned request can no longer hydrate anything if it does answer.
|
|
187
|
+
const next = h.funnel.refresh()
|
|
188
|
+
expect(h.fetches()).toBe(2)
|
|
189
|
+
await h.settle('late-answer-from-the-abandoned-fetch')
|
|
190
|
+
expect(h.applied()).toEqual([])
|
|
191
|
+
await h.settle('recovered')
|
|
192
|
+
await next
|
|
193
|
+
expect(h.applied()).toEqual(['recovered'])
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('does not count a timed-out fetch as coverage', async () => {
|
|
197
|
+
const h = harness('ws1', 5)
|
|
198
|
+
const mark = h.funnel.refreshMark()
|
|
199
|
+
await expect(h.funnel.refresh()).rejects.toThrow(/timed out/)
|
|
200
|
+
expect(h.funnel.hydratedSince(mark)).toBe(false)
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
describe('coverage mark', () => {
|
|
205
|
+
it('reports a fetch that started after the mark and hydrated', async () => {
|
|
206
|
+
const h = harness()
|
|
207
|
+
const mark = h.funnel.refreshMark()
|
|
208
|
+
const done = h.funnel.refresh()
|
|
209
|
+
expect(h.funnel.hydratedSince(mark)).toBe(false)
|
|
210
|
+
await h.settle('snap1')
|
|
211
|
+
await done
|
|
212
|
+
expect(h.funnel.hydratedSince(mark)).toBe(true)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
// The mark is taken when a coarse event arrives. A fetch ALREADY in flight may have been
|
|
216
|
+
// issued before that event, so it must not read as coverage for it.
|
|
217
|
+
it('does not count a fetch that was already in flight when the mark was taken', async () => {
|
|
218
|
+
const h = harness()
|
|
219
|
+
const done = h.funnel.refresh()
|
|
220
|
+
const mark = h.funnel.refreshMark()
|
|
221
|
+
await h.settle('in-flight-before-the-event')
|
|
222
|
+
await done
|
|
223
|
+
expect(h.funnel.hydratedSince(mark)).toBe(false)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('does not count a fetch that failed or was discarded', async () => {
|
|
227
|
+
const h = harness()
|
|
228
|
+
const mark = h.funnel.refreshMark()
|
|
229
|
+
const rejects = expect(h.funnel.refresh()).rejects.toThrow()
|
|
230
|
+
await h.fail()
|
|
231
|
+
await rejects
|
|
232
|
+
expect(h.funnel.hydratedSince(mark)).toBe(false)
|
|
233
|
+
|
|
234
|
+
const mark2 = h.funnel.refreshMark()
|
|
235
|
+
const discarded = h.funnel.refresh()
|
|
236
|
+
h.setWorkspaceId('ws2')
|
|
237
|
+
await h.settle('stale')
|
|
238
|
+
await discarded
|
|
239
|
+
expect(h.funnel.hydratedSince(mark2)).toBe(false)
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
})
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { WorkspaceSnapshot } from '~/types/domain'
|
|
2
|
+
import type { LiveWriteBaselines } from '~/stores/workspace/hydrate'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one door every full-snapshot refresh goes through.
|
|
6
|
+
*
|
|
7
|
+
* WHY A FUNNEL. `workspace.refresh()` is the client's heaviest operation: a ~20-read snapshot
|
|
8
|
+
* aggregate on the server, then 31 hydrate calls into 24 stores. Roughly 35 call sites reach for it
|
|
9
|
+
* directly after a mutation, and the event stream schedules its own on every coarse `board` event,
|
|
10
|
+
* so the paths that produce the most events are exactly the paths that stacked the most redundant
|
|
11
|
+
* snapshots. Making the funnel the FUNCTION every caller already calls is what lets those call
|
|
12
|
+
* sites stay as they are: there is nothing for a new mutation to remember to opt into.
|
|
13
|
+
*
|
|
14
|
+
* THE COALESCING RULE, and why it is not plain single-flight. A caller that awaits `refresh()`
|
|
15
|
+
* after its own mutation is entitled to a snapshot that INCLUDES that mutation. Handing it the
|
|
16
|
+
* promise of a fetch already in flight would break that: the in-flight request may have been issued
|
|
17
|
+
* before the mutation committed. So a call arriving during a fetch does not join it, it joins a
|
|
18
|
+
* SINGLE queued follow-up that starts once the current one settles. Any number of callers during
|
|
19
|
+
* one slow fetch therefore cost one extra fetch between them, never one each, and every caller
|
|
20
|
+
* still observes a snapshot read after it called.
|
|
21
|
+
*
|
|
22
|
+
* THE COVERAGE MARK. A coarse `board` event means "something changed, resync", and a snapshot whose
|
|
23
|
+
* fetch was ISSUED after that event arrived necessarily contains the change (the server emits the
|
|
24
|
+
* event after committing it). {@link RefreshFunnel.refreshMark} + {@link RefreshFunnel.hydratedSince}
|
|
25
|
+
* let the stream's debounce ask exactly that question and drop a refresh some mutation's own
|
|
26
|
+
* `refresh()` already covered, which is the duplicate the direct call sites otherwise pay twice.
|
|
27
|
+
* The mark counts fetches STARTED and only a fetch that actually HYDRATED advances the answer, so a
|
|
28
|
+
* discarded or failed one never reads as coverage.
|
|
29
|
+
*
|
|
30
|
+
* Ordering needs no sequence guard: at most one fetch is ever in flight, so two snapshots cannot
|
|
31
|
+
* resolve out of order. A board SWITCH is the one thing that outdates a request rather than
|
|
32
|
+
* ordering it, so it is checked twice: an arrived snapshot for a board nothing is showing is
|
|
33
|
+
* discarded, and a queued follow-up for such a board is never issued (it would fetch the CURRENT
|
|
34
|
+
* board on a caller that asked about the old one, and report that as its answer).
|
|
35
|
+
*
|
|
36
|
+
* SERIALIZING MAKES A STALL EVERYONE'S PROBLEM, which is why the slot is bounded: see
|
|
37
|
+
* {@link SNAPSHOT_DEADLINE_MS}.
|
|
38
|
+
*/
|
|
39
|
+
export interface RefreshFunnelDeps {
|
|
40
|
+
/** The active workspace id, or null before bootstrap. Read fresh on each attempt. */
|
|
41
|
+
readonly currentWorkspaceId: () => string | null
|
|
42
|
+
/** Read the snapshot. `signal` aborts on the deadline, so a stalled request is released. */
|
|
43
|
+
readonly fetchSnapshot: (workspaceId: string, signal: AbortSignal) => Promise<WorkspaceSnapshot>
|
|
44
|
+
/**
|
|
45
|
+
* Capture the live-write baselines BEFORE the fetch: anything a live event writes while this
|
|
46
|
+
* (potentially slow) snapshot is in flight is newer than the snapshot, so the hydrate must not
|
|
47
|
+
* clobber it back.
|
|
48
|
+
*/
|
|
49
|
+
readonly captureBaselines: () => LiveWriteBaselines
|
|
50
|
+
readonly apply: (snapshot: WorkspaceSnapshot, baselines: LiveWriteBaselines) => void
|
|
51
|
+
/** Override the deadline below. Exists for the tests; production takes the default. */
|
|
52
|
+
readonly deadlineMs?: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* How long the funnel holds its one slot before giving up on a fetch.
|
|
57
|
+
*
|
|
58
|
+
* The snapshot read goes through the shared wretch client, which sets no timeout, so a stalled
|
|
59
|
+
* connection (a dropped mobile or VPN link) leaves a GET pending without ever rejecting. With every
|
|
60
|
+
* refresh serialized behind one slot, that single stall would wedge the whole path: no later fetch
|
|
61
|
+
* is issued, the coarse-event resync stops running, the stream's bounded retry never sees a failure
|
|
62
|
+
* to retry, and every awaited `refresh()` hangs its caller forever. A deadline turns it into an
|
|
63
|
+
* ordinary failure all three can act on. It ABORTS as well as abandons, so the stalled request is
|
|
64
|
+
* released rather than left to answer into nothing, and a snapshot that lands after the deadline is
|
|
65
|
+
* never applied: the funnel has already moved on, and its baselines are that much staler.
|
|
66
|
+
*
|
|
67
|
+
* Sized well above a slow-but-real snapshot (the aggregate is ~20 reads) so it only ever fires on a
|
|
68
|
+
* connection that is not coming back.
|
|
69
|
+
*/
|
|
70
|
+
const SNAPSHOT_DEADLINE_MS = 30_000
|
|
71
|
+
|
|
72
|
+
export interface RefreshFunnel {
|
|
73
|
+
/** Re-fetch the snapshot and re-hydrate, coalesced as described above. */
|
|
74
|
+
readonly refresh: () => Promise<void>
|
|
75
|
+
/** A token to compare against later: how many snapshot fetches have STARTED. */
|
|
76
|
+
readonly refreshMark: () => number
|
|
77
|
+
/** Whether a snapshot fetch issued after `mark` has already hydrated. */
|
|
78
|
+
readonly hydratedSince: (mark: number) => boolean
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function createRefreshFunnel(deps: RefreshFunnelDeps): RefreshFunnel {
|
|
82
|
+
const deadlineMs = deps.deadlineMs ?? SNAPSHOT_DEADLINE_MS
|
|
83
|
+
let starts = 0
|
|
84
|
+
let lastHydratedStart = 0
|
|
85
|
+
let inFlight: Promise<void> | null = null
|
|
86
|
+
/**
|
|
87
|
+
* The one queued follow-up, tagged with the board it was queued FOR. A caller joins it only when
|
|
88
|
+
* it asks about the same board: after a switch mid-fetch, a caller asking about the NEW board must
|
|
89
|
+
* be neither served nor stood down by a follow-up queued for the old one.
|
|
90
|
+
*/
|
|
91
|
+
let queued: { readonly targetId: string; readonly promise: Promise<void> } | null = null
|
|
92
|
+
|
|
93
|
+
/** Read the snapshot under the deadline, aborting the request when it expires. */
|
|
94
|
+
async function fetchSnapshot(targetId: string): Promise<WorkspaceSnapshot> {
|
|
95
|
+
const controller = new AbortController()
|
|
96
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
97
|
+
try {
|
|
98
|
+
return await new Promise<WorkspaceSnapshot>((resolve, reject) => {
|
|
99
|
+
timer = setTimeout(() => {
|
|
100
|
+
controller.abort()
|
|
101
|
+
reject(new Error(`Board snapshot fetch timed out after ${deadlineMs}ms`))
|
|
102
|
+
}, deadlineMs)
|
|
103
|
+
deps.fetchSnapshot(targetId, controller.signal).then(resolve, reject)
|
|
104
|
+
})
|
|
105
|
+
} finally {
|
|
106
|
+
clearTimeout(timer)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function fetchAndApply(targetId: string): Promise<void> {
|
|
111
|
+
const start = ++starts
|
|
112
|
+
const baselines = deps.captureBaselines()
|
|
113
|
+
const snapshot = await fetchSnapshot(targetId)
|
|
114
|
+
// The active board switched while this fetch was in flight: its snapshot describes a board
|
|
115
|
+
// nothing is showing, so applying it would replace the new board's state with the old one's.
|
|
116
|
+
if (deps.currentWorkspaceId() !== targetId) return
|
|
117
|
+
deps.apply(snapshot, baselines)
|
|
118
|
+
lastHydratedStart = start
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function refresh(): Promise<void> {
|
|
122
|
+
const targetId = deps.currentWorkspaceId()
|
|
123
|
+
if (!targetId) return Promise.resolve()
|
|
124
|
+
if (!inFlight) {
|
|
125
|
+
inFlight = fetchAndApply(targetId).finally(() => {
|
|
126
|
+
inFlight = null
|
|
127
|
+
})
|
|
128
|
+
return inFlight
|
|
129
|
+
}
|
|
130
|
+
if (queued?.targetId === targetId) return queued.promise
|
|
131
|
+
// A fetch is already running and may predate this caller's write, so hand back a follow-up
|
|
132
|
+
// that starts after it rather than its result. One follow-up serves every caller that arrives
|
|
133
|
+
// during this fetch asking about the same board. `settle` swallows only the ORDERING dependency
|
|
134
|
+
// on the current attempt: the failure itself already reached that attempt's own caller, and
|
|
135
|
+
// this caller gets the outcome of the fresh fetch below.
|
|
136
|
+
const promise = inFlight.then(settle, settle).then(() => {
|
|
137
|
+
if (queued?.promise === promise) queued = null
|
|
138
|
+
// The board switched while this follow-up waited. Fetching now would read the CURRENT
|
|
139
|
+
// board's snapshot on behalf of a caller that asked about the old one, hydrate it beside
|
|
140
|
+
// the switch's own hydrate, and resolve as though the board it asked about had refreshed.
|
|
141
|
+
if (deps.currentWorkspaceId() !== targetId) return
|
|
142
|
+
return refresh()
|
|
143
|
+
})
|
|
144
|
+
queued = { targetId, promise }
|
|
145
|
+
return promise
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
refresh,
|
|
150
|
+
refreshMark: () => starts,
|
|
151
|
+
hydratedSince: (mark) => lastHydratedStart > mark,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Resolve regardless of how the awaited attempt ended: this chain sequences, it does not report. */
|
|
156
|
+
function settle(): void {}
|