Skip to content

Preserve complete rows when realtime events race refetches - #129

Merged
KidkArolis merged 4 commits into
masterfrom
codex/fix-refetch-realtime-race
Sep 14, 2026
Merged

KidkArolis merged 4 commits into
masterfrom
codex/fix-refetch-realtime-race

Conversation

@ralake

@ralake ralake commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

A partial realtime notification arriving during a realtime: 'refetch' request could replace a complete server response row with an ID-only cached record. Consumers then rendered incomplete data; in Humaans this caused the task decoder to throw when performance-cycle tasks arrived.

Refetch queries now retain their fetched values and treat realtime notifications as invalidations during response rebasing and replay. The trailing reconciliation still runs, while mutation acknowledgements and optimistic overlays keep their existing handling.

Extended the existing fetch-race test to cover partial create, patch, and remove notifications. The reproduction failed before the fix. All 365 tests, type checking, lint, formatting, and the packaged ESM/CommonJS/declaration checks pass.

cursor[bot]
cursor Bot previously approved these changes Sep 11, 2026

@cursor cursor 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.

Stale comment

Risk: medium. Approved — Bugbot completed with no findings, Security Agent was not running, and this refetch-race cache fix looks ready to merge. No reviewers were assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver - Risk Based

@ralake
ralake requested a review from KidkArolis September 14, 2026 08:43
@KidkArolis

Copy link
Copy Markdown
Collaborator

Follow-up pushed in 39a3b40.

Approach:

  • Treat explicit realtime: "refetch" queries and server-authoritative queries as invalidation-only views.
  • Realtime events still update the shared entity cache and trigger the existing reconciliation. They cannot replace the last fetched rows while that reconciliation is in flight.
  • Keep server-window behavior unchanged, so provable event effects can still merge locally.
  • Centralize the distinction in usesInvalidationOnlyRealtime and use it for both row ownership and fetch-response rebasing/replay.

The regression matrix now runs created, patched, and removed races against both explicit refetch and .server() queries, and asserts that partial payloads are never exposed.

Verification: npm run test passes all 365 tests, including type-checking, lint, formatting, and coverage.

@KidkArolis

Copy link
Copy Markdown
Collaborator

Implemented the revised approach in 31f4222:

  • ID-only created, updated, and patched payloads are now automatic invalidations. They never enter the canonical entity cache, so a complete row cannot be replaced by { id }.
  • ID-only removed remains an authoritative removal, since the ID is sufficient to apply it.
  • Full realtime payloads keep the existing classification-driven merge behavior.
  • Added optional FeathersAdapter({ isInvalidationEvent }) support for application-specific multi-field notification payloads (for example, an ID plus an app-owned marker).
  • Invalidations reconcile matching gets and service finds, preserve inactive queries as pending, trigger a trailing fetch when needed, and propagate to cursor pagination and relation-dependent query roots.
  • Kept .server() / realtime: "refetch" views fetch-owned and renamed the internal response mode accordingly.

Regression coverage verifies that neither built-in ID-only nor adapter-classified invalidations ever publish or cache incomplete entities. Full project verification passes: typecheck, lint, formatting, and all 366 tests.

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 31f4222. Configure here.

Comment thread lib/core/queryStore.ts
force: request.force,
...(request.causes ? { causes: request.causes } : {}),
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Invalidations reuse stale local cache

Medium Severity

ID-only invalidations keep the previous complete entity in cache, then ask reconciliation.request to refresh. That path still allows local answers: #prepareReconcile can reselect a materialized find from the unchanged table, and a follow-up get fetch can return via #tryLocalGet. When a snapshot or already-evicted .all() left service.materialized set, nothing hits the server and live queries keep publishing pre-invalidation rows.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 31f4222. Configure here.

@cursor
cursor Bot dismissed their stale review September 14, 2026 20:45

Risk: medium. Dismissed the prior approval because Cursor Bugbot completed as skipped on the current head, so that approval is stale. Human review is needed.

@KidkArolis

Copy link
Copy Markdown
Collaborator

Thermo-nuclear follow-up applied in 86541c6:

  • Replaced the parallel invalidation queue with a discriminated mode: "invalidation" variant in the existing atomic event queue. This removes the duplicated disposal, scheduling, loop, grouping, and reset state that had to stay synchronized.
  • Renamed usesInvalidationOnlyRealtime to usesFetchOwnedRows, separating query-row ownership from actual invalidation payload semantics.
  • Tightened the relational regression assertion: one invalidation is now explicitly bounded to the current graph refetch plus cache-convergence refetch, rather than accepting any count greater than one.

Verification remains clean: typecheck, lint, formatting, coverage, and all 366 tests pass.

@cursor cursor 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.

Risk: medium. Left a non-blocking comment — Cursor Bugbot was present but finished as skipped, so this head is not eligible for automatic approval. No reviewers were assigned; human review is needed.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver - Risk Based

@KidkArolis

Copy link
Copy Markdown
Collaborator

After release:

  • { id: 'x' } realtime updates are treated as invalidations: they trigger refetches without overwriting the cached object.
  • Full realtime payloads continue updating the cache normally.
  • ID-only removals still remove immediately.
  • Custom invalidation payloads can be identified with isInvalidationEvent.
  • Refetch and .server() queries now safely handle realtime events that arrive during fetching.

In short: complete realtime payloads remain useful data; incomplete notifications trigger safe refetches without corrupting the cache.

@KidkArolis
KidkArolis merged commit ea587d6 into master Sep 14, 2026
4 checks passed
@KidkArolis
KidkArolis deleted the codex/fix-refetch-realtime-race branch September 14, 2026 20:58
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.

2 participants