Skip to content

Close race window in StoreService.patch() between snapshot and write-back#5385

Merged
richardhjtan merged 2 commits into
mainfrom
cs-11609-listing-create-race-condition-bug
Jul 13, 2026
Merged

Close race window in StoreService.patch() between snapshot and write-back#5385
richardhjtan merged 2 commits into
mainfrom
cs-11609-listing-create-race-condition-bug

Conversation

@richardhjtan

@richardhjtan richardhjtan commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Summary

There's a catalog listing-create command that creates a listing card, then kicks off about nine background tasks concurrently against that same card to auto-populate it — name, summary, tags, categories, license, examples, and specs get set directly, while a screenshot and thumbnail get attached via PatchCardInstanceCommandstore.patch(). Fields were silently disappearing after listing creation — a tag or category set by one task would vanish once another task's patch landed later.

The cause is in StoreService.patch(): it snapshots the instance, resolves the patch's relationship target (a network fetch), then reapplies the snapshot wholesale — so whatever another task wrote to the same instance during that fetch gets reverted when the stale snapshot writes back.

This reorders the method to resolve the relationship before taking the snapshot instead of after, so the snapshot can't go stale. No behavior change for the non-concurrent case.

Why patch() resolves relationships at all

This isn't required to persist the patch — the server only needs the pointer, not the target's content. It's there because LinksTo.deserialize()'s lazy fallback (cache → included[]not-loaded sentinel) assumes a live UI template that re-renders once the real value arrives, but most patch() callers are Commands — plain code with no equivalent "wait for it." Without the eager load, a patched relationship would come back as an unresolved placeholder for any caller whose target isn't already cached, with no automatic recovery.

This PR is intentionally scoped to the concurrency bug rather than also removing that eager load — patch()'s relationships path has other callers (the AI chat edit tools, code-mode field editing, submission-retry logic) that haven't been audited for whether they assume synchronous hydration.

Test plan

  • Added an integration test that gates the relationship-resolution step on a controlled Deferred, mutates an unrelated field on the same live instance while patch() is in flight, and asserts that field survives.
  • Verified both directions locally: reverting just the reorder in store.ts fails the test (actual: "Hassan", expected: "Race Winner"); with the reorder in place, it passes.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a3298f3af0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/host/tests/integration/store-test.gts
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

    1 files      1 suites   2h 57m 27s ⏱️
3 470 tests 3 455 ✅ 15 💤 0 ❌
3 489 runs  3 474 ✅ 15 💤 0 ❌

Results for commit 1b97c4d.

Realm Server Test Results

    1 files  ±  0      1 suites  ±0   11m 29s ⏱️ + 1m 54s
1 815 tests +141  1 815 ✅ +141  0 💤 ±0  0 ❌ ±0 
1 894 runs  +141  1 894 ✅ +141  0 💤 ±0  0 ❌ ±0 

Results for commit 1b97c4d. ± Comparison against earlier commit 96f3e98.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a concurrency hazard in StoreService.patch() where a long-running linked-relationship resolution step could occur after taking the serialized snapshot used for merge + write-back, allowing concurrent writes to the same instance to be reverted by the stale snapshot.

Changes:

  • Reorders StoreService.patch() to resolve linked-card relationships before snapshotting/serializing the target instance, shrinking the race window that could clobber concurrent field writes.
  • Adds an integration test that forces patch() to pause during relationship resolution, performs a concurrent write to another field, and asserts that the write survives the patch.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
packages/host/app/services/store.ts Reorders relationship resolution to happen before the snapshot/serialize step in StoreService.patch().
packages/host/tests/integration/store-test.gts Adds a regression test covering a concurrent field write during store.patch() while relationship resolution is artificially delayed.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/host/tests/integration/store-test.gts
@habdelra

habdelra commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

can you provide more context for the reason of this PR? why do we need to load links when we are doing a document patch? linked fields are not part of the patch payload, these are side loaded data. the document being patched should be able to be patched without loading links at all. (a patch should not touch the included[] part of the JSON:API documetn only the field is truly a brand new instance you already have it locally and are supplying a local id.

@richardhjtan richardhjtan marked this pull request as draft July 7, 2026 13:49
richardhjtan and others added 2 commits July 13, 2026 09:31
…rite-back

patch() snapshotted the instance's full document before resolving linked
relationships (a real network gap), then reapplied that snapshot wholesale
afterward. Any field written on the same live instance by other code during
that gap was silently reverted when the stale snapshot landed. Resolving
linked relationships first, then snapshotting immediately before the
write-back, closes the window.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…atch()

The write ran synchronously right after invoking patch(), before the
function had actually reached the relationship-load step it awaits on —
patch() yields at its own initial instance lookup first. That let the write
land before either the old or new snapshot point, so the test could pass
without the fix under test. Wait for a signal fired from inside the stub
once relationship loading is genuinely paused, then write.

Also restore the literal original loadPatchedInstances reference in the
finally block instead of a rebound copy of it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@richardhjtan

Copy link
Copy Markdown
Contributor Author

can you provide more context for the reason of this PR? why do we need to load links when we are doing a document patch? linked fields are not part of the patch payload, these are side loaded data. the document being patched should be able to be patched without loading links at all. (a patch should not touch the included[] part of the JSON:API documetn only the field is truly a brand new instance you already have it locally and are supplying a local id.

Sure @habdelra

There is a catalog listing create command that creates a listing card, then kicks off about nine background tasks concurrently against that same card to auto-populate it — name, summary, tags, categories, license, examples and specs get set directly, while a screenshot and thumbnail get attached via PatchCardInstanceCommand → store.patch(). I was seeing fields silently disappear after listing creation — a tag or category set by one task would vanish once another task's patch landed later. I traced it to store.patch(): it snapshots the instance, resolves the patch's relationship target (a network fetch), then reapplies the snapshot wholesale — so whatever another task wrote to the same instance during that fetch gets reverted when the stale snapshot writes back. That's what this PR fixes: resolving the relationship before taking the snapshot instead of after, so the snapshot can't go stale.

On why patch() resolves the relationship at all — you're right it's not needed to persist the patch, the server only needs the pointer. It's there because LinksTo.deserialize()'s lazy fallback (cache → included[] → not-loaded sentinel) assumes a live UI template that re-renders once the real value arrives, but most patch() callers are Commands — plain code with no equivalent "wait for it." Without the eager load, a patched relationship would come back as an unresolved placeholder for any caller that doesn't already have the target cached, with no automatic recovery.

I intentionally kept this PR scoped to the concurrency bug rather than also removing that eager load — patch()'s relationships path has other callers (the AI chat edit tools, code-mode field editing, submission-retry logic) I haven't audited for whether they assume synchronous hydration

@richardhjtan richardhjtan force-pushed the cs-11609-listing-create-race-condition-bug branch from 96f3e98 to 1b97c4d Compare July 13, 2026 03:23
@richardhjtan richardhjtan marked this pull request as ready for review July 13, 2026 04:03
@richardhjtan richardhjtan requested a review from a team July 13, 2026 08:19
@richardhjtan richardhjtan merged commit bb65580 into main Jul 13, 2026
100 of 101 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants