Skip to content

Commit 74ca851

Browse files
authored
feat(collab-doc): If-Match optimistic concurrency so persist never clobbers an out-of-band edit (#6085)
* feat(collab-doc): optimistic-concurrency guard so persist never clobbers an out-of-band edit The relay projected the live Yjs doc back to durable markdown unconditionally (last-write-wins), so a persist already in flight when an external write landed could overwrite it. Add RFC 7232 If-Match optimistic concurrency end to end, reconciling through the CRDT (never rejecting user work): - updateWorkspaceFileContent gains an expectedUpdatedAt guard: the write commits only if the file is still at that version (checked against the SELECT ... FOR UPDATE-locked row, so it is atomic with the write), else it throws the new ContentVersionConflictError without clobbering. - persistFileDoc takes expectedVersion and returns a discriminated result (persisted | missing | conflict). On conflict it returns the current durable content + version instead of writing. - The relay tracks the durable version its live doc is synced to — set on seed, advanced when a durable write is merged in (apply-edit carries the version), and on each successful persist. It is held cluster-wide in Redis (filedoc:syncver:{name}) so whichever task persists reads the same version, with the per-room value as the single-pod fallback. - flushPersist sends that version as If-Match. On a conflict it merges the current durable content into the live doc (so the out-of-band edit AND the live edits converge) and retries (bounded), so even a last-leave flush racing an external write persists the reconciled result rather than losing the session's edits. Threads the version through the seed + persist contracts and the apply-edit payload. No schema change (reuses workspace_files.updatedAt as the version token). Tests: app-side CAS (match writes, mismatch throws + cleans up the orphan upload), relay conflict handled gracefully without clobber/loop; 236 realtime + 76 sim collab/uploads tests, tsc x2, lint, api-validation, boundaries, prune all green. * chore(collab-doc): heartbeat-refresh the synced-version key TTL alongside its stream Keep filedoc:syncver:{name} alive as long as the room's stream (it was only re-set on seed/merge/persist), so an open-but-idle doc's persist If-Match token can't expire and force a needless reconcile. * fix(collab-doc): stop persist-conflict retries when there is no live doc to reconcile On an If-Match conflict with no live doc to reconcile into (last collaborator gone, no shared stream), applyMarkdownToLiveFileDoc returns no-live-room; re-projecting the same pre-teardown snapshot would only re-conflict, so break the retry loop immediately and leave the out-of-band (durable) content authoritative — the intended conflict policy. Addresses Greptile review. * fix(collab-doc): close three optimistic-concurrency edge cases from review - Single-pod persist retry projected the pre-reconcile snapshot (captureState always returned the initial localState), while the synced version had been advanced by the reconcile — so the If-Match could pass and clobber the reconciled edit. captureState now re-reads the live doc on each attempt (falling back to the pre-teardown snapshot only once the room is gone). - The synced version was recorded from this task's own seed FETCH before knowing whether this task's seed actually won; a peer winning with a different version could leave a newer token than the stream content. Record it only inside the didSeed branch (the task whose seed won); peer-seeded tasks read the winner's cluster value. - Persist wrote UNCONDITIONALLY when no version was available (relay version momentarily missing), which could clobber non-empty durable content. It now returns conflict for a non-empty file with no version (reconcile/retry once the version is re-established); an empty file's first write stays unconditional. * fix(collab-doc): defer (not reconcile) on missing version, and use the freshest version token - Missing-version persist now returns 'deferred' instead of 'conflict'. A missing version token (a Redis blip on a peer-seeded task) is NOT a genuine out-of-band change, so triggering a reconcile would wipe live edits (incoming-wins) even though nothing changed durably. Deferred means: don't write, don't reconcile — leave the edits in the stream and let a later persist write them once the version is re-established. - currentVersion now takes the MAX of the cluster (Redis) and local room versions rather than always preferring Redis, so a lagged/failed fire-and-forget Redis set can't shadow a newer local value and cause spurious If-Match conflicts. Versions are monotonic epoch-ms, so the larger is the later sync. * fix(collab-doc): make persist If-Match teardown-race-immune and recover missing version on final flush Close two last-leave concurrency holes Cursor flagged: - Thread the reconciled version LOCALLY through the persist retry loop. After a conflict+reconcile the correct next If-Match is exactly result.version, so carry it in a local var instead of re-deriving from room.syncedVersion/Redis. On a last-leave flush destroyRoomIfIdle removes the room from the map before the async flush finishes, so mergeMarkdownIntoRoom's recordVersion can no longer update room.syncedVersion — threading makes each retry's precondition correct by construction, immune to that dropped mutation and to a best-effort Redis re-read. - Cache the resolved version back into room.syncedVersion in currentVersion() so a peer-seeded/tail-only task (which never sets it locally) or a later transient Redis read failure still resolves it from the last value seen (monotonic max, never regresses). - On a FINAL flush, briefly retry resolving the If-Match when the version read momentarily fails, rather than deferring and stranding the session's edits in the TTL'd stream — the version is cluster-wide and heartbeat-refreshed. * fix(collab-doc): stamp cluster sync version the moment the seed wins, before the liveness guard The winning seeder set the If-Match token (room + Redis filedoc:syncver) only after the liveness/seeded guard that follows seedIfEmpty. But the tailer can integrate the just-appended seed DURING the seedIfEmpty await, so isDocSeeded(room.doc) is already true when the guard runs and it returns early — leaving the stream holding seed content with no cluster version. Later persists then send no If-Match, the app returns `deferred`, and session edits stay only in the TTL'd stream (the exact stranding this PR prevents elsewhere). Move the version stamp to immediately after seedIfEmpty wins, before the guard. Recording it only once our seed won (not from the fetch) is preserved, so it still can't shadow a peer's winning seed. * fix(collab-doc): make the synced-version token monotonic at every write site The If-Match token is written fire-and-forget from the seed stamp, merges, and persists, both locally and to Redis. An out-of-order write (e.g. a seed's lagged setSyncedVersion landing after a later merge's) could regress it below the version the live doc already incorporates, causing spurious If-Match conflicts — and on a last-leave flush with no live room to reconcile into, a spurious conflict leaves durable authoritative and drops the session's edits. - setSyncedVersion now writes via SET_VERSION_IF_NEWER_SCRIPT (Redis-side compare-and-set): it overwrites only when the new value is greater, refreshing the TTL either way. - recordVersion / the persisted branch / the seed stamp all take Math.max instead of assigning room.syncedVersion directly. Versions are monotonic epoch-ms, so "newer" is a plain numeric compare, exact within a Lua double. * fix(collab-doc): close three last-leave persist edge cases from review - Stale snapshot after reconcile (High): the multi-task captureState fell back to the pre-await localState snapshot even after a reconcile advanced ifMatch, so a failed stream re-read could persist the pre-reconcile state against the new version and clobber the out-of-band edit the reconcile just incorporated. NULL localState after a reconcile so a failed read aborts instead. - Lock miss aborts reconcile (Medium): a merge-lock acquisition failure returned 'no-live-room', indistinguishable from an absent stream, so flushPersist treated transient contention as terminal. Return a distinct 'merge-unavailable' and handle it as retry-later (edits stay in the stream), never as "nothing to reconcile into". - Peer syncver never recovers (Medium): the winner's setSyncedVersion was fire-and-forget with swallowed errors — the only way a peer-seeded task learns the durable version — so a dropped write left that peer deferring forever. Make it retry (bounded) like appendUpdate/seedIfEmpty; the monotonic script keeps a racing retry a no-op. * fix(collab-doc): scope the persist If-Match to a content version so metadata bumps can't clobber edits The optimistic-concurrency validator was `updatedAt`, which rename/move/delete/restore also bump with no content change. A racing live-doc persist then saw a stale token, got `conflict`, reconciled the pre-edit durable body via updateYFragment (incoming-wins on overlap), and wiped the user's in-flight edits. Scope the validator to content (RFC 7232 semantics — validate the representation, not the row): - New `workspace_files.content_updated_at` (NOT NULL, `now()` fast-default — no table rewrite). Advances ONLY on content writes (upload / overwrite / create); metadata writes never touch it. - The FOR UPDATE CAS, the merge-notify version, and the seed version all use `content_updated_at`. A rename now leaves it unchanged, so the persist If-Match still matches -> no spurious conflict, no reconcile, no lost edits. Genuine out-of-band content writes still conflict and reconcile. - Consolidated the collab schema into one migration (the collab-state table + the new column) per request, rather than a separate follow-up migration. Relay/store/contracts unchanged (still a numeric monotonic version). * chore(collab-doc): condense the densest persist comments (no behavior change) Cleanup pass: tighten the three longest comment blocks added while hardening the persist path (currentVersion cache, ifMatch threading, final-flush version retry) without dropping any invariant. No dead code found (biome lint clean; all new symbols referenced). * fix(collab-doc): persist must return the content version, not updatedAt Follow-up to the content-scoped If-Match: persistFileDoc still returned `updatedAt` as the version in both the persisted and conflict results, while the CAS/seed/merge all guard on `content_updated_at`. A content write sets both to the same instant, so it was coincidentally correct — until they diverge: if a metadata write bumps `updatedAt` past `content_updated_at`, the conflict path returned the larger `updatedAt`, so the relay's re-persist sent an If-Match the CAS (which checks `content_updated_at`) could never match → perpetual conflict → dropped reconciled edits. Return `contentUpdatedAt` in both paths so the relay's token always matches what it's checked against. * fix(collab-doc): defer persist whenever the version is missing; guard the content-version test - Empty-file CAS race (Medium): the unconditional-write carve-out for size===0 read `record.size` outside the write transaction, so a concurrent first content write could land after the check and be clobbered. With content_updated_at NOT NULL every existing file always has a real version, so a missing expectedVersion is always transient — always defer, never write unconditionally. Removes the TOCTOU hole. - Content-version test (Low): the merge-chokepoint test kept updatedAt == contentUpdatedAt, so it passed even if wired to the wrong field. Mock distinct values and assert contentUpdatedAt, so a regression to updatedAt now fails the test. * fix(collab-doc): don't reconcile a conflict the live doc already reflects (would wipe newer edits) flushPersist reconciled the durable body into the live doc on every conflict. But when the conflict comes from a racing self-persist (or an apply-edit the chokepoint already merged), the durable body is a STALE SUBSET of the live stream, and the incoming-wins updateYFragment merge moves the doc backward — wiping newer in-flight edits, which the retry then persists. Before reconciling, re-check the freshest synced version. If it already covers the conflict version, the live doc has already incorporated that content (or is ahead), so skip the reconcile and just retry with the freshest version as If-Match — the re-projection captures the current live stream, preserving every edit. Only a genuine out-of-band change the live doc hasn't incorporated (freshest < conflict version) is reconciled in. freshest never exceeds the durable version, so this can't loop. * fix(collab-doc): make content_updated_at monotonic per file; skip-reconcile can't loop The If-Match token was stamped with app-local new Date() on each content write, so cross-instance clock skew could stamp a later write with an EARLIER content_updated_at — breaking the version ordering the whole optimistic-concurrency scheme (and the skip-reconcile branch's freshest>=version assumption) depends on. Under skew the relay's monotonic syncedVersion could exceed the durable version, sticking the If-Match: persist conflicts forever, exhausts retries, drops the session's edits. - Stamp content_updated_at strictly after the current committed value (we hold the row's FOR UPDATE lock): new Date(max(now, currentFile.contentUpdatedAt + 1ms)). Monotonic per file regardless of clocks; also removes same-millisecond collisions. updatedAt stays plain wall-clock (display/sort). - Skip-reconcile branch retries with result.version (the durable value the CAS will match), never freshest (which could exceed it and loop). Belt-and-suspenders now that the version is monotonic. * refactor(collab-doc): drop the destructive in-persist reconcile; adopt-version-and-retry on conflict The in-persist reconcile projected the durable body back over the live doc via updateYFragment ("make the doc match"). That is destructive: when the live stream is already ahead — the common case, because the write chokepoint (mergeEditIntoLiveFileDoc) already merged the out-of-band change into the stream — it moved the doc backward and wiped newer in-flight edits. This produced a run of races (stale snapshot, wipe-newer-edits, version-lag skip miss) that a full-document reconcile fundamentally can't avoid, since deciding when it's safe relies on a laggy cross-task version token. Remove it. On conflict, adopt the durable version as the new If-Match and retry: captureState re-reads the current stream (which holds the out-of-band change AND the live edits), so the re-projection persists the converged result. The durable change reaches the live doc via the chokepoint, never here. Trade-off: the only unmerged out-of-band write is one whose chokepoint merge itself failed (rare, logged), which we accept over the frequent reconcile-wipes-edits race. - flushPersist: conflict -> ifMatch = result.version, retry (bounded). No applyMarkdownToLiveFileDoc. - conflict response drops `markdown` (contract + relay type + persist) — no body needed, saves a blob fetch. applyMarkdownToLiveFileDoc stays (still used by the apply-edit route / the chokepoint). * fix(collab-doc): don't let a last-leave conflict retry clobber via the stale local snapshot Regression from dropping the reconcile: on conflict the retry adopts result.version and re-reads captureState. But after single-pod last-leave teardown the room is already destroyed, so captureState falls back to the pre-teardown localState (which lacks the out-of-band change); the retry then CAS-passes and overwrites the committed external write — undoing the external-wins last-leave policy. Null localState on the first conflict, so the retry can only use freshly-read authoritative state (stream / live doc). When none is available (single-pod room gone, or a transient stream-read failure) captureState returns null and the retry stops, leaving durable content authoritative. Covers both the single-pod and multi-task-stream-unavailable variants of the stale-snapshot clobber. * fix(collab-doc): stop (don't re-persist) on a persist conflict — closes the commit-window clobber The conflict retry adopted the durable version and immediately re-persisted the current stream, assuming the stream already held the out-of-band change. But an external write commits durable BEFORE its chokepoint merge (mergeEditIntoLiveFileDoc) reaches the stream, so a persist landing in that window CAS-passed with a stream that still lacked the external content and clobbered the committed write — not just the rare merge-failed path, but a race on every external write, worst at last-leave flushes. Make persist a single attempt: on conflict, STOP and leave durable authoritative. The chokepoint merges the change into the stream and — only once it is actually there — advances the synced version via its own recordVersion; a later flush (debounced or final) then projects the converged stream with a matching token. The session's edits stay in the stream meanwhile. The conflict handler deliberately does NOT advance the synced version, or the next flush would clobber with a still-behind stream. Removes the retry loop and PERSIST_CONFLICT_RETRIES.
1 parent 525c340 commit 74ca851

19 files changed

Lines changed: 633 additions & 109 deletions

File tree

apps/realtime/src/handlers/file-doc-app.ts

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ function postToApp(path: string, payload: unknown, timeoutMs: number): Promise<R
2626
export async function fetchFileDocSeed(
2727
workspaceId: string,
2828
fileId: string
29-
): Promise<Uint8Array | null> {
29+
): Promise<{ update: Uint8Array; version: number } | null> {
3030
const response = await postToApp(
3131
'/api/internal/file-doc/seed',
3232
{ workspaceId, fileId },
@@ -35,16 +35,16 @@ export async function fetchFileDocSeed(
3535
if (!response.ok) {
3636
throw new Error(`Seed fetch failed for file ${fileId}: ${response.status}`)
3737
}
38-
const body = (await response.json()) as { update?: unknown }
38+
const body = (await response.json()) as { update?: unknown; version?: unknown }
3939
const update = body?.update
40-
// A well-formed response is `{ update: base64-string | null }`. Anything else is a contract
41-
// violation, not a "genuinely empty file" — throw so the caller retries rather than silently
42-
// treating a malformed body as empty and stranding the room unseeded.
40+
// A well-formed response is `{ update: base64-string | null, version: number | null }`. Anything
41+
// else is a contract violation, not a "genuinely empty file" — throw so the caller retries rather
42+
// than silently treating a malformed body as empty and stranding the room unseeded.
4343
if (update === null) return null
44-
if (typeof update !== 'string') {
44+
if (typeof update !== 'string' || typeof body?.version !== 'number') {
4545
throw new Error(`Seed fetch for file ${fileId} returned a malformed body`)
4646
}
47-
return new Uint8Array(Buffer.from(update, 'base64'))
47+
return { update: new Uint8Array(Buffer.from(update, 'base64')), version: body.version }
4848
}
4949

5050
/**
@@ -73,25 +73,55 @@ export async function fetchFileDocMerge(
7373
return new Uint8Array(Buffer.from(body.update, 'base64'))
7474
}
7575

76+
/**
77+
* Result of a persist attempt (mirrors the app's `persistFileDoc` contract):
78+
* - `persisted` — written; `version` is the new durable version the relay records as synced.
79+
* - `missing` — the file is gone.
80+
* - `conflict` — the file changed out-of-band since `expectedVersion`; NOT written. `version` is the
81+
* current durable version the relay adopts as its new If-Match to re-persist the current live stream.
82+
*/
83+
export type PersistResult =
84+
| { status: 'persisted'; version: number }
85+
| { status: 'missing' }
86+
| { status: 'conflict'; version: number }
87+
| { status: 'deferred' }
88+
7689
/**
7790
* Ask the app to project a live collaborative document back to durable markdown and write it to the
78-
* file (Yjs → markdown, through the exact editor engine). This is the server-authoritative durable
79-
* path — called debounced while the doc is edited and when the last collaborator leaves — that
80-
* replaces the editor's client autosave, so a server/copilot edit can't be clobbered by a stale
81-
* keystroke. THROWS on a transport failure so the caller can log/retry on the next debounce.
91+
* file (Yjs → markdown, through the exact editor engine)the server-authoritative durable path that
92+
* replaces the editor's client autosave. `expectedVersion` (the durable version the live doc synced
93+
* from) is the optimistic-concurrency guard: on a mismatch the app returns `conflict` (rather than
94+
* clobbering) so the caller reconciles and retries. THROWS only on a transport/contract failure.
8295
*/
8396
export async function fetchFileDocPersist(
8497
workspaceId: string,
8598
fileId: string,
8699
userId: string,
87-
docState: Uint8Array
88-
): Promise<void> {
100+
docState: Uint8Array,
101+
expectedVersion?: number
102+
): Promise<PersistResult> {
89103
const response = await postToApp(
90104
'/api/internal/file-doc/persist',
91-
{ workspaceId, fileId, userId, docState: Buffer.from(docState).toString('base64') },
105+
{
106+
workspaceId,
107+
fileId,
108+
userId,
109+
docState: Buffer.from(docState).toString('base64'),
110+
...(expectedVersion !== undefined ? { expectedVersion } : {}),
111+
},
92112
FILE_DOC_TIMEOUTS.persistRequestMs
93113
)
94114
if (!response.ok) {
95115
throw new Error(`Persist failed for file ${fileId}: ${response.status}`)
96116
}
117+
const body = (await response.json()) as PersistResult
118+
if (
119+
body?.status !== 'persisted' &&
120+
body?.status !== 'missing' &&
121+
body?.status !== 'conflict' &&
122+
body?.status !== 'deferred'
123+
) {
124+
throw new Error(`Persist for file ${fileId} returned a malformed body`)
125+
}
126+
return body
97127
}

apps/realtime/src/handlers/file-doc-store.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,18 @@ const RELEASE_LOCK_SCRIPT =
6161
const SEED_IF_EMPTY_SCRIPT =
6262
"if redis.call('xlen', KEYS[1]) == 0 then redis.call('xadd', KEYS[1], '*', ARGV[1], ARGV[2]); return 1 else return 0 end"
6363

64+
/**
65+
* Monotonic set of the synced-version token: overwrite ONLY when the new value is greater than the
66+
* stored one (or none is stored). The token is written fire-and-forget from multiple sites (seed stamp,
67+
* merge, persist) and across tasks, so an out-of-order write must never REGRESS it to a version older
68+
* than the live doc already incorporates — a regressed token causes spurious If-Match conflicts and, on a
69+
* last-leave flush with no live room to reconcile into, a lost persist. Refreshes the TTL on both paths
70+
* so a write skipped as older still keeps the (higher) value alive. Versions are monotonic epoch-ms,
71+
* comfortably within a Lua double, so the numeric compare is exact.
72+
*/
73+
const SET_VERSION_IF_NEWER_SCRIPT =
74+
"local c = redis.call('get', KEYS[1]); if c == false or tonumber(c) < tonumber(ARGV[1]) then redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[2]) else redis.call('expire', KEYS[1], ARGV[2]) end; return 1"
75+
6476
/**
6577
* The transaction origin the store stamps on updates it applies from the stream. The relay's
6678
* `doc.on('update')` handler uses it to distinguish an update that ARRIVED from a peer (fan out to
@@ -79,6 +91,8 @@ export const REDIS_ORIGIN = Symbol('file-doc-redis')
7991
export const REDIS_SNAPSHOT_ORIGIN = Symbol('file-doc-redis-snapshot')
8092

8193
const STREAM_PREFIX = 'filedoc:stream:'
94+
/** Cluster-wide "durable version the live doc is synced to" (the persist If-Match token). */
95+
const SYNC_VERSION_PREFIX = 'filedoc:syncver:'
8296
const SEED_LOCK_PREFIX = 'filedoc:seedlock:'
8397
const COMPACT_LOCK_PREFIX = 'filedoc:compactlock:'
8498
const PERSIST_LOCK_PREFIX = 'filedoc:persistlock:'
@@ -444,6 +458,56 @@ export class FileDocStore {
444458
return this.acquireLock(`${MERGE_LOCK_PREFIX}${name}`, ttlMs)
445459
}
446460

461+
/**
462+
* The durable file version (its `updatedAt`, epoch ms) the shared live doc is synced to — the
463+
* cluster-wide {@link https://www.rfc-editor.org/rfc/rfc7232 `If-Match`} token for persistence. Held
464+
* in Redis (not per-task room state) so whichever task runs a debounced/last-leave persist reads the
465+
* SAME version, even though the write that advanced it (a seed or a merged edit) may have run on
466+
* another task. Returns `null` when unset/expired (persist then falls back to the local room's value).
467+
*/
468+
async getSyncedVersion(name: string): Promise<number | null> {
469+
if (!this.enabled || !this.write) return null
470+
try {
471+
const value = await this.write.get(`${SYNC_VERSION_PREFIX}${name}`)
472+
const parsed = value === null ? Number.NaN : Number(value)
473+
return Number.isFinite(parsed) ? parsed : null
474+
} catch (error) {
475+
logger.warn(`FileDocStore getSyncedVersion failed for ${name}`, {
476+
error: getErrorMessage(error),
477+
})
478+
return null
479+
}
480+
}
481+
482+
/** Record the durable version the shared live doc is now synced to. MONOTONIC — writes only when the
483+
* new value exceeds the stored one ({@link SET_VERSION_IF_NEWER_SCRIPT}), so an out-of-order
484+
* fire-and-forget write can't regress the token. Best-effort; TTL-bounded like the stream so an idle
485+
* file's key can't outlive its room. No-op when disabled (single-pod fallback). */
486+
async setSyncedVersion(name: string, version: number): Promise<void> {
487+
if (!this.enabled || !this.write) return
488+
// Retry a transient failure (bounded) rather than swallow it: this token is the ONLY way a
489+
// peer-seeded task learns the durable version, so a dropped write would leave that peer's persists
490+
// deferring forever with the session's edits stranded in the TTL'd stream. The monotonic script makes
491+
// a retry that races a newer value a no-op, never a regression.
492+
for (let attempt = 0; attempt <= PUBLISH_MAX_RETRIES; attempt++) {
493+
try {
494+
await this.write.eval(SET_VERSION_IF_NEWER_SCRIPT, {
495+
keys: [`${SYNC_VERSION_PREFIX}${name}`],
496+
arguments: [String(version), String(STREAM_TTL_SEC)],
497+
})
498+
return
499+
} catch (error) {
500+
if (attempt === PUBLISH_MAX_RETRIES) {
501+
logger.warn(`FileDocStore setSyncedVersion failed for ${name}`, {
502+
error: getErrorMessage(error),
503+
})
504+
return
505+
}
506+
await sleep(backoffWithJitter(attempt + 1, null, { baseMs: 50, maxMs: 500 }))
507+
}
508+
}
509+
}
510+
447511
async releaseMergeSlot(name: string, token: string): Promise<void> {
448512
await this.releaseLock(`${MERGE_LOCK_PREFIX}${name}`, token)
449513
}
@@ -534,6 +598,9 @@ export class FileDocStore {
534598
if (!this.write) return
535599
for (const name of this.rooms.keys()) {
536600
await this.write.expire(streamKey(name), STREAM_TTL_SEC).catch(() => {})
601+
// Keep the synced-version key alive as long as its stream, so an open-but-idle doc's persist
602+
// If-Match token can't expire out from under it (which would force a needless reconcile).
603+
await this.write.expire(`${SYNC_VERSION_PREFIX}${name}`, STREAM_TTL_SEC).catch(() => {})
537604
}
538605
}
539606
}

apps/realtime/src/handlers/file-doc.test.ts

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -130,11 +130,11 @@ async function flushMicrotasks(): Promise<void> {
130130
* An encoded Yjs update shaped like the server seed builder's output: some content in the shared
131131
* `default` type plus the {@link FILE_DOC_SEED} flag, so applying it marks the doc seeded.
132132
*/
133-
function encodedSeedUpdate(content: string): Uint8Array {
133+
function seedResult(content: string): { update: Uint8Array; version: number } {
134134
const doc = new Y.Doc()
135135
doc.getText(FILE_DOC_FIELD).insert(0, content)
136136
doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true)
137-
return Y.encodeStateAsUpdate(doc)
137+
return { update: Y.encodeStateAsUpdate(doc), version: 1 }
138138
}
139139

140140
/** Apply a server sync reply frame (`[SYNC tag][sync message]`) into a fresh client doc. */
@@ -189,6 +189,8 @@ describe('setupWorkspaceFileDocHandlers', () => {
189189
// Default: the merge builder returns a valid no-op (empty-doc) update. Tests exercising copilot
190190
// merges override it.
191191
mockFetchFileDocMerge.mockResolvedValue(Y.encodeStateAsUpdate(new Y.Doc()))
192+
// Default: persist succeeds. Tests asserting conflict/reconcile override this per-case.
193+
mockFetchFileDocPersist.mockResolvedValue({ status: 'persisted', version: 1 })
192194
})
193195

194196
afterEach(() => {
@@ -262,7 +264,7 @@ describe('setupWorkspaceFileDocHandlers', () => {
262264
})
263265

264266
it('does NOT persist a seeded-but-unedited doc on last disconnect (no clobber of a concurrent write)', async () => {
265-
mockFetchFileDocSeed.mockResolvedValue(encodedSeedUpdate('# From server'))
267+
mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server'))
266268
const { io } = createIo()
267269
const { handlers } = setup('socket-1', io)
268270
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
@@ -276,7 +278,7 @@ describe('setupWorkspaceFileDocHandlers', () => {
276278
})
277279

278280
it('persists on last disconnect once a genuine user edit has landed', async () => {
279-
mockFetchFileDocSeed.mockResolvedValue(encodedSeedUpdate('# From server'))
281+
mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server'))
280282
const { io } = createIo()
281283
const { handlers } = setup('socket-1', io)
282284
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
@@ -297,8 +299,39 @@ describe('setupWorkspaceFileDocHandlers', () => {
297299
expect(mockFetchFileDocPersist).toHaveBeenCalled()
298300
})
299301

302+
it('stops on a persist conflict without clobbering (single attempt, durable left authoritative)', async () => {
303+
mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server'))
304+
// A persist reports an out-of-band change (If-Match conflict). The relay must NOT re-persist against
305+
// the current stream (the external write commits durable before its chokepoint merge lands, so a
306+
// re-persist could clobber it) — it stops after a single attempt and leaves the durable file
307+
// authoritative; a later flush projects the converged stream once the merge lands.
308+
mockFetchFileDocPersist.mockResolvedValue({
309+
status: 'conflict',
310+
version: 999,
311+
})
312+
const { io } = createIo()
313+
const { handlers } = setup('socket-1', io)
314+
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
315+
await flushMicrotasks()
316+
317+
const edit = new Y.Doc()
318+
edit.getText(FILE_DOC_FIELD).insert(0, 'user typed this')
319+
handlers[FILE_DOC_EVENTS.MESSAGE](
320+
frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) =>
321+
syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(edit))
322+
)
323+
)
324+
await flushMicrotasks()
325+
326+
// The conflict is handled gracefully: the persist is attempted exactly once (never silently skipped,
327+
// never retried against a possibly-behind stream) and the durable file is left authoritative.
328+
cleanupFileDocForSocket('socket-1', io, true)
329+
await flushMicrotasks()
330+
expect(mockFetchFileDocPersist).toHaveBeenCalledTimes(1)
331+
})
332+
300333
it('flushAllFileDocRooms persists open EDITED rooms (graceful shutdown), skips unedited', async () => {
301-
mockFetchFileDocSeed.mockResolvedValue(encodedSeedUpdate('# From server'))
334+
mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server'))
302335
const { io } = createIo()
303336
const { handlers } = setup('socket-1', io)
304337
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
@@ -324,7 +357,7 @@ describe('setupWorkspaceFileDocHandlers', () => {
324357
})
325358

326359
it('joins the room, sends sync step 1, and seeds the document from the server', async () => {
327-
mockFetchFileDocSeed.mockResolvedValue(encodedSeedUpdate('# From server'))
360+
mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server'))
328361
const { io } = createIo()
329362
const { socket, handlers } = setup('socket-1', io)
330363

@@ -360,7 +393,7 @@ describe('setupWorkspaceFileDocHandlers', () => {
360393
it('seeds the document only once from the server across concurrent joiners of the same file', async () => {
361394
// Keep the first seed fetch IN FLIGHT so the doc is still unseeded when the second socket joins:
362395
// that forces the dedup onto `serverSeedStarted` (the in-flight guard) rather than `isDocSeeded`.
363-
let resolveSeed: (v: Uint8Array | null) => void = () => {}
396+
let resolveSeed: (v: { update: Uint8Array; version: number } | null) => void = () => {}
364397
mockFetchFileDocSeed.mockReturnValueOnce(new Promise((resolve) => (resolveSeed = resolve)))
365398
const { io } = createIo()
366399
const a = setup('socket-a', io)
@@ -370,7 +403,7 @@ describe('setupWorkspaceFileDocHandlers', () => {
370403
await b.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 2 })
371404
// Second join happened with the fetch still pending; only after this does the seed land.
372405
expect(mockFetchFileDocSeed).toHaveBeenCalledTimes(1)
373-
resolveSeed(encodedSeedUpdate('# From server'))
406+
resolveSeed(seedResult('# From server'))
374407
await flushMicrotasks()
375408
expect(mockFetchFileDocSeed).toHaveBeenCalledTimes(1)
376409
})
@@ -401,7 +434,7 @@ describe('setupWorkspaceFileDocHandlers', () => {
401434
it('makes one seed attempt and releases the guard on failure so a later join retries', async () => {
402435
mockFetchFileDocSeed
403436
.mockRejectedValueOnce(new Error('transport blip'))
404-
.mockResolvedValueOnce(encodedSeedUpdate('# Recovered'))
437+
.mockResolvedValueOnce(seedResult('# Recovered'))
405438
const { io } = createIo()
406439
const { socket, handlers } = setup('socket-1', io)
407440

@@ -428,7 +461,7 @@ describe('setupWorkspaceFileDocHandlers', () => {
428461
})
429462

430463
it('does not seed a room that was dropped while the seed fetch was in flight', async () => {
431-
let resolveSeed: (v: Uint8Array | null) => void = () => {}
464+
let resolveSeed: (v: { update: Uint8Array; version: number } | null) => void = () => {}
432465
mockFetchFileDocSeed.mockReturnValueOnce(new Promise((resolve) => (resolveSeed = resolve)))
433466
const { io } = createIo()
434467
const { handlers } = setup('socket-1', io)
@@ -437,7 +470,7 @@ describe('setupWorkspaceFileDocHandlers', () => {
437470
// The only owner leaves → the room (and its doc) is destroyed while the fetch is still pending.
438471
cleanupFileDocForSocket('socket-1', io, true)
439472
// Resolving now must not touch the destroyed doc or throw (liveness re-check after the await).
440-
resolveSeed(encodedSeedUpdate('# Too late'))
473+
resolveSeed(seedResult('# Too late'))
441474
await expect(flushMicrotasks()).resolves.toBeUndefined()
442475
})
443476

@@ -447,7 +480,7 @@ describe('setupWorkspaceFileDocHandlers', () => {
447480
// edits are readiness-gated), but even if some update landed content in the doc before the seed
448481
// fetch resolved, the seed must still apply and set the flag — or the client's
449482
// `synced && initialContentLoaded` gate would never open.
450-
let resolveSeed: (v: Uint8Array | null) => void = () => {}
483+
let resolveSeed: (v: { update: Uint8Array; version: number } | null) => void = () => {}
451484
mockFetchFileDocSeed.mockReturnValueOnce(new Promise((resolve) => (resolveSeed = resolve)))
452485
const { io } = createIo()
453486
const { socket, handlers } = setup('socket-1', io)
@@ -461,7 +494,7 @@ describe('setupWorkspaceFileDocHandlers', () => {
461494
syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(placeholder))
462495
)
463496
)
464-
resolveSeed(encodedSeedUpdate('# Seeded'))
497+
resolveSeed(seedResult('# Seeded'))
465498
await flushMicrotasks()
466499

467500
socket.emit.mockClear()
@@ -478,7 +511,7 @@ describe('setupWorkspaceFileDocHandlers', () => {
478511
})
479512

480513
it('merges a copilot edit into a seeded live room and relays it to editors', async () => {
481-
mockFetchFileDocSeed.mockResolvedValue(encodedSeedUpdate('# Original'))
514+
mockFetchFileDocSeed.mockResolvedValue(seedResult('# Original'))
482515
const { io, sent } = createIo()
483516
const { handlers } = setup('socket-1', io)
484517
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
@@ -511,7 +544,7 @@ describe('setupWorkspaceFileDocHandlers', () => {
511544
})
512545

513546
it('serializes concurrent merges for the same file (second waits for the first)', async () => {
514-
mockFetchFileDocSeed.mockResolvedValue(encodedSeedUpdate('# Original'))
547+
mockFetchFileDocSeed.mockResolvedValue(seedResult('# Original'))
515548
const { io } = createIo()
516549
const { handlers } = setup('socket-1', io)
517550
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })

0 commit comments

Comments
 (0)