Skip to content

WarpDrive: Transactional Notification Delivery and Reactive Request State

Summary

WarpDrive currently delivers cache-change notifications under five distinct timing policies that grew independently: immediate per-call delivery, transaction-deferred delivery (relationships only), request-window buffering (_enableAsyncFlush), a synchronous-render escape hatch (willSyncFlushWatchers), and consumer-forced flushes. This RFC replaces all five with a single rule — every write to store-managed state happens inside a transaction, and all notifications from a transaction are delivered in one synchronous batch when it closes — and rebuilds the request-state layer (RequestStateService) as transactionally-written, signals-backed data so that record data and request status can never be observed out of sync.

Motivation

The problem: five flush policies, zero mental models

The NotificationManager is how WarpDrive tells reactive layers (records, record arrays, documents, request states) that cache state changed. When a notification is delivered determines what a renderer or an interleaved promise continuation observes. Today that timing depends on which of five policies a given write happens to hit:

  1. Immediate: a bare notify() (attributes, state, added/removed, …) flushes the entire buffer synchronously before returning — including mid-cache.put, while a payload is half-applied.
  2. Transaction-deferred: relationship notifications from the graph defer to the notify phase of the store's internal transaction (store._schedule('notify')), so they are never dispatched mid-graph-sync.
  3. Request windows: request/push processing sets _enableAsyncFlush, buffers everything, and relies on an explicit notifications._flush() later — hand-rolled in five places with five slightly different shapes.
  4. Sync-render bypass: when the configured signals implementation reports willSyncFlushWatchers(), buffering is skipped so a synchronous render cannot observe stale state.
  5. Forced flushes: internal consumers (legacy relationship support, the request cache via _onNextFlush) flush or sequence against the buffer directly because none of the above gives them a reliable "the world is consistent now" point.

These are all partial answers to one question — when is the current unit of work done? — using three incompatible definitions of "unit of work."

The concrete failures this causes today

Torn synchronous reads. Between a response's cache.put and its finalize-time flush there are several microtask hops. Any code that runs in those gaps sees an incoherent mix: peekRecord returns a record that peekAll does not yet contain; a record field read before the request returns its stale memoized value while a never-read field on the same record computes fresh from the new cache.

Cross-request mingling. The notification buffer is shared. When two responses are processed in adjacent microtasks, both buffer into the same map and the first finalize flush delivers them mingled — response B's record data publishes before B's own request state transitions, which is exactly the tear the buffering was built to prevent.

Data / request-state skew. Request states transition on promise hops (a .then inside the request cache) and are stitched back to the notification flush via a one-shot _onNextFlush callback. The coupling is fragile and only covers the blocking-request path; everywhere else (bare store.push, non-blocking priorities, the sync-render bypass) data and request state publish at different instants.

Lost notifications on teardown. A notification buffered during a transaction is silently dropped if the same transaction tears down its subscriber (e.g. deleteRecord on a new record unloads it, unsubscribing the legacy RecordState before the flush). Consumers work around this by depending on immediate delivery, which is what blocks unifying the policies. (This was surfaced concretely while removing the duplicate relationship buffer in warp-drive#10992.)

The expected outcome

  • One documented delivery contract instead of five implicit ones.
  • Synchronous APIs (peekRecord, peekAll, relationship access) can never observe a torn world: the buffer is provably empty at every yield point.
  • Record data and request status for one response always publish in the same synchronous instant.
  • Per-response batch isolation under concurrent requests.
  • Deletion of the accidental machinery: _enableAsyncFlush, all explicit _flush() call sites, _onNextFlush, the relationships-only carve-out, and the willSyncFlushWatchers bypass.

Detailed design

Terminology

  • Transaction: the synchronous unit of work in which store-managed state is written. An upgrade of the existing internal store._run / store._join mechanism; not a database-style transaction (no rollback).
  • Publication: delivery of all notifications buffered during a transaction, in one synchronous batch at its close.

The invariant

Every write to store-managed state (cache content, graph edges, request lifecycle) happens inside a transaction. All notifications emitted during a transaction are delivered in one synchronous batch when the outermost transaction closes. The notification buffer is empty at every yield point.

Because delivery is always synchronous within the frame that performed the writes, code outside the store can only ever execute in one of two states: before a transaction (old cache, old memoization) or after one (new cache, all signals invalidated) — both coherent. The "cache updated but notifications pending" window that torn reads and interleaving bugs live in becomes unrepresentable outside store internals.

1. Transactions: phase queues

The existing internal mechanism (_run/_join/_schedule) already defines phases; this RFC upgrades its single-slot callbacks to queues and adds one phase:

mutate → coalesce → sync → notify → cleanup
  • mutate is the transaction body; coalesce and sync are the graph's existing remote/local settling phases, unchanged in role.
  • notify drains the notification buffer until empty. A mutation performed by a subscriber during delivery joins the open transaction and lands in the same drain (with a dev-mode depth guard against subscriber ping-pong, replacing today's single-slot assertion).
  • cleanup (new): record teardown, unsubscription, and identifier disconnect run here — strictly after delivery. This structurally fixes the lost-notification-on-teardown class: the world is told before things die.
  • Nested transactions join the outermost one; only its close runs phases.

The internal shape of the transaction object is perf-sensitive (it wraps every write in hot paths) and is left as an explicit unresolved question below; the phase semantics above are the normative part.

2. NotificationManager: single buffer, single trigger, two-pass delivery

  • The per-identifier, per-namespace, channel-aware buffer (as of warp-drive#10560 / #10992) is kept. The only flush trigger is the notify phase. A notify() call with no open transaction opens a degenerate one (precedent: graph.push already self-wraps in _run today).
  • Delivery is two-pass: internal subscribers first (reactive records, record arrays, documents, request states — everything whose callback is signal invalidation), then external subscribers. After pass one, every memoized value is coherent, so even a synchronous read from inside a user's subscription callback sees a consistent world.
  • Deleted: the immediate-flush path, _enableAsyncFlush coupling, _onNextFlush, the relationships-only deferral carve-out, and the willSyncFlushWatchers() check — nothing buffers across a yield, so there is nothing a synchronous watcher flush could miss.

The delivery contract, documented as public API:

A notification emitted while you are subscribed is delivered to you exactly once, at the close of the emitting transaction, before any same-transaction teardown, always against a fully-settled cache.

3. Request state: transactionally-written, signals-backed

The RequestStateService is replaced by lazily-created, signal-backed state records — per RequestKey, and per ResourceKey for mutations:

ts
interface RequestState {
  status: 'pending' | 'fulfilled' | 'rejected' | 'aborted';
  isMutation: boolean;
  request: ImmutableRequestInfo;
  response: ResponseInfo | null;
  error: Error | null;
}

Transitions are plain writes performed inside the owning transaction: pending at dispatch; fulfilled/rejected in the same transaction as put/didCommit/commitWasRejected. The tear between record data and isSaving/isLoading becomes unrepresentable because both are published by the same flush. Reactive consumers do not subscribe at all — they read the state and autotrack.

The exact public API shape for consuming these (and for the imperative subscription escape hatch that replaces subscribeForRecord) is an unresolved question below. The sunset path is not: getRequestStateService and subscribeForRecord remain as a compatibility shim — an internal pass-two subscriber replaying transitions to old callbacks — behind a deprecation (id warp-drive:deprecate-request-state-subscriptions, until: 6.0, gated by a build-config flag following the existing ENABLE_LEGACY_* pattern).

4. Who owns the transaction (async paths)

Three cooperating layers:

LayerRole
CacheManager (floor)Every cache write method (put, didCommit, commitWasRejected, patch, mutate, upsert, setIsDeleted, …) joins-or-opens a transaction. This guards against direct calls: a custom handler invoking cache.put bare still gets a single coherent publication.
CacheHandler and the legacy network handler (primary)Each response-application frame opens one transaction spanning cache application, reactive document setup, lifetimes.didRequest, and the request-state transition. One publication per response — which also provides per-response isolation that today's shared finalize flush cannot.
RequestManager (sentinel)It cannot own the transaction — the handler chain spans real async I/O and transactions are synchronous. Instead it writes the pending transition at dispatch, and at settle asserts (dev-mode) that a terminal transition was written — opening a fallback micro-transaction when a request bypassed the CacheHandler — and that the buffer is empty.

The current finalize-time flush inside store.request() becomes a dev-mode assertion that the buffer is already empty: the permanent proof that the invariant holds.

5. Synchronous APIs

store.push, createRecord, deleteRecord, unloadRecord, unloadAll, and graph mutations already run inside _join; they inherit the model with no call-site changes. The bolt-ons are deleted: _push loses its asyncFlush parameter, unloadAll's hand-rolled flag/flush/pause-resume becomes transaction + cleanup-phase work, and the forced flushes in legacy relationship support are removed.

A public batching primitive falls out for free and may be exposed once the internals stabilize:

ts
store.batch(() => {
  store.push(payloadA);
  store.push(payloadB);
  record.name = 'Chris';
}); // one publication

6. Migration sequence

Each phase is independently shippable and behavior-gated by WarpDrive's existing test matrix (main-test-app, graph, json-api, legacy, ember framework suites):

  1. Instrumentation: tag buffered notifications with a request id; demonstrate today's cross-request mingling.
  2. Phase queues + cleanup; move teardown into it. Gate: the notify-then-unload scenario (the failure mode found in #10992) passes under full deferral.
  3. CacheManager floor; notify() self-wraps; delete the relationships-only carve-out. Gate: peekAll/peekRecord agreement; bare-cache.put custom-handler test.
  4. Request state v2 + compat shim + deprecation. Gate: request-state suites; an isSaving-vs-data correlation test.
  5. Handler-owned transactions; delete _enableAsyncFlush and all explicit flushes; finalize flush becomes an assertion. Gate: a two-concurrent-responses isolation test; the assertion stays silent across all suites.
  6. Two-pass delivery; remove willSyncFlushWatchers from the NotificationManager; optionally expose store.batch.

Ordering constraints: (2) must precede (3) — the cleanup phase is what makes full deferral sound; (4) must precede (5) — otherwise deleting the request window reopens the data/request-state tear.

Ecosystem implications

  • Deprecations: getRequestStateService / subscribeForRecord (see §3); the asyncFlush parameter of store._push (intimate API).
  • Addon ecosystem: custom Cache implementations and custom request handlers are the affected extension points; both get stronger guarantees with no interface change (the CacheManager floor wraps them). Addons that subscribe to notifications and depend on mid-mutation delivery timing will observe changes; the delivery contract in §2 becomes the documented guarantee.
  • Server-side rendering: publication is always synchronous within the mutating frame, which is strictly friendlier to SSR than microtask-timed or scheduler-timed delivery.
  • Debuggability / Ember Inspector: LOG_NOTIFICATIONS gains a stable story — every notification logs a buffer event and a publication event with a transaction id, replacing today's regime-dependent logs.
  • Lint rules, blueprints, Engines, IDE support: no changes.

How we teach this

The concept maps to vocabulary developers already have from databases and other reactive systems: writes happen in transactions; observers see transaction boundaries, never intermediate states. Teaching materials need one new sentence more than they have today, and several fewer caveats:

  • Guides: the "reactivity" section of the WarpDrive docs gains a short "when do observers see changes?" passage stating the delivery contract. Existing guidance about awaiting settled state is unchanged.
  • API docs: NotificationManager.subscribe documents the contract in §2; the new request-state read APIs are documented alongside getRequestState usage they replace.
  • Existing users are reached through the deprecation guide entry for warp-drive:deprecate-request-state-subscriptions, which shows the mechanical migration from subscribeForRecord callbacks to reading signals-backed request state.

No reorganization of the guides is required; this removes special cases rather than adding concepts.

Drawbacks

  • Notification timing is observable. Ad-hoc attribute notifications move from mid-mutation to transaction close. Application tests that assert intermediate states, and addons that rely on immediate delivery, will notice. The phase-by-phase landing plan exists precisely to surface these in WarpDrive's own suites first.
  • Churn in intimate APIs. Several semi-public internals (getRequestStateService, _enableAsyncFlush, forced _flush()) are load bearing in the wild despite their markings; the compatibility shim and deprecation window carry real maintenance cost until 6.0.
  • A transaction wrapper on every write is hot-path code. If the internal shape is wrong, this trades a correctness problem for a performance one (see unresolved questions).

Alternatives

  • Status quo: keep five policies and continue patching consumers (the relationships-only carve-out shipped in warp-drive#10992 is exactly such a patch, and this RFC deletes it). The accidental complexity keeps compounding: #10560 had to make the now-removed duplicate buffer channel-aware precisely because the policies could not be unified.
  • Microtask-scheduled flushing (deliver on the next microtask rather than at transaction close): simpler to implement, but reintroduces the torn-read window for synchronous APIs, breaks synchronous renderers, and makes SSR timing fragile. Rejected.
  • Per-namespace policies (formalize "relationships defer, everything else is immediate"): preserves today's observable timing exactly, but hard-codes the very incoherence this RFC removes and leaves the request-window machinery intact. This is the shipped interim state, not the destination.
  • Prior art: MobX (runInAction/transactions with reactions at action close), Vue (nextTick-batched effects), Solid (batch()), and Svelte 5 (microtask-batched effects) all converge on "writes batch; observers see batch boundaries." The transaction-close (rather than microtask) timing here is what preserves WarpDrive's promise-resolution ordering guarantees.

Unresolved questions

  1. Public API shape for reactive request subscriptions. The signals-backed request state (§3) needs a consumer-facing read API — a store.requestStateFor(keyOrRecord) accessor, properties on ReactiveDocument, both, or something else — and a decision on whether any imperative subscription API survives beyond the deprecation shim. Strawman sketches exist but the shape should be settled during the Exploring stage with the Data team.
  2. The internal shape of the transaction. Phase queues, the join fast-path, and buffer representation sit on the hottest write paths in the library. Options include a pooled transaction object, a monomorphic phase array, or retaining the current _cbs-style record with queues only where re-entrancy is possible. This needs benchmarks (notification-heavy push/mutation workloads) before the shape is fixed.
  3. Entanglement with the Render Aware Scheduler interface (emberjs/rfcs#957). That proposal makes promise interleaving safer by giving the framework a render-aware notion of when work flushes relative to the browser's event loop. If it lands, some guarantees this RFC achieves by synchronous publication (notably the sync-render bypass deletion, and how external pass-two subscribers are scheduled) could instead be expressed as scheduler lanes — potentially allowing external delivery to be deferred off the critical path without reopening the torn-read window. The two proposals should be kept explicitly compatible: this RFC's invariant is the stronger (synchronous) guarantee, and adopting #957 would relax only the external delivery pass, never the internal one.
  4. store.batch as public API: name, whether it is sugar over the internal transaction or a distinct concept, and whether it may be opened during an active flush.

Released under the MIT License.