fix: serialize persistence ownership selection
This commit is contained in:
@@ -255,10 +255,13 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
|||||||
* @param id - the persisted session to reload.
|
* @param id - the persisted session to reload.
|
||||||
* @returns the header plus the event log, ending on a balanced `turn/end`.
|
* @returns the header plus the event log, ending on a balanced `turn/end`.
|
||||||
*/
|
*/
|
||||||
load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
async load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||||
const live = this.ctx.sessions.get(id)
|
const selected = await this.serialize(id, async () => {
|
||||||
if (live !== undefined) return this.loadLiveSnapshot(live)
|
const live = this.ctx.sessions.get(id)
|
||||||
return this.serialize(id, () => this.loadCore(id))
|
if (live !== undefined) return { live }
|
||||||
|
return { loaded: await this.loadCore(id) }
|
||||||
|
})
|
||||||
|
return 'loaded' in selected ? selected.loaded : this.loadLiveSnapshot(selected.live)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
private async loadCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||||
|
|||||||
@@ -121,6 +121,39 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rechecks live ownership after a cold load enters the per-id chain', async () => {
|
||||||
|
const fix = await makeFixture()
|
||||||
|
const { ctx, fiber } = await freshCtx(fix)
|
||||||
|
try {
|
||||||
|
const id = SessionId('queued-load-live-race')
|
||||||
|
const header = meta(id, WORK)
|
||||||
|
const start: SessionEvent = {
|
||||||
|
type: 'turn/start',
|
||||||
|
seq: 0,
|
||||||
|
time: 1,
|
||||||
|
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||||
|
}
|
||||||
|
await ctx.sessionPersistence.create(header)
|
||||||
|
await ctx.sessionPersistence.append(id, [start])
|
||||||
|
|
||||||
|
const loading = ctx.sessionPersistence.load(id)
|
||||||
|
const live = ctx.sessions.create(id, { seed: [start], meta: header })
|
||||||
|
await expect(loading).rejects.toThrow(/live turn is open/)
|
||||||
|
|
||||||
|
live.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||||
|
await ctx.sessions.flush(live)
|
||||||
|
const loaded = await ctx.sessionPersistence.load(id)
|
||||||
|
expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'turn/end'])
|
||||||
|
expect(loaded.events.at(-1)).toMatchObject({
|
||||||
|
type: 'turn/end',
|
||||||
|
data: { reason: { kind: 'completed' } },
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
await fiber.dispose()
|
||||||
|
await fix.cleanup()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('does not load an unmaterialized empty live session', async () => {
|
it('does not load an unmaterialized empty live session', async () => {
|
||||||
const fix = await makeFixture()
|
const fix = await makeFixture()
|
||||||
const { ctx, fiber } = await freshCtx(fix)
|
const { ctx, fiber } = await freshCtx(fix)
|
||||||
|
|||||||
@@ -354,6 +354,7 @@ describe('PersistenceCoordinator retirement', () => {
|
|||||||
coordinator = new PersistenceCoordinator(inner, backend)
|
coordinator = new PersistenceCoordinator(inner, backend)
|
||||||
}, { inject: ['sessions'] }))
|
}, { inject: ['sessions'] }))
|
||||||
const appendGate = Promise.withResolvers<boolean>()
|
const appendGate = Promise.withResolvers<boolean>()
|
||||||
|
const loadGate = Promise.withResolvers<boolean>()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const id = SessionId('retiring-buffered-owner')
|
const id = SessionId('retiring-buffered-owner')
|
||||||
@@ -367,15 +368,20 @@ describe('PersistenceCoordinator retirement', () => {
|
|||||||
first.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
first.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||||
await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) })
|
await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) })
|
||||||
await firstFiber.dispose()
|
await firstFiber.dispose()
|
||||||
|
const baselineLoads = backend.loadAttempts
|
||||||
|
backend.beforeLoadStored = async () => { await loadGate.promise }
|
||||||
const coldLoad = coordinator.load(id)
|
const coldLoad = coordinator.load(id)
|
||||||
|
|
||||||
|
appendGate.resolve(true)
|
||||||
|
await vi.waitFor(() => { expect(backend.loadAttempts).toBe(baselineLoads + 1) })
|
||||||
|
|
||||||
let reuse!: Session
|
let reuse!: Session
|
||||||
await ctx.plugin(Object.assign((inner: Context) => {
|
await ctx.plugin(Object.assign((inner: Context) => {
|
||||||
reuse = inner.sessions.create(id)
|
reuse = inner.sessions.create(id)
|
||||||
}, { inject: ['sessions'] }))
|
}, { inject: ['sessions'] }))
|
||||||
const reuseFlush = ctx.sessions.flush(reuse)
|
const reuseFlush = ctx.sessions.flush(reuse)
|
||||||
|
|
||||||
appendGate.resolve(true)
|
loadGate.resolve(true)
|
||||||
await expect(coldLoad).resolves.toMatchObject({
|
await expect(coldLoad).resolves.toMatchObject({
|
||||||
events: [{ seq: 0 }, { seq: 1 }],
|
events: [{ seq: 0 }, { seq: 1 }],
|
||||||
})
|
})
|
||||||
@@ -385,6 +391,7 @@ describe('PersistenceCoordinator retirement', () => {
|
|||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
appendGate.resolve(true)
|
appendGate.resolve(true)
|
||||||
|
loadGate.resolve(true)
|
||||||
await backendFiber.dispose()
|
await backendFiber.dispose()
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user