Skip to content

feat(subscriptions): lazy initialPayload factory to close subscribe-before-snapshot race#145

Merged
cuzzlor merged 3 commits into
mainfrom
feat/wrap-subscription-iterator-lazy-payload
Jun 23, 2026
Merged

feat(subscriptions): lazy initialPayload factory to close subscribe-before-snapshot race#145
cuzzlor merged 3 commits into
mainfrom
feat/wrap-subscription-iterator-lazy-payload

Conversation

@cuzzlor

@cuzzlor cuzzlor commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Problem

wrapSubscriptionIterator exists to avoid subscribe-time races: it hands the client an initialPayload (current state) before live events stream in. But it drains the entire initialPayload before its first wrapped.next() — and graphql-subscriptions' PubSubAsyncIterableIterator only establishes its channel subscription (subscribeAll() → Redis SUBSCRIBE) on that first next().

So the channel goes live after the snapshot is read. Any event published between the snapshot read (T_snap) and the subscription going live (T_live) is both lost (no subscriber yet) and absent from the snapshot (read before it happened):

T_snap  read DB snapshot (initialPayload)
  …     graphql-ws drains N initial rows one-by-one
  …     first wrapped.next()  ->  subscribeAll()  ->  Redis SUBSCRIBE …
T_live  SUBSCRIBE ack — channel finally live   ← events in T_snap…T_live are dropped

Why it bites: the window is usually invisible because the work behind an event takes long enough (a network call, an LLM round-trip, etc.) to finish well after the subscription is live. But when a background job completes extremely fast — e.g. it returns a cached/precomputed result with no slow I/O — it can flip to its terminal state and publish within the T_snap…T_live window. That event is dropped and isn't in the snapshot either, so the client is left showing the stale in-progress state until something forces a refetch. The faster the job, the more reliably it lands in the gap.

Fix

Allow initialPayload to be a factory: () => T | T[] | undefined | Promise<…>.

When a factory is supplied, the wrapped iterator is pulled eagerly at construction (establishing the subscription) before the factory reads the snapshot. The buffered event is delivered immediately after the snapshot drains, and events fired during the read are queued by the wrapped iterator instead of dropped.

return wrapSubscriptionIterator({
  iterator: pubsub().asyncIterableIterator(`topic.${id}`),
  initialPayload: () => repo.find({ where: { id } }), // runs AFTER subscribe is kicked
})

The plain value form (T | T[]) is unchanged, including the eventIsFinal cached-final-event short-circuit (which must not subscribe). Backward-compatible feature → 3.1.0 → 3.2.0.

Residual nuance

The sub-millisecond ordering between "SUBSCRIBE sent" and "SUBSCRIBE acked" remains theoretically open — but cannot occur in the real topology (local Redis SUBSCRIBE is kicked before the Postgres snapshot round-trip), and this shrinks the original "drain N rows + round-trip" window to effectively nothing. The provably-correct alternative (helper owns the subscription via pubSubEngine.subscribe, awaiting the ack) is a larger breaking API change and was deliberately not taken here.

Tests

Adds a controllable fake iterator mimicking PubSubAsyncIterableIterator (subscribe-on-first-next, push() with pull/push queues) and 11 cases:

  • subscribe-ordering regression (subscribe runs before the factory; value-form contrast)
  • no event dropped — both when published during the window and during the snapshot read itself
  • factory shapes: single value, array, undefined, Promise
  • eventIsFinal teardown on an already-final factory snapshot
  • early return() / throw() before any next() — no unhandled rejection

Verification

  • npm test — 100 passed
  • npm run check-types — clean
  • npm run lint (--max-warnings 0) — clean
  • npm run build (rollup + attw) — "No problems found", all 4 entry points green

Downstream follow-up (separate repo)

Migrate snapshot-then-stream call sites to the factory form after bumping the dep (keep any up-front auth read; pass only the payload read as a factory). Leave eventIsFinal-with-cached-payload callers on the value form.

🤖 Generated with Claude Code


Dependency vulnerability fixes (commit 2)

This PR also clears all open Dependabot advisories. Every one is a transitive devDependency (development scope), so the published package is unaffected.

  • npm audit fix resolves 7: shell-quote (critical), vite + fast-uri ×2 + ws (high), qs + js-yaml (moderate).
  • The remaining low-severity esbuild advisory (GHSA-g7r4-m6w7-qqqr, dev-server arbitrary file read on Windows) couldn't be reached by audit fix: tsx@4.21.0 pins esbuild ~0.27.0. Since vite@8.0.16 already accepts ^0.28.0 and tsx is not used by any npm script, an overrides: { "esbuild": "^0.28.1" } pin lifts the whole tree to the patched 0.28.1.

Both npm audit and the repo's better-npm-audit now report 0 vulnerabilities; npm test (100 passed) and npm run build are green on esbuild 0.28.1.

…riptionIterator

wrapSubscriptionIterator drained the entire initialPayload before its first
wrapped.next(), but graphql-subscriptions iterators subscribe to their channel
lazily on that first next(). The channel therefore went live only AFTER the
snapshot was read, so any event published between the snapshot read and the
subscription going live was both lost (no subscriber yet) and absent from the
snapshot — leaving fast-completing entities stuck in a stale state until a
manual refetch.

Allow initialPayload to be a factory: () => T | T[] | Promise<T | T[]>. When a
factory is supplied, the wrapped iterator is pulled eagerly at construction to
establish the subscription BEFORE the factory reads the snapshot; the buffered
event is delivered immediately after the snapshot drains, and events fired
during the read are queued by the wrapped iterator instead of dropped.

The plain value form (T | T[]) is unchanged, including the eventIsFinal
cached-final-event short-circuit (which must not subscribe). Bumps 3.1.0 -> 3.2.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI 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.

Pull request overview

This PR updates wrapSubscriptionIterator to support a lazy initialPayload factory so the wrapped pubsub iterator is pulled (and thus subscribes) before a “snapshot” read occurs, closing a subscribe-before-snapshot race that could drop events between snapshot read and channel subscription.

Changes:

  • Add initialPayload factory support with an eager first pull of the wrapped iterator to establish the subscription before snapshot reads.
  • Add a controllable async iterator test double plus targeted regression tests covering ordering, buffering, and early teardown.
  • Update README documentation and bump package version to 3.2.0.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
src/subscriptions/subscription-iterator.ts Adds factory type(s) + eager-pull logic to establish subscription before snapshot read.
src/subscriptions/subscription-iterator.spec.ts Adds a controllable iterator and new tests to validate race closure and teardown behavior.
README.md Documents the factory form and the subscribe-before-snapshot rationale/usage.
package.json Bumps version from 3.1.0 to 3.2.0.
Comments suppressed due to low confidence (1)

src/subscriptions/subscription-iterator.ts:44

  • The JSDoc for eventIsFinal says the wrapped iterator is closed “without ever being pulled from” when the final initial payload entry is final. With the new factory form you always pull once eagerly (to establish the subscription), so this statement is no longer accurate and could mislead API consumers.
 * @param eventIsFinal An optional function to determine if an event is final (and iteration should end).
 *   The full `initialPayload` is always yielded; `eventIsFinal` is only checked against its final entry, after which
 *   the wrapped iterator is closed without ever being pulled from. For events from the wrapped iterator, every event
 *   is checked.

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

Comment thread src/subscriptions/subscription-iterator.ts Outdated
Comment thread README.md Outdated
cuzzlor and others added 2 commits June 22, 2026 18:10
All advisories were in transitive devDependencies (development scope), so the
published package is unaffected.

`npm audit fix` clears 7 (shell-quote critical; vite + fast-uri x2 + ws high;
qs + js-yaml moderate). The remaining low-severity esbuild advisory
(GHSA-g7r4-m6w7-qqqr, dev-server file read on Windows) couldn't be reached by
audit fix because tsx@4.21.0 pins esbuild ~0.27.0; vite@8.0.16 already allows
^0.28.0, so an overrides pin to ^0.28.1 lifts the whole tree to the patched
version. tsx is not used by any npm script, and tests + build pass on 0.28.1.

npm audit and better-npm-audit both report 0 vulnerabilities.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Widen InitialPayloadFactory to allow returning `undefined` (or a promise of
  it) for "no snapshot"; toQueue already handles it and a realistic findOne
  factory now typechecks without a cast. Drops the unsafe cast in the test.
- Correct the eventIsFinal JSDoc: the wrapped iterator is pulled once eagerly
  in the factory form (then discarded on teardown); "never pulled" holds only
  for the value form.
- Fix the README eventIsFinal bullet, which wrongly claimed eventIsFinal is not
  applied to initialPayload (it is checked against the final entry), and
  document that a factory may return undefined.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cuzzlor cuzzlor enabled auto-merge June 22, 2026 10:17
@cuzzlor cuzzlor disabled auto-merge June 23, 2026 01:35
@cuzzlor cuzzlor enabled auto-merge (squash) June 23, 2026 01:35
@cuzzlor cuzzlor merged commit 6181e3d into main Jun 23, 2026
2 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