Skip to content

feat: Replace Meteor LiveQueryHandle flow with AbortSignals - #1812

Draft
Julusian wants to merge 13 commits into
Sofie-Automation:mainfrom
SuperFlyTV:feat/abort-signals-for-publications
Draft

feat: Replace Meteor LiveQueryHandle flow with AbortSignals#1812
Julusian wants to merge 13 commits into
Sofie-Automation:mainfrom
SuperFlyTV:feat/abort-signals-for-publications

Conversation

@Julusian

Copy link
Copy Markdown
Member

About the Contributor

This pull request is posted on behalf of Superfly

Type of Contribution

This is a: Code improvement

Current Behavior

Meteor had a convention of returning stop callbacks for mongo observers and other bits of reactivity that want to be stopped.
We were semi-constrained to this previously, but now that we no longer use meteor, we are free to easily change this.

In the past these stop callbacks have given us some bugs, as it is very easy to setup a bunch of observers, and not handle them correctly. In particular I remember a bug where there were a bunch setup inside a Promise.all, and if one of them threw then the others would be leaked.

New Behavior

A modern and js standard approach to this is to use AbortSignals instead of returning callbacks.
This means that the observe calls are bound to a signal instead of returning their stop function, and we can abort the signal and let that bubble through to all the observers setup on it.

In particular, for the ddp publications, when a subscription is started it now creates an AbortSignal, which will be aborted either when the client disconnects, or unsubscribes. This signal gets passed down through the call stack, sometimes wrapped in a new AbortSignal, to allow a local point for stopping and restarting, right through the mongo logic.
The signal is set as required in the collection methods, to enforce it is provided and ensure that cleanup is thought about.

Similarly, there has been at least one bug where a debounce in some custom publication logic has fired and restarted a stopped publication. The debounce used in these now also gets bound to this signal, and even if it did fire, the signal would be aborted and the new observer would fail to initialise because of this.

This is a POC that needs some further testing

Testing

  • I have added one or more unit tests for this PR
  • I have updated the relevant unit tests
  • No unit test changes are needed for this PR

Affected areas

Time Frame

Other Information

Status

  • PR is ready to be reviewed.
  • The functionality has been tested by the author.
  • Relevant unit tests has been added / updated.
  • Relevant documentation (code comments, system documentation) has been added / updated.

Julusian and others added 13 commits August 17, 2026 13:09
Adds meteor/server/lib/observerLifetime.ts: createChildAbort and
processLifetimeSignal as permanent primitives, plus temporary interop
shims (stopOnAbort, attachPendingHandles, handleFromSignalSetup) for
migrating LiveQueryHandle-based observers to AbortSignal lifetimes.

Refactors driveSubscriptionFromCursor and the observe shims in
asyncCollection/changeStreamCursor onto them. No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
observe/observeChanges (and the cursor's observeAsync/observeChangesAsync)
now accept a `signal` in the options and return a readiness promise instead
of a stop handle. The signal is the observer's lifetime: aborting it stops
the observer, aborting during setup resolves quietly leaving nothing
running, and a rejection means setup failed with nothing left running.

The handle-returning forms remain, deprecated, until callers are migrated.

driveSubscriptionFromCursor now uses the signal form directly, so the DDP
cursor contract is signal-based.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
InMemoryMongoCollection.observe/observeChanges/onChange accept an
AbortSignal defining the observer's lifetime, alongside the deprecated
handle-returning forms. WithSignal/hasSignal move to corelib's mongo.ts
so both the in-memory and async collection layers share one definition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the LiveQueryHandle/stop() plumbing across the backend observer
stack with AbortSignal-scoped lifetimes, fed from the per-subscription
signal that DdpPublicationContext already owns.

- observerLifetime: createChildAbort (AbortScope, composed via
  AbortSignal.any so dropped scopes are collectable) and runOnAbort as
  permanent primitives; stopOnAbort/attachPendingHandles/
  handleFromSignalSetup as temporary interop.
- CustomPublish exposes `signal`, retiring the single-slot onStop that
  silently overwrote a second registration.
- optimizedObserverBase owns a per-worker controller and passes its
  signal into setupObservers, so a throw partway through setup releases
  everything already started.
- ReactiveMongoObserverGroup, observerChain, RundownsObserver and the
  content-observer classes drop their handle arrays and dispose()
  methods in favour of caller-supplied signals; restartable scopes use
  per-generation child scopes.
- Process-lifetime observers take processLifetimeSignal.

This closes the sequential-setup leaks in pieceContentStatusUI,
bucketContentObserver and StudioObserver, where a throw between observer
creations orphaned the earlier ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the AbortSignal migration by deleting the handle-based API now
that nothing uses it:

- LiveQueryHandle/LiveQueryHandleSync, waitForAllObserversReady, the
  interop shims (stopOnAbort, attachPendingHandles,
  handleFromSignalSetup), ReactiveMongoObserverGroup and
  SetupObserversResult are gone.
- observe/observeChanges now take a required `signal` in their options
  and return Promise<void>; there is no longer a way to start an
  observer without stating its lifetime.
- PublicationContext.onStop, CustomPublish.onStop and
  DdpPublicationContext's stopCallbacks are replaced by the signal.

Removing SetupObserversResult surfaced eight publications
(studio, studioUI, showStyleUI, triggeredActionsUI,
peripheralDeviceForDevice, externalEventSubscriptions and the two
packageManager ones) that were still returning handle arrays which
nothing consumed; those observers are now bound to the publication
signal. executeFunction.ts likewise had a window where its handle was
still null while setup was in flight, so an early cleanup() leaked the
observer; aborting a controller works whenever it happens.

Adds observerLeak.test.ts, which asserts that a setupObservers throwing
after starting two observers leaves none running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the module-level processLifetimeSignal singleton with an
AbortController created in main.ts and threaded into each startup
function: setupSystemStatusObservers, startMediaObjectDurationMonitor,
startStudioMappingsHashObserver, startRundownVersionHashObservers,
startBlueprintConfigPresetObservers,
startExternalMessageQueueStatusMonitor and startDeviceTriggersObserver
(plus ObserveChangesForHash/ObserveChangesHelper underneath).

Aborting it now actually releases those observers, so a graceful
shutdown has something to call, and tests scope their own observers to
a controller they abort in afterEach rather than sharing a singleton
that can never be reset between suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two related tidy-ups, both removing machinery that existed to work
around callees that half-cleaned up after themselves.

ObserveMultiplexer#addSubscriber threw its startup error while the
subscriber was still registered, relying on the caller aborting to tear
it down ("who will abort, tearing us down"). It now deregisters before
throwing, so rejecting means nothing was left running. That makes
startObserveOnSignal redundant - its child scope existed only to have
something to abort on failure - so observe/observeChanges pass the
caller's signal straight through, saving an AbortScope and an
AbortSignal.any composite per observer. The contract is now stated on
the collection interface and covered by a test.

The deviceTriggers observers took change handlers that returned a
cleanup function, which each class stored and replayed on abort. Of the
three, one returned nothing, one returned a literal no-op, and only
clearTriggers was real. StudioObserver now takes a named handler object
including an explicit onRundownContentGone, registered once per
generation with runOnAbort, and the classes lose their #cleanup fields.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
createReactiveContentCache now takes the signal and self-cancels,
returning just the cache rather than a { cache, cancel } pair that every
caller had to wire up with runOnAbort.

Both deviceTriggers observers collapse to a single constructor line: the
reaction signature already matched their ChangedHandler, and the
`if (signal.aborted) return` guard they each carried is now enforced by
the cache itself, which stops delivering reactions once aborted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds createDebounce(fn, wait, signal) and gives PromiseDebounce a signal
in its constructor, so debounces end with their owner instead of every
call site pairing a `_.debounce` with a runOnAbort cancel and an
`if (signal.aborted) return` guard in the body. Both now ignore calls
made after the signal aborts and discard a pending invocation at that
point.

createDebounce is implemented on plain setTimeout rather than wrapping
_.debounce: underscore captures Date.now at module load, so its debounce
never fires under jest's fake timers, which is why the observers relying
on it could only be tested through waitUntil polling loops. The new one
is directly testable.

Converts the debounces that have a lifetime - the two deviceTriggers
reactive caches, mountedTriggers' publish buffer, StudioObserver's two,
and both RundownsObservers. StudioObserver.stop() is now just an abort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The six _.debounce class fields in observers that own an AbortSignal now
use createDebounce. They previously guarded their bodies with
`if (this.#signal.aborted) return` but never cancelled, so a pending
timer outlived the observer.

These classes assign #signal in the constructor body, so the debounces
had to move there too - a field initializer cannot read it. Their bodies
are unchanged apart from dropping the aborted guard, which createDebounce
now enforces. bucketContentObserver no longer needs #signal at all.

Also propagates the abort reason: PromiseDebounce rejects waiters with
signal.reason rather than a bare 'Cancelled', and StudioObserver ends its
observer chain with `trigger` instead of `call` - the chain discards the
return value, so `call`'s promise was unhandled and would surface as an
unhandled rejection when the debounce was cancelled.

The only remaining _.debounce is the module-level one in
api/blueprints/development.ts, which has no lifetime to bind to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removes the last of the migration scaffolding now that nothing needs it.

WithSignal existed so the signal-taking overload could be spelled
`Options & WithSignal` alongside the deprecated plain-`Options` one, and
hasSignal was the runtime discriminator between them. With the deprecated
overloads gone, hasSignal had no call sites and the intersection was
noise, so `signal` now lives on the interfaces themselves: a new
ObserveOptions carries it, ObserveChangesOptions extends that, and
FindObserveChangesOptions inherits it. FindOptions deliberately does not -
a plain find has no lifetime. All 25 `& WithSignal` intersections collapse
to the plain options type.

corelib's MongoLiveQueryHandle and the last three deprecated overloads
(InMemoryMongoCollection.onChange/observe/observeChanges) are deleted, so
an AbortSignal is required there too. webui re-exports meteor-lib's
separate copy of the type, not this one, so corelib had no consumers
outside its own tests; meteor-lib's stays as it is frontend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The engine test's waits now say what they were still waiting for, which
is what identified the residual intermittent failure as a stale-snapshot
revert (published val goes 1 -> 2 -> 1 -> 2 for a single update) rather
than slow delivery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Julusian Julusian added Contribution from SuperFly.tv Contributions sponsored by SuperFly.tv Goodbye Meteor! labels Aug 17, 2026
@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0f2c5867-cc96-4053-8780-069d4a3d6e04

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Contribution from SuperFly.tv Contributions sponsored by SuperFly.tv Goodbye Meteor!

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant