Skip to content

feat(webapp,run-engine,run-store,redis): wire the execution-snapshot store behind an off-by-default dial - #4783

Open
d-cs wants to merge 101 commits into
mainfrom
feat/snapshot-store-wiring-tri-13451
Open

feat(webapp,run-engine,run-store,redis): wire the execution-snapshot store behind an off-by-default dial#4783
d-cs wants to merge 101 commits into
mainfrom
feat/snapshot-store-wiring-tri-13451

Conversation

@d-cs

@d-cs d-cs commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

Makes the Redis-backed execution-snapshot store reachable from production, off by default.

With no RUN_ENGINE_SNAPSHOT_STORE_REDIS_HOST set, nothing is constructed, no connection is opened, no metric series is registered and no job is scheduled, so the store chain behaves exactly as it does today. A fresh self-host is in that state: none of the new variables appear in .env.example, the docker files, or the Helm chart. The dial cannot activate anything on its own either, because construction is gated on the connection rather than on the dial.

Once the connection is configured, expect three Redis connections and the orphan sweep running on its cron schedule, at any dial position. The sweep runs at every position on purpose: an operator has to observe a full pass before dual-write starts. Until the dial moves, the keyspace is empty and a pass finds nothing, but it is not nothing.

Stacked on #4765, which builds the store and the decorator. Review that one first.

The dial is a feature flag, not an environment variable

A sustained append failure burns a task attempt on every state transition, so runs can exhaust their retry budget on infrastructure failure rather than task failure. Dialling down is therefore a correctness control, and it cannot wait for a deploy.

Two catalog keys, because they must accept different values:

  • snapshotStoreMode (global) holds all five positions.
  • snapshotStoreOrgMode (per organisation) holds off, dual-write and compare only.

Snapshot reads are global, so an organisation at a read position would read state its own writes never created. The narrower enum makes that unrepresentable rather than documented. ORG_LOCKED_FLAGS turns out to enforce nothing (it is a client-side predicate and no save path consults it), so the line is held the way the mint grace stamps hold it: both organisation routes strip the global key from an incoming payload.

The environment keeps what cannot be hot-swapped, plus RUN_ENGINE_SNAPSHOT_STORE_MODE as the floor used when no flag row exists, so a self-host install still works with no rows at all. No variable falls back to the generic REDIS_*: this is a distinct durable endpoint, and a fallback would silently put execution state on the general-purpose cache.

The resolver never queries

The dial is read on every snapshot write, while the per-run lock is held. Seven decorator methods accept a caller-supplied transaction, so this code cannot see a caller's transaction boundary, and an awaited read could land inside someone else's open interactive transaction, on the same connection pool for single-DB and self-host.

So the resolver is synchronous. The global value comes from the existing globalFlagsRegistry, already an in-memory snapshot read synchronously on the trigger hot path. The per-organisation value comes from a bounded LRU. A miss returns the global answer and warms the cache off-path, so a cold organisation costs no round trip and a control-plane blip cannot fail a state transition.

Gating, and the sweep

Construction is gated on the connection, not the dial. The store's options require a constructed Redis store, that store opens its socket in its constructor, and the client factory sets no lazyConnect, so building it unconditionally would open a doomed localhost connection in every self-host install, developer machine and CI run.

The orphan sweep runs as a cron job on the engine's existing worker. enqueueOnce gives no overlap protection (its dedup record is the queue item and the ack deletes it, and nothing extends the visibility timeout), so each pass takes a fenced lock released with a compare-and-delete. A bare DEL would let a pass that overran its own lock delete its successor's.

The append-failure hook binds to the existing repair job, sharing the stall watchdog's job id and its availableAt, so the two compensators can never enqueue two repairs for one run and neither can win a race that changes the delay.

Notes for review

Three existing assertions changed, deliberately. runInTransaction now always installs the staging facade and forWaitpointCompletion always wraps its handle: both were conditional on the dial, which cannot work once the dial moves at runtime and a per-organisation value lives in an organisation row. The replacements assert the property that matters, that nothing is appended at off.

@internal/redis gains a cluster-capable client. Cluster mode currently reaches the sweep connection but not the hot path, because the Redis snapshot store still builds its own single-node client; that becomes a one-line change once its options accept a pre-built client.

Six guards are verified by reintroducing the defect rather than by observing a pass: the organisation key strip, the module-scope instrument check, the construction gate, the graded boot check, the lock fence and the repair job id each fail when their guard is removed.

Three items are threaded and not yet honoured, because they need #4765's store API: the sweep budget, the abort signal, and CONFIRM_ORPHAN_AFTER_MS. Cluster mode reaches the sweep connection but not the hot path for the same reason. Each is commented at its site.

No changeset and no server-changes entry: the dial defaults off, @internal/redis is not consumed independently, and nothing user-visible changes yet.

d-cs added 30 commits August 24, 2026 14:48
The decorator that dual-writes snapshots to Redis has to own the snapshot id, or
the same snapshot carries a different id in each store and the comparator chases
a difference that is not real.

Four of the six snapshot input types had no id field, so four write sites could
not carry one. Add it to CompletionSnapshotInput, ExpireSnapshotInput,
RescheduleSnapshotInput and CreateExecutionSnapshotInput, and thread it through
every nested create. createCancelledRun built its create inline and dropped the
id its input already carried; it now passes it too.

The field is optional everywhere, so an absent id still falls through to
Prisma's @default(cuid()) and no existing caller changes.
…ators

RunStore has 71 members. A decorator that intercepts a dozen of them should not
restate the other 59 forwarders alongside its real logic, and hand-writing them
invites a typo no test would catch.

Generate the base from the interface instead. The generator also emits the
member-name lists, so the suite can assert that the class and the interface hold
exactly the same members: a method added to RunStore and not to the base fails a
test rather than becoming a silent hole in the decorator.

The one data property on the interface becomes a getter over the delegate, read
live rather than captured, so a delegate whose client changes is not cached.
…parity tests

No nested write site returns the snapshot it created: createRun returns the run,
expireParkedRun returns a count, and the rest return a selected TaskRun. So the
Redis entry is built from each site's own input plus the caller-minted id.

That means every value Postgres derives rather than receives has to be
reproduced: the DEQUEUED-to-PENDING rewrite, the four values lockRunToWorker
hard-codes, the three rescheduleRun defaults, and the engine column default a
completion leaves unset.

The parity suite covers all ten physical write sites, comparing the built entry
against the row Postgres actually wrote. It caught the dropped id in
createCancelledRun.
…write

The last dial position makes the Redis store the sole snapshot writer, so
Postgres has to stop writing snapshot rows without changing anything else it
does. One constructor flag does that across all ten write sites.

With it off, the nine nested creates are omitted and the run mutation still
lands; createExecutionSnapshot echoes its input in the shape callers expect
rather than inserting; and the completed-waitpoint join inserts are skipped,
since they would otherwise link to a row that no longer exists.

Defaults to true, so every existing caller and test is unaffected.
A decorator over any RunStore that also writes execution snapshots to Redis. It
overrides only the methods that touch a snapshot and inherits the rest.

Write order is the correctness property, and the two orders differ on purpose. A
transition writes Postgres first: a crash in the gap leaves a stale latest
snapshot, which the heartbeat stall watchdog already heals. A birth writes Redis
first: a crash there leaves an unreachable key for a run that does not exist,
where Postgres-first would leave a run with no snapshot at all and no way to
read one. Each order is chosen so the crash state is the harmless one.

A failed transition append retries three times, then hands the run to the repair
job. It never rethrows, because Postgres has already committed and a throw would
turn a healable gap into a caller-visible error. A failed birth append is
survivable before redis-only, where Postgres still holds the snapshot, and
refuses at redis-only, where it would otherwise create a run with no snapshot
anywhere; refusing works only because the birth append comes first.

None of the four non-failure append outcomes enqueues a repair: an absent
keyspace is every pre-cutover run's transitions, a fork means another writer
advanced the head, a duplicate is a retry that landed, and a cycle mismatch is
the store refusing an untrustworthy pointer on purpose.

At mode off the decorator makes no Redis call and builds no entry.
…ore handles

Proves the deferral from inside the transaction callback rather than assuming it:
a staged append is absent from Redis while the transaction is open and present
once it commits, and a rollback leaves both stores agreeing the transition never
happened.
The engine resolves its since-cursor to a createdAt before it asks for the
window, so the snapshot id is gone by then and the id-addressed read cannot
serve it. Adding a cursor-addressed read is the alternative to changing the
engine's read path, which stays untouched.

The cursor is exclusive and keeps the same-millisecond blind spot the Postgres
read has. Matching it is the requirement, not an oversight: a Redis read that is
more correct than the Postgres read shows up as divergence during compare mode,
which exists to surface real defects. Closing the blind spot needs seq ordering
on both sides and belongs after the cutover.

The walk goes newest-first and stops at the first entry at or before the cursor,
so its length is the length of the answer rather than the run's history.

This adds a read operation. It does not touch the append script, the keyspace,
or the write-ordering protocol.
…back

Two of the five snapshot reads take arbitrary Prisma arguments, and a key-value
store cannot answer an arbitrary query. Only three production call sites exist,
all in the engine's executionSnapshotSystem, and both generic ones send a single
fixed shape, so the decorator recognises exactly those shapes and delegates
everything else. Each matcher rejects an argument object carrying a key it does
not know, because a query that has drifted must be answered correctly by
Postgres rather than approximately from Redis.

A miss is the coexistence path, not an error: a pre-cutover run or expired
history falls back to Postgres. The entry supplies every scalar column, and the
checkpoint and waitpoint rows are read back through the delegate only when the
entry says they exist, so the common read of a running run makes no Postgres
call at all.

Which runs read from Redis is a hash of the run id, so a run does not change
store between two reads of one poll, two instances of the same dial agree, and
raising the dial only ever adds runs to the cohort.
Two rules, because neither can see what the other leaves behind. A terminal run
whose keyspace never received the completion expiry gets one applied, so it
reaps on the schedule a healthy terminal append would have set. A keyspace with
no run row at all, past an age threshold, is deleted outright — that is a
crashed birth, which is non-terminal so it carries no expiry and has no run row,
so the first rule can never match it.

It never reaps on an unknown answer: a live run is left alone however old its
keyspace, a young orphan is left for the birth that may still be in flight, and
a batch whose run lookup failed is skipped rather than treated as absent.

Run rows are resolved through the run store rather than a raw client, because
under the run-ops split a run can live on either database and a raw lookup would
report a live run as an orphan.

Nothing schedules this. The engine's worker has to run it, and run-store cannot
reach the engine.

Also moves the decorator suites onto the worker-scoped container fixture. The
per-test one boots a Postgres and a Redis container for every test, which is
what the replication tests need and these do not; the sweeper suite alone went
from repeated two-minute timeouts to ten seconds.
…eads on

The engine's own flows, driven against the decorator with every snapshot read
served from Redis, injected through the store seam that runStoreInjectability
already proves. Same flows, same expectations, different store underneath — the
point is that nothing in the engine has to know, so no existing suite changes.

Covers a run driven to completion, the execution data at each step, a
since-window wider than the fifty cap, and a pre-cutover run with no keyspace
falling back to Postgres.

The environment-boundary test asserts parity rather than a fixed shape: whatever
Postgres answers for a foreign environment, Redis has to answer the same, or the
tenant boundary behaves differently once reads move over.
…oth stores

Three defects, all of which passed the existing suites because no test drove a
snapshot that actually carried waitpoints, and because the parity suite compared
createdAt against a value it had just read back from the row.

The decorator never passed a cycle to the append, so no wp:<cycleSeq> key was
written for any snapshot and the completed-waitpoint side of Redis was
permanently empty. It now mints a cycle when the id set differs from the current
head and carries the previous cycleSeq forward when it does not, so a resume
writes the record set once and the copy-forwards that follow write no key at
all.

The since-window hydration returned an empty completedWaitpointOrder. That
column is not the join: the engine reads it off the head row as the oracle that
gives each completed waitpoint its position in a batch, so an empty order
resumed every batched triggerAndWait with an undefined index.

Seven of the eight write sites stamped the entry from the app clock while
Postgres stamped its own column default, so the two stores held different
instants for one snapshot. The decorator now supplies createdAt, and an equal
updatedAt, at every site, and the standalone path supplies it too rather than
reading the row back. Beyond making the field comparable, this aligns the
since-window: the cursor is resolved from one store and applied in the other,
and two different instants misfilter that window.

The parity suite gains an independent clock-provenance guard, and a case proving
an absent instant still takes the database default, which is what keeps the
store's behaviour unchanged while the decorator is off.
…oint

The generator that emits the pass-through store base is a runnable script, not
dead code, and the same glob covers any script added there later.
…arity real

The sweep discovered keyspaces by their cur key, which the append script writes
only when an entry is valid. A keyspace whose entries all carry an error has no
cur and no index, so neither sweep rule could ever see it and it leaked with no
expiry, which is the same unbounded leak the second rule exists to close. It now
scans on the entry hash, which every append writes, and the age probe falls back
to the newest instant in that hash when the index is empty.

Enumerating a run's cycle keys used KEYS. That command iterates the whole
database and blocks while it does, and a hash tag routes a key without scoping
the scan, so a sweep pass would have issued one full keyspace scan per run. It
now reads the dense cycle high-water counter the append script maintains, which
is the same source the store's own terminal-expiry loop uses, and pipelines the
existence checks into one round trip.

The timestamp parity assertion was still tautological. The previous commit added
a note saying the builders receive an independent instant and did not change the
builder calls, which kept reading the value off the row under test. Every case
now mints one instant, passes it to the store, and gives the builder the same
value, so a write site that stops forwarding the caller's instant fails here.

Also documents what an injected fault actually does at each write path, since
only the birth path rethrows, and scopes a run count in the chaos suite to the
environment under test.
Review asked why the pass-through base is tested against a hand-built delegate
rather than a real store. Checking what the compiler already guarantees showed
the test's own stated reason was wrong, and that one of its cases could not fail.

implements RunStore already rejects a missing member with TS2420, so the claim
that a method added later would become a silent hole was not true. The case
comparing the class against the generated name list could not detect a parse
miss either, because both the class and the list come from one parse of the
interface, so a miss drops the member from both sides. The generator's comment
asserting otherwise was false.

Parity now lives where it can actually fail: assertions tying the name lists to
keyof RunStore in both directions, and one rejecting a public member the class
declares and the interface does not. They sit in src rather than in a test,
because the build config excludes test files, so a type assertion written in a
test is never checked. Each was verified by making it fail.

What the compiler cannot see is inside the forwarder bodies, since every one is
typed (...args: any[]): any. A forwarder wired to the wrong member, or dropping
an argument, typechecks cleanly. The remaining probe covers exactly that, using
a per-member sentinel so a misrouted body returns the wrong value rather than
merely returning something. Verified by rewiring a forwarder: typecheck passes,
the probe fails and names the member.

Renames the double to forwardingProbe across both suites and says at the top why
a container cannot replace it: no database is involved in whether a pass-through
passes through.
A member was removed from the generated list while verifying that the new parity
assertion fails when one goes missing, and the restore did not run, so the
verification state was committed. Regenerated from the interface.

The assertion did its job: typecheck rejects the list, naming the missing
member.
… client key prefix

Two defects from review, both silent.

An append staged inside a transaction dropped its expected-head argument, and
the post-commit flush passed undefined in its place. That disabled the
compare-and-set for every snapshot written inside a transaction, which is the
path both engine transaction writers use, so a stale append that the store would
have refused as forked was written instead and became the head. The expectation
now travels with the staged entry.

The sweep built its scan pattern without the client key prefix. ioredis prepends
that prefix to keys for ordinary commands but not to a SCAN MATCH pattern, and
returns matched keys with it still attached, so a prefixed client made the sweep
match nothing and report a clean pass. The engine sets a prefix on every other
Redis client it builds, so this would have surfaced at wiring time as a reaper
that silently protected nothing.

Also removes a keyPrefix option on the sweep that could never work: the keyspace
prefix belongs to snapshotKeys in the store, which writes snap: keys
unconditionally, so there was no other keyspace to point it at.

Both fixes have a test verified by reintroducing the defect: the staged stale
append is written without the guard, and the prefixed sweep scans nothing.
… source

The generator was scaffolding for a one-off job: writing 70 near-identical
forwarders. Keeping it meant carrying a hand-rolled scanner over the interface
body, because the TypeScript compiler API is not resolvable in this workspace,
which is more machinery than a file that changes only when the interface does.

The two files it produced are now maintained by hand, and their headers say so.
Nothing is lost, because the generator was never what guaranteed they were
right. That is the compiler: implements RunStore rejects a missing member, and
the parity assertions tie both name lists to keyof RunStore in each direction
and reject a public member the interface does not declare. Each was re-verified
by making it fail after the generator was removed.

Also drops the knip entry that existed only to treat that script as an entry
point.
The sweeper needs to know which run statuses are terminal and cannot import the
list, because run-engine depends on run-store rather than the other way round.
The copy's comment claimed a parity test kept the two equal. No such test
existed, so the claim was false and the copy could drift silently.

Drift is not symmetric. A status added to the engine and not the copy makes the
sweep treat a finished run as live and never apply its completion expiry. A
status removed from the engine and not the copy makes it treat a live run as
finished, and that reaps state a run is still using.

Verified by removing a status and rebuilding: the test fails and reports seven
members against eight.
The forwarders were (...args: any[]): any, so the compiler could not see inside
them. A body that called the wrong delegate member, or reordered its arguments,
typechecked cleanly. That is not a theoretical gap: it is why a runtime probe
existed to catch it, and it is the same shape of hole that let three other
defects on this branch pass a green suite.

Every member now restates its interface signature and forwards its arguments by
name, so both mistakes are compile errors. Verified by making them: a forward to
the wrong member produces two type errors, and swapping two arguments produces
one.

Seven members are overloaded. TypeScript cannot express a single body that
satisfies an overload set, so their overloads are declared for callers and their
one implementation forwards through a cast. That cast is now the only place the
compiler is not checking the forward.

The probe shrinks to what is left: those seven casts, a dropped OPTIONAL argument
(omitting a trailing tx compiles and silently stops forwarding the transaction),
and whether the data property is read live or captured once. Its header states
which of those the compiler already covers.

Headers on both files now describe what they are rather than that they were once
scaffolded.
Typing the forwarders closed the wrong-member and reordered-argument holes but
not this one: omitting a trailing OPTIONAL argument still compiles. Two
forwarders did exactly that, because the retyping pass read parameter names with
a pattern that a preceding inline comment defeated, and both affected parameters
happened to be optional and commented.

The effects were silent and not small. findLatestExecutionSnapshot stopped
applying its tenant scope, so a direct use of the base could read across the
environment boundary. upsertWaitpointTag stopped applying its residency hint, so
a tag write for a new-database environment would land on legacy.

A source-level guard now asserts that every single-signature member forwards
exactly the parameters it declares, in order. It reads the interface and the base
and compares them, because that property is invisible to the compiler by
definition. It carries a vacuity check, so a parse failure fails the suite
instead of quietly matching nothing, and that check earned itself immediately by
catching a parser that skipped every generic member.

Verified: with a parameter dropped again, typecheck reports zero errors and the
guard names the member and the missing argument.
A completed waitpoint with no batch index was invisible to every Redis read, so
a run resumed from the store lost that wait's result while Postgres still
returned it. That is every wait.for, every single triggerAndWait and every
token: the engine passes index as batchIndex ?? undefined, so only waits inside
a batch carry one.

The cause was reading the id set out of the ordered list. That list is the index
oracle and its positions ARE the indexes, so it can only ever hold indexed ids,
and deduping it yields a set missing exactly the index-less ones. Postgres has no
such restriction: its completed-waitpoint join records every id.

The cycle key now carries the complete distinct set in its own field, written
when the cycle is minted and read back beside the order. The order keeps its
meaning and stays index-only.

Two tests: one asserting an index-less wait survives a round trip with an empty
order, and one asserting the set matches the Postgres join for a mix of indexed
and index-less waits. Verified by deriving the set from the order again, which
makes the waitpoint vanish.

The suites missed this because every earlier case gave each waitpoint an index.
The previous fix stored the complete id set but left three places still deriving
it from the ordered list, and the ordered list holds only batch-indexed ids.

A carry-forward decided on the order alone. Two DIFFERENT single waits both
present an empty order, so they compared equal, the second inherited the first's
cycle, and a read returned the wrong waitpoint entirely. The comparison now
requires the id set to match as well.

The dequeue site built its Redis refs from the ordered list while the delegate
connects the complete set in Postgres, so an index-less waitpoint reached
Postgres and never reached Redis. Refs are now built from the complete set, with
the index taken from the ordered list where the id appears in it.

The entry decode derived the set from the order too, which meant getLatest and
getById returned an incomplete set. That is the hot read: findLatestExecutionSnapshot
hydrates the waitpoint rows from it, so a resume would have fetched no row at all
for a single wait. The read scripts now return the stored set alongside the order.

Four tests, each verified against its own defect: two consecutive single waits
keep separate cycles, a repeated one still carries forward, the dequeue snapshot
keeps an index-less id, and the hot read hydrates its row.
A sweep for values derived where they should be read found one more. The
hydrated payload left out lastHeartbeatAt entirely, so a Redis-served read
returned undefined for it where Postgres returns null. No code writes that
column, so null is not a guess: it is the only value Postgres ever holds.

The effect was small but constant, on every read served from Redis, and it is
the kind of difference a comparator has to either explain or chase.

Guarded by comparing the KEY SET of the two payloads rather than their values, so
a column omitted by the hydrator fails as a missing key rather than passing as an
absent value. Verified by removing the line again: the test names the column.

Also covers the timestamp write on both schema variants. updatedAt is declared
@updatedat, which Prisma manages, so whether an explicit value survives a create
is a property of the client rather than of the schema, and the two variants are
separately generated clients. Agreeing declarations were not evidence. Both
honour the caller's instant.
An independent pass hunting one shape, a value derived where it should be read,
found these. None was reachable from a test that existed.

The hot read paid a second Redis call in its most common case. An entry with no
wait cycle has no waitpoints by construction, and the hydrator asked the store to
confirm that rather than concluding it, on every read of a run that is not
resuming from a wait. It now distinguishes the three cases and only asks when it
genuinely does not know.

decodeWaitpointIds still reconstructed the id set from the ordered list when the
stored set was absent. That is the sixth instance of the bug fixed five times,
surviving as a fallback. It is unreachable today, because both fields are written
by one command, but the reconstruction is lossy by nature and the loss is silent.
A missing set beside a non-empty order now reports the entry as not present,
which sends the caller to Postgres.

The window read checked one liveness anchor where the append script deliberately
checks two and explains why. An index lost to eviction while the entry hash
survived would have reported an empty hit rather than a miss, so the poll would
have returned nothing new for the rest of the run's life while Postgres held the
transitions.

The wrapped store handle dropped the staging buffer, so a handle taken inside a
transaction would have appended before the commit. No caller writes a snapshot
through it today.

Also restores excess-property checking on the nested snapshot writes. Routing
them through a generic helper let a typo'd field name compile and fail at
runtime; a concrete parameter type brings the check back at the five sites that
pass a fresh literal. Verified: a bogus field is now TS2353.
Two paths reached the same silent hang, and neither had a test.

When the store refuses a carried pointer it was still writing the entry, which
then became the run's head with no pointer at all. A read of that answers
present-with-nothing, and present-with-nothing is precisely the signal that tells
the engine's read-repair it does not need to look, so the runner got a
waitpoint-less continue and dropped it. Refusing the pointer stays right; the
append now mints a fresh cycle from the refs the caller carried, in the same
atomic call, so the entry always has a pointer that can be trusted. Refs are
optional and only the fallback needs them, so callers that supply none keep the
previous behaviour.

The second path needs no refusal at all. An entry whose cycle key has gone still
carries its pointer, and the read answered empty for it too. That is reachable by
eviction and also by the completion expiry, which is applied to every key for a
run at one moment but lets them expire independently. Reads now report such an
entry as not present, which sends the caller to Postgres, where the join rows
still are. The hot read and the window both fall back rather than serve it.

Three tests. The refusal is driven at the store, because the decorator cannot
reach it on purpose: its probe sees the id set no longer matches and mints a new
cycle, so the refusal only happens when the key vanishes between probe and
append. Each verified against its own defect.
At that position Postgres holds no snapshot rows, so a run routed away from
Redis by the cohort percentage reads nothing at all. The percentage is only
meaningful while both stores hold the data.

Fixing it in the dial rather than documenting the constraint makes the
combination unreachable, instead of leaving three settings that have to agree by
convention.
…ecorator-tri-13449

# Conflicts:
#	internal-packages/run-store/src/index.ts
…edis cluster

SCAN carries no key, so a cluster cannot route it: one connection iterates one
node's keyspace and then reports a completed cursor. The sweep now fans out over
every master, resolved per pass so a failover cannot leave it scanning a stale
node list, and reports how many it covered.

Rule 2 deletes a whole keyspace when the run lookup returns no row. That lookup
partitions ids by residency and reads each store's replica, so an absent row is
not proof of absence. Deletion now needs the keyspace to be seen absent in two
separate passes, and any run found to exist clears its mark.

Both window reads returned the head's waitpoint order without its dangling flag,
so a head whose cycle key had expired came back with an empty order rather than
falling back to Postgres, losing every position on a batched resume.

Also lets both classes take a caller-built client so they can reach a cluster at
all, and gives the sweep a deadline and an abort signal so a pass can stop inside
its budget instead of being killed mid-cursor.
d-cs added 10 commits August 27, 2026 18:01
Red. Nothing alerts on a forked append. Under store residency the writer set for
a run is stable, so a fork means a genuinely lost append or a real concurrent
writer, and the head has already diverged from Postgres by the time it is
observed. Today that is a warn line nobody reads.

The rule is asserted against the counter name the code exports, and every name
matcher in the file is asserted to be suffix-anchored: the exported prefix is
added by the telemetry pipeline, so an exact-name rule matches nothing and says
nothing while it does so.
…not noise

The outcome was documented as another writer advancing the head, which a repair
cannot help. That reasoning came from a model where any writer could append to
any run. A run's store is now fixed at birth, so the writer set per run is
stable and a fork means a lost append or a genuinely concurrent writer, with the
head already disagreeing with Postgres by the time it is seen. Logged at error
and paged on.

Still no repair. A repair here re-derives the head from Postgres, and two
attempts at that in this area were reviewed and withdrawn as unsafe, so the
decision stays with an operator; the rule says what to do instead.

The append-failure rule's remediation was also wrong as written: turning the
dial to off no longer stops appends for runs already resident, so it named a
step that would not stop the failures.
…e Postgres head

Red. repairRedisHead does not exist yet, so all 17 cases fail.

The repair job currently does nothing for the seven execution statuses the
mirror's transition path can lose an append for, and it reads the latest
snapshot through the decorated store, which serves Redis's own stale head back
to it once reads are served from Redis.
…d the lost entry

Green. 17/17 in taskRunExecutionSnapshotStore.repair.test.ts.

The repair job wrote nothing to Redis for the seven execution statuses the
mirror's transition path can lose an append for, so the recovery path for a
lost append was a no-op and the mirrored history kept a permanent gap.

It also read the latest snapshot through the decorated store, which serves the
mirror's own stale head once reads come from Redis, so the repair would decide
the snapshot was no longer current and stop at the moment it was needed.

The repair now reads the Postgres head through the undecorated store and
re-appends it. Additive only: the append script's duplicate guard makes a
re-append of an entry that already landed a no-op, and its no-keyspace refusal
keeps a run that was never resident non-resident. Nothing is deleted or
expired.
…s moved on

Green. 18/18 in taskRunExecutionSnapshotStore.repair.test.ts.

The repair is enqueued with a minute's delay, so the run has usually
transitioned by the time it runs. Targeting only the snapshot the job named
meant the common case fell straight back to doing nothing, and the mirror kept
serving a head the run had left. The repair now targets whatever Postgres holds
as the head at the moment it runs, and it runs ahead of the queue recovery so a
stale head is healed for every execution status rather than only the ones whose
queue state still needs correcting.
… the real append script

UNRUN. Docker on this machine is saturated by another workspace (222 running
containers), so these four cases were written and typechecked but not executed.
They are the only place the append script's duplicate and no-keyspace guards are
exercised by the repair rather than asserted about it.
It is used only by the boot check that produces it, so exporting it added a
public name for nothing.
A fork means the head is not what the write expected. The two compare-and-set
sites assert the head, so once it is wrong every later append from them forks as
well and the mirror is frozen for the rest of the run. Reporting that and moving
on left the run diverged permanently.

Reproduced against a real cluster by deleting the head key on a live run, which
is what an eviction does: Redis held three entries against eight in Postgres, the
head stayed empty, five appends forked and paged, and nothing repaired it.

A fork now asks for the repair that already exists. That repair re-derives the
head from Postgres without asserting the head, which is the one operation this
needs. After the change, the same fault heals in one repair cycle and the head
matches Postgres again. The entries lost while the head was frozen stay missing,
because appending them after newer entries would corrupt the order.
…name the halted outcome

Repair writes collapsed into the catch-all site, so the one number that says
whether a repair worked was missing, and a repair racing a live transition looked
identical to a real divergence on the fork alert.

The repair also reported the outcome as `off` when it was refused by the hard
stop, which would tell an operator the rollout dial was down when it was not.
…ost is set

Every boot assertion was keyed on the deployment dial being past off. The
per-organisation override can put one organisation at dual-write while the
deployment dial is still off, which is how a ramp starts, so a ramped
organisation ran on a configuration nothing had checked.

Reproduced by booting with a zero completion expiry, the deployment dial at off
and one organisation pinned to dual-write: the process started and served
traffic. It now refuses, and serves nothing before exiting.

Reachability stays keyed on the dial. That one is a transient fault rather than
bad configuration, and refusing on it below the final position would bleed fleet
capacity during a Redis incident.
devin-ai-integration[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

d-cs added 5 commits August 28, 2026 12:00
…at caused it

The two rule bodies issue their own Redis commands and none of them were
guarded, so an error from any one of them left the whole pass. The scan
restarts from the same place each time, so collection then stopped for
every run until a human removed the key. Observed against a single
malformed keyspace: seven consecutive passes collected nothing.

A bad keyspace now costs itself one pass. The count is reported so that
containment is not mistaken for nothing going wrong.
The environment half of the hard stop converged over a rolling deploy
rather than a flag interval. For the length of that deploy the fleet is
mixed: a stopped process writes no transition, then a running one
asserts a head that was never written and forks. Every fork enqueues a
repair, and the repair restores the head but not the entries behind it.
A control whose own convergence manufactures the divergence it exists to
stop cannot be the way in.

The flag now converges in one flag interval. Boot refuses to start when
the retired variable is still set to 1, because a variable that no
longer halts anything leaves an operator believing the mirror is stopped
while it runs. A value of 0 carries no intent and is ignored.

The guaranteed-inert state remains an unconfigured host, which is
bootstrap config rather than an operational control.
A run's residency is its Redis keyspace, and the test for it lives in
the append script, so the store had to complete a round trip just to
learn a run was not its own. That put Redis on the path of every
transition of every run, resident or not: two percent with a healthy
Redis, four times with a slow one, and it never decayed, because a fleet
holding no resident runs still asked once per transition.

Residency is monotonic, which is what makes a local answer sound. Only a
birth creates a keyspace: the script refuses a transition into a dead
one, and the repair appends as a transition. So a keyspace the script
reports absent is absent for good, and that answer may skip the network.
The reverse is a hint only, and a stale one costs a single round trip
that returns skippedNoKeyspace.

Only the script's own reply may create a negative. A birth path can be
re-entered, so a birth that did mirror can reach the not-mirrored branch
on a retry once the short-lived override cache has moved, and inferring
absence from that local decision would suppress every later transition
of a resident run and freeze its head.
…nswering

Two faults under one cause. The read paths fell back on a miss and on a
dangling cycle but not on an error, so a Redis that stopped answering
turned an engine read into a throw once the command timed out. Postgres
holds every row below redis-only, so falling back is strictly better
than failing. At redis-only it still throws, because nothing else holds
the rows and an empty history served as real is worse than an error.

The second is the cost of the first probe for a run a process has not
seen. The residency cache removes the steady-state round trip but not
that one, and under a brownout it costs the whole retry budget. A
per-process breaker opens after a short run of connectivity failures and
refuses later calls locally, so the endpoint takes itself off the run
path with no operator and no deploy.

Script errors never count toward it. A wrong type or a missing script
fails identically on every retry against every node, so counting those
would open the circuit on a defect and stop mirroring runs that are
healthy.
…t an append

The repair restores the head but not the entries lost with it, so a
keyspace ends up holed with a correct head. At dual-write that is
invisible and harmless. At redis-read the window read serves a range
straight from Redis, and its guards see a miss and a dangling cycle but
cannot see a HOLE, so a window that should hold eight entries returns
four with nothing logged. A history that is short rather than wrong is
the harder kind to notice.

The keyspace now records that its history is untrustworthy and both
window commands refuse, which routes the caller through its existing
miss path to Postgres. Point reads are left alone, because the repair
does guarantee the head converges and refusing those would send every
transition of a once-forked run to Postgres for the rest of its life.

Backfilling instead would be worse. A late append takes a fresh
sequence number, and the window scripts walk the index in sequence order
as though it were time order, so an old entry with a high sequence
truncates the window harder than the hole does.

A fork sets the marker itself, and so do the repair's early exits. The
head converging on its own is exactly the case that hid this: four
entries against eight, with a matching head, and nothing to say so. The
marker is a field on the seq hash, so the keyspace expiry governs it and
it is only ever set on a keyspace that already exists.
devin-ai-integration[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@coderabbitai coderabbitai Bot 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal-packages/run-engine/src/engine/index.ts (1)

362-363: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use a counter base name without _total.

The Prometheus exporter defaults to adding _total to counters. The current instrument can therefore export as run_engine_snapshot_store_sweep_pass_total_total, which does not match the alert. Rename it to run_engine.snapshot_store.sweep_pass.

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9232c690-ab4b-40ba-81a2-13185d2bc02c

📥 Commits

Reviewing files that changed from the base of the PR and between 7a6541e and ff161df.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (2)
  • apps/webapp/app/v3/featureFlags.ts
  • internal-packages/run-engine/src/engine/index.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.

📜 Review details
⏰ Context from checks skipped due to timeout. (21)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (20, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (17, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (19, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (13, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (9, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (16, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (14, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (21, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (8, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (18, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (11, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (15, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (12, 24)
  • GitHub Check: e2e / 🧪 CLI v3 tests (warp-windows-latest-x64-8x - npm)
  • GitHub Check: e2e / 🧪 CLI v3 tests (warp-windows-latest-x64-8x - pnpm)
  • GitHub Check: packages / 🧪 Unit Tests: Packages (1, 3)
  • GitHub Check: packages / 🧪 Unit Tests: Packages (2, 3)
  • GitHub Check: e2e-webapp / 🧪 E2E Tests: Webapp (2, 2)
  • GitHub Check: packages / 🧪 Unit Tests: Packages (3, 3)
  • GitHub Check: e2e-webapp / 🧪 E2E Tests: Webapp (1, 2)
  • GitHub Check: internal / 🧪 Unit Tests: Internal
🧰 Additional context used
📓 Path-based instructions (10)
New code must target Run Engine V2 through the singleton in `app/v3/runEngine.server.ts`; do not reintroduce V1 execution paths. V1 branches may only reject or finalize gracefully with a clean 4xx.

📄 CodeRabbit inference engine (apps/webapp/CLAUDE.md)

Files:

  • apps/webapp/app/v3/featureFlags.ts
Never use `request.signal` to detect client disconnects. Use `getRequestAbortSignal()` from `app/services/httpAsyncStorage.server.ts`, which is wired to Express response close events.

📄 CodeRabbit inference engine (apps/webapp/CLAUDE.md)

Files:

  • apps/webapp/app/v3/featureFlags.ts
For dashboard changes, visually verify the running Remix app with Chrome DevTools MCP, using snapshots, screenshots, interaction, and console-message checks as appropriate.

📄 CodeRabbit inference engine (apps/webapp/CLAUDE.md)

Files:

  • apps/webapp/app/v3/featureFlags.ts
**Prefer static imports over dynamic imports.** Only use dynamic `import()` when:

📄 CodeRabbit inference engine (AGENTS.md)

Files:

  • apps/webapp/app/v3/featureFlags.ts
  • internal-packages/run-engine/src/engine/index.ts
Add crumbs as you write code — not just when debugging. Mark lines with

📄 CodeRabbit inference engine (AGENTS.md)

Files:

  • apps/webapp/app/v3/featureFlags.ts
  • internal-packages/run-engine/src/engine/index.ts
Use zod for validation in packages/core and apps/webapp

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Files:

  • apps/webapp/app/v3/featureFlags.ts
Access environment variables through the `env` export of `env.server.ts` instead of directly accessing `process.env`

📄 CodeRabbit inference engine (.cursor/rules/webapp.mdc)

Files:

  • apps/webapp/app/v3/featureFlags.ts
Use function declarations instead of default exports

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Files:

  • apps/webapp/app/v3/featureFlags.ts
  • internal-packages/run-engine/src/engine/index.ts
Use types over interfaces for TypeScript

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Files:

  • apps/webapp/app/v3/featureFlags.ts
  • internal-packages/run-engine/src/engine/index.ts
When creating or editing OTEL metrics (counters, histograms, gauges), ensure metric attributes have low cardinality by using only enums, booleans, bounded error codes, or bounded shard IDs

📄 CodeRabbit inference engine (.cursor/rules/otel-metrics.mdc)

Files:

  • apps/webapp/app/v3/featureFlags.ts
  • internal-packages/run-engine/src/engine/index.ts
🔇 Additional comments (3)
internal-packages/run-engine/src/engine/index.ts (2)

6-6: LGTM!

Also applies to: 30-31, 123-124, 307-309, 1136-1140, 1423-1427, 1858-1884, 1907-1911, 2524-2540, 2967-3013, 3024-3043, 3059-3068


265-283: 🩺 Stability & Availability

No change needed. snapshotStore.runSweep is supplied as a wrapper before new RunEngine. The late binding occurs inside that wrapper, so hasRunner is true, the cron is enabled, and the sweep metrics are created during construction.

apps/webapp/app/v3/featureFlags.ts (1)

2-2: LGTM!

Also applies to: 41-45, 53-64, 169-172, 178-182, 201-203, 221-240

The entry carries only the checkpoint id. The row itself stays in
Postgres and is read back through the delegate, and only when the entry
says one exists, so a running run with no checkpoint costs no Postgres
read at all.

Both halves of that split are now asserted. A snapshot served from Redis
returns its checkpoint row, with the location and image reference a
resume needs, and the answer equals the one Postgres alone would give. A
snapshot with no checkpoint performs no delegate read.

Untestable by driving traffic: checkpointing is a deployed-supervisor
behaviour and a local run never produces one, so the only way to cover
it is against a seeded row.
@d-cs
d-cs force-pushed the feat/snapshot-store-wiring-tri-13451 branch from e303589 to 469bb49 Compare August 28, 2026 13:25
@d-cs

d-cs commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

Manual testing complete

28 scenarios run against a real six node Redis cluster (3 masters, 3 replicas) with metrics
flowing, plus a webapp running on two processes for the multi-instance cases. Everything below is
local, reproducible, and separate from the automated suite.

The headline check

A full reconcile of Redis against Postgres, over every keyspace on the cluster:

runs_with_keyspace=101  agree=99  head_mismatch=0  missing_in_pg=0  terminal_without_ttl=0

That includes 14 changes of the per-organisation dial while runs were in flight across both
processes, with zero forked appends. The two disagreements were both deliberate: one keyspace left
over from before the residency change, and one run whose head I corrupted by hand to test the
repair.

The 28 scenarios

# Scenario Result
1 Reconcile Redis against Postgres over every keyspace pass
2 Force a forked head pass, found a defect
3 Prove the repair recovers a lost append pass, found a defect
4 Check the batch waitpoint order pass
5 Move the dial down, then up, over live runs pass
6 Set the Redis host, keep the dial off pass, cost quantified
7 Flap the per-organisation dial pass
8 Confirm a per-organisation pin is not a rollback pass
9 Move to the top dial position while Redis is down pass
10 Move the dial with two webapp processes running pass
11 Make Redis slow rather than dead pass, cost quantified
12 Move the dial down during a real Redis outage pass
13 Exercise the transaction staging buffer and a rollback pass
14 Restore a checkpoint from a snapshot served by Redis pass, covered by test
15 Check the completion expiry is applied pass
16 Sweep one orphaned keyspace pass
17 Kill a webapp during a sweep pass
18 Run two sweeps at once pass
19 Sweep with a lagging read replica pass, real streaming lag
20 Bypass the boot checks using one organisation pass, found a defect
21 Confirm the boot failure behaviour pass
22 Test both feature flag guards pass
23 Cover every write site pass, 8 of 10 observed, 2 by test
24 Set a partial read threshold pass
25 Confirm the store is inert with no host configured pass
26 Shut a webapp down cleanly pass
27 Check each flag page offers only what it can save pass
28 Save an organisation dial under load pass

Scenarios 2, 3, 6, 11, 19, 20 and 23 are expanded below, either because they found something or
because the number they produced matters.

What the notable ones proved

A dial change cannot move a live run's store. A run resident in Redis kept mirroring while the
deployment dial went to off and back: 8 entries in Redis, 8 in Postgres, heads identical, no
forks. Same result for the per-organisation dial pinned off mid-flight. A run finishes on the store
it was born on, which is the property everything else rests on.

The lowest dial position is inert for run execution, not just for requests. It was not, when
first measured. Births were gated by the dial but transitions were not, and the residency test lives
inside the append script, so the store had to complete a round trip just to learn a run was not its
own. With a slow Redis that cost four times the normal run duration, for every run, and it did not
decrease as resident runs drained. Requests were unaffected throughout. Fixed by letting a process
remember the answer, with a breaker bounding the first call: script calls on the server now drop to
zero after the first per run, where they were one per transition.

A degraded read replica cannot cause a deletion. Tested with a real streaming replica running a
90 second apply delay, so the orphan sweep genuinely could not see a run that existed. The sweep
marked the keyspace, held it across two passes, and withdrew the mark when the replica caught up.
Nothing was deleted. Worth knowing for operators: the real safety margin here is the no run row age
threshold, which defaults to 24 hours and is orders of magnitude above any plausible lag.

Batch waitpoint order agrees. Three wait cycles, membership and sequence identical between
Redis and Postgres in all three.

Clean shutdown, cluster fan out, expiry and reaping all behave. Terminal keyspaces get the
configured expiry and live ones do not. The sweep fans out over every master, so a pass cannot
report a clean sweep having scanned one node of six. An orphan is marked on one pass and only
removed on a later one.

Six defects found by running these, all fixed in this PR

  1. A forked append started no repair, so a single fork made the divergence permanent.
  2. Configuration was validated only past the lowest dial position, so a deployment could start with
    an expiry of zero in exactly the configuration a gradual rollout uses.
  3. One keyspace with an unexpected Redis type stopped garbage collection for every run,
    indefinitely. The scan restarts from the same place each pass, so it never recovered on its own.
    Now contained to the keyspace that caused it, and counted.
  4. The environment variable half of the hard stop converged over a rolling deploy rather than a flag
    interval. During that window a stopped process skips a transition and a running one asserts a
    head that was never written, which forks. The flag is now the whole stop.
  5. Redis on the run path at the lowest dial position, above.
  6. A repair restored the head but not the entries lost with it, and no read guard could detect a
    gap, so a shortened history was served as though it were whole. A keyspace that lost an append
    now records that its history is untrustworthy and window reads fall back to Postgres.

Worth calling out on the last one: re-appending the lost entries would have made it worse. A late
append takes a fresh sequence number, and the window scripts walk the index in sequence order as
though it were time order, so an old entry with a high sequence truncates the window earlier than
the hole did.

Known limits, stated rather than buried

Two of the ten write sites are covered by tests rather than by observation. A local worker dequeues
faster than any expiry can fire, and the other site has a single internal caller.

The hard stop is not safe at the top dial position, where reads continue from a frozen mirror
because nothing else holds the rows. Accepted and documented rather than engineered around, and it
binds only at the last position.

Residency is remembered per process. That is sound, because only a birth creates a keyspace so an
absent one stays absent, but it means a rolling deploy has every new process re-learn each run in
flight. Moving it onto the run row is tracked separately and is worth doing before reads move.

Deployment posture

Ship with no Redis host configured. That state is fully inert: nothing is constructed, nothing is
probed, and it needs no action from an operator. Set the host and the dial only when starting the
rollout.

@devin-ai-integration devin-ai-integration Bot 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.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 1 new potential issue.

Devin Review

Comment on lines 929 to +934
// makes that misconfiguration unreachable rather than merely documented.
if (this.mode === "redis-only") return true;

// Halted heads are frozen, and below `redis-only` Postgres still holds the whole log, so serving
// reads from it is strictly better than serving a head that stopped moving.
if (this.halted()) return false;

@devin-ai-integration devin-ai-integration Bot Aug 28, 2026

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.

🔍 redis-only read path assumes Postgres is empty, but this build still writes it

At redis-only, readsFromRedis forces Redis and #readMayFallBack refuses fallback, on the premise that Postgres holds no snapshot rows there. The boot check states this build always writes Postgres snapshots and the delegate always writes them, so a Redis read error at redis-only throws into the engine even though the Postgres row exists. Forward-looking and dial-gated, but the premise is currently false for this build.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Accurate, and it is a known and accepted limitation rather than an oversight, but your framing is sharper than what the code says so I want to answer it properly.

You are right that the premise is currently false. This build does still write Postgres snapshots, the boot check warns about exactly that, and #readMayFallBack refuses fallback at redis-only on a premise that only becomes true after Postgres stops being a snapshot writer.

I am leaving it, for one reason: at redis-only the position exists to prove Redis alone is sufficient. Falling back to Postgres there would make it silently pass while depending on the store it is meant to have replaced, and the run that mattered would look fine. An error is the honest answer, and it is loud.

Two things that bound the exposure. It is reachable only at the last dial position, which is several tickets away and gated on a soak. And the boot warning is already the signal that the premise is not yet true, which is why it is a warning rather than a refusal.

The related case is worth naming since it is the same premise: the hard stop is also unsafe at redis-only, because readsFromRedis returns before consulting it, so a stop there leaves writes stopped and reads coming from a frozen mirror. That has been accepted explicitly as a limitation we will not engineer around, and both are recorded against the write-cutover work rather than this PR.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9a938b77-1b81-43a5-a4f7-c1cf78f1c678

📥 Commits

Reviewing files that changed from the base of the PR and between ff161df and 469bb49.

📒 Files selected for processing (1)
  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.checkpoint.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.

📜 Review details
⏰ Context from checks skipped due to timeout. (15)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (17, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (20, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (11, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (19, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (13, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (21, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (18, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (16, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (15, 24)
  • GitHub Check: e2e / 🧪 CLI v3 tests (warp-windows-latest-x64-8x - npm)
  • GitHub Check: e2e / 🧪 CLI v3 tests (warp-windows-latest-x64-8x - pnpm)
  • GitHub Check: e2e-webapp / 🧪 E2E Tests: Webapp (2, 2)
  • GitHub Check: e2e-webapp / 🧪 E2E Tests: Webapp (1, 2)
  • GitHub Check: packages / 🧪 Unit Tests: Packages (3, 3)
  • GitHub Check: internal / 🧪 Unit Tests: Internal
🧰 Additional context used
📓 Path-based instructions (7)
We use vitest exclusively. **Never mock anything** - use testcontainers instead.

📄 CodeRabbit inference engine (AGENTS.md)

Files:

  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.checkpoint.test.ts
**Prefer static imports over dynamic imports.** Only use dynamic `import()` when:

📄 CodeRabbit inference engine (AGENTS.md)

Files:

  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.checkpoint.test.ts
Add crumbs as you write code — not just when debugging. Mark lines with

📄 CodeRabbit inference engine (AGENTS.md)

Files:

  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.checkpoint.test.ts
Use vitest for all tests in the Trigger.dev repository

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Files:

  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.checkpoint.test.ts
Use function declarations instead of default exports

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Files:

  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.checkpoint.test.ts
Use types over interfaces for TypeScript

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Files:

  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.checkpoint.test.ts
When creating or editing OTEL metrics (counters, histograms, gauges), ensure metric attributes have low cardinality by using only enums, booleans, bounded error codes, or bounded shard IDs

📄 CodeRabbit inference engine (.cursor/rules/otel-metrics.mdc)

Files:

  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.checkpoint.test.ts

Comment on lines +121 to +126
(plain as unknown as Record<string, unknown>).findExecutionSnapshot = (
...args: unknown[]
) => {
delegateReads += 1;
return (original as (...a: unknown[]) => unknown)(...args);
};

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the manual method replacement.

Lines 121-126 manually replace plain.findExecutionSnapshot. This is a mock/spy of production behavior. Observe the PostgreSQL query count through the testcontainer-backed database instead.

As per coding guidelines, “We use vitest exclusively. Never mock anything - use testcontainers instead.”

Source: Coding guidelines

Seven defects, four of them in code added earlier in this branch.

The circuit breaker let every concurrent caller through a half-open
window, so during an outage each one paid the full retry timeout instead
of one probing recovery. The slot is now reserved before the call and
released in a finally, and the open decision reads the state captured at
entry, since reading it after a re-open cannot tell a failed trial from
an ordinary failure.

The read fallback stopped before hydration, which makes a second Redis
call when a window row carries a wait cycle whose ids the read did not.
Only the head row of a window is decoded with its ids, so every other
row asks, and a failure there threw into the engine at redis-read rather
than falling back.

Two overlapping saves of one organisation dial shared a single pending
entry, so the first read's completion reopened the window while the
second was still outstanding, and a lagging replica could restore the
pre-save value. The flag now holds the generation that owns the read.

A transition into a keyspace whose index was lost rebuilt that index
holding one entry, so a window read saw a live index, reported a hit and
returned that entry as the whole range. It now marks the keyspace and
window reads fall back, which keeps the head moving where refusing the
transition would have frozen it.

Dropping a run read its wait cycle count from the seq hash, so a missing
seq read as zero cycles and left every cycle key behind, invisible to
the sweep as well because it discovers keyspaces by the entry hash.
Cleanup now probes past the count, bounded, inside the one slot.

A docblock still said forks are never repaired, four lines above the
code that repairs them, and the fork alert still told the on-call
engineer to resync by hand. Both corrected, and the alert now says which
way to read a falling rate and why halting makes it worse.

Also: a dead sweep-metric emitter removed, since the engine owns those
and its field list had already drifted; the retired halt variable read
through the env adapter rather than process.env; and every alert matcher
made tolerant of an optional _total, because a matcher that guesses that
wrong matches nothing while looking correct.

One seam-only fork test deleted as duplicative. The container-backed
fork case now asserts the full repair payload, which caught a wrong
expectation of the execution status a lock produces.

@devin-ai-integration devin-ai-integration Bot 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.

Devin Review found 1 new potential issue.

Devin Review

Comment on lines +771 to +780
switch (result.outcome) {
case "written":
return "reappended";
case "duplicate":
return "duplicate";
case "skippedNoKeyspace":
return "notResident";
case "forked":
return "forked";
}

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.

🔍 Repair duplicate outcome skips the gaps marker

On the alreadyCurrent and redisAhead paths, repairRedisHead marks the keyspace holed via markGapsIfResident, and the append path passes markGaps: true. When the append instead returns duplicate, the Lua script returns before its gaps branch and repairRedisHead returns without marking, so window reads for that keyspace are not forced to fall back to Postgres. Reachable only when the target id is present but not the head; worth confirming the skip is intended.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal-packages/run-store/src/taskRunExecutionSnapshotStore.ts (1)

755-767: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make the repair head check atomic.

Lines 731-745 inspect the Redis head, but Line 755 appends with no expectedCur. If a live transition writes a newer head after that inspection, this repair can overwrite it with the older Postgres row. Redis reads then return a regressed snapshot.

Use the observed Redis head as an atomic compare-and-set condition. If it changes, re-read both heads and retry or mark gaps after convergence.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 675f1acb-8f97-4dbf-bec4-7f69f00b250f

📥 Commits

Reviewing files that changed from the base of the PR and between 469bb49 and e4140b7.

📒 Files selected for processing (15)
  • apps/webapp/app/env.server.ts
  • apps/webapp/app/v3/snapshotStoreBoot.server.ts
  • apps/webapp/app/v3/snapshotStoreMetrics.server.ts
  • apps/webapp/app/v3/snapshotStoreMode.server.ts
  • apps/webapp/test/snapshotStoreAlerts.test.ts
  • apps/webapp/test/snapshotStoreMode.test.ts
  • docker/config/alerts/snapshot-store.yml
  • internal-packages/run-store/src/circuitBreaker.test.ts
  • internal-packages/run-store/src/circuitBreaker.ts
  • internal-packages/run-store/src/redisSnapshotStore.gaps.test.ts
  • internal-packages/run-store/src/redisSnapshotStore.ts
  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.hotPath.test.ts
  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.rampSites.test.ts
  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.transitions.test.ts
  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.rampSites.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.

📜 Review details
🧰 Additional context used
📓 Path-based instructions (14)
New code must target Run Engine V2 through the singleton in `app/v3/runEngine.server.ts`; do not reintroduce V1 execution paths. V1 branches may only reject or finalize gracefully with a clean 4xx.

📄 CodeRabbit inference engine (apps/webapp/CLAUDE.md)

Files:

  • apps/webapp/app/v3/snapshotStoreMetrics.server.ts
  • apps/webapp/app/v3/snapshotStoreBoot.server.ts
  • apps/webapp/app/v3/snapshotStoreMode.server.ts
Never use `request.signal` to detect client disconnects. Use `getRequestAbortSignal()` from `app/services/httpAsyncStorage.server.ts`, which is wired to Express response close events.

📄 CodeRabbit inference engine (apps/webapp/CLAUDE.md)

Files:

  • apps/webapp/app/v3/snapshotStoreMetrics.server.ts
  • apps/webapp/app/v3/snapshotStoreBoot.server.ts
  • apps/webapp/app/v3/snapshotStoreMode.server.ts
  • apps/webapp/app/env.server.ts
We use vitest exclusively. **Never mock anything** - use testcontainers instead.

📄 CodeRabbit inference engine (AGENTS.md)

Files:

  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.hotPath.test.ts
  • internal-packages/run-store/src/redisSnapshotStore.gaps.test.ts
  • apps/webapp/test/snapshotStoreAlerts.test.ts
  • apps/webapp/test/snapshotStoreMode.test.ts
  • internal-packages/run-store/src/circuitBreaker.test.ts
  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.transitions.test.ts
Test files must not import `app/env.server.ts`; pass configuration as options instead.

📄 CodeRabbit inference engine (apps/webapp/CLAUDE.md)

Files:

  • apps/webapp/test/snapshotStoreAlerts.test.ts
  • apps/webapp/test/snapshotStoreMode.test.ts
For dashboard changes, visually verify the running Remix app with Chrome DevTools MCP, using snapshots, screenshots, interaction, and console-message checks as appropriate.

📄 CodeRabbit inference engine (apps/webapp/CLAUDE.md)

Files:

  • apps/webapp/app/v3/snapshotStoreMetrics.server.ts
  • apps/webapp/app/v3/snapshotStoreBoot.server.ts
  • apps/webapp/app/v3/snapshotStoreMode.server.ts
  • apps/webapp/app/env.server.ts
**Prefer static imports over dynamic imports.** Only use dynamic `import()` when:

📄 CodeRabbit inference engine (AGENTS.md)

Files:

  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.hotPath.test.ts
  • internal-packages/run-store/src/redisSnapshotStore.gaps.test.ts
  • apps/webapp/test/snapshotStoreAlerts.test.ts
  • apps/webapp/test/snapshotStoreMode.test.ts
  • apps/webapp/app/v3/snapshotStoreMetrics.server.ts
  • apps/webapp/app/v3/snapshotStoreBoot.server.ts
  • internal-packages/run-store/src/circuitBreaker.test.ts
  • apps/webapp/app/v3/snapshotStoreMode.server.ts
  • internal-packages/run-store/src/circuitBreaker.ts
  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.transitions.test.ts
  • apps/webapp/app/env.server.ts
  • internal-packages/run-store/src/redisSnapshotStore.ts
  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.ts
Add crumbs as you write code — not just when debugging. Mark lines with

📄 CodeRabbit inference engine (AGENTS.md)

Files:

  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.hotPath.test.ts
  • internal-packages/run-store/src/redisSnapshotStore.gaps.test.ts
  • docker/config/alerts/snapshot-store.yml
  • apps/webapp/test/snapshotStoreAlerts.test.ts
  • apps/webapp/test/snapshotStoreMode.test.ts
  • apps/webapp/app/v3/snapshotStoreMetrics.server.ts
  • apps/webapp/app/v3/snapshotStoreBoot.server.ts
  • internal-packages/run-store/src/circuitBreaker.test.ts
  • apps/webapp/app/v3/snapshotStoreMode.server.ts
  • internal-packages/run-store/src/circuitBreaker.ts
  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.transitions.test.ts
  • apps/webapp/app/env.server.ts
  • internal-packages/run-store/src/redisSnapshotStore.ts
  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.ts
Use zod for validation in packages/core and apps/webapp

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Files:

  • apps/webapp/test/snapshotStoreAlerts.test.ts
  • apps/webapp/test/snapshotStoreMode.test.ts
  • apps/webapp/app/v3/snapshotStoreMetrics.server.ts
  • apps/webapp/app/v3/snapshotStoreBoot.server.ts
  • apps/webapp/app/v3/snapshotStoreMode.server.ts
  • apps/webapp/app/env.server.ts
Do not import `env.server.ts` directly or indirectly into test files; instead pass environment-dependent values through options/parameters to make code testable

📄 CodeRabbit inference engine (.cursor/rules/webapp.mdc)

Files:

  • apps/webapp/test/snapshotStoreAlerts.test.ts
  • apps/webapp/test/snapshotStoreMode.test.ts
Access environment variables through the `env` export of `env.server.ts` instead of directly accessing `process.env`

📄 CodeRabbit inference engine (.cursor/rules/webapp.mdc)

Files:

  • apps/webapp/test/snapshotStoreAlerts.test.ts
  • apps/webapp/test/snapshotStoreMode.test.ts
  • apps/webapp/app/v3/snapshotStoreMetrics.server.ts
  • apps/webapp/app/v3/snapshotStoreBoot.server.ts
  • apps/webapp/app/v3/snapshotStoreMode.server.ts
  • apps/webapp/app/env.server.ts
Use vitest for all tests in the Trigger.dev repository

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Files:

  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.hotPath.test.ts
  • internal-packages/run-store/src/redisSnapshotStore.gaps.test.ts
  • apps/webapp/test/snapshotStoreAlerts.test.ts
  • apps/webapp/test/snapshotStoreMode.test.ts
  • internal-packages/run-store/src/circuitBreaker.test.ts
  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.transitions.test.ts
Use function declarations instead of default exports

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Files:

  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.hotPath.test.ts
  • internal-packages/run-store/src/redisSnapshotStore.gaps.test.ts
  • apps/webapp/test/snapshotStoreAlerts.test.ts
  • apps/webapp/test/snapshotStoreMode.test.ts
  • apps/webapp/app/v3/snapshotStoreMetrics.server.ts
  • apps/webapp/app/v3/snapshotStoreBoot.server.ts
  • internal-packages/run-store/src/circuitBreaker.test.ts
  • apps/webapp/app/v3/snapshotStoreMode.server.ts
  • internal-packages/run-store/src/circuitBreaker.ts
  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.transitions.test.ts
  • apps/webapp/app/env.server.ts
  • internal-packages/run-store/src/redisSnapshotStore.ts
  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.ts
Use types over interfaces for TypeScript

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Files:

  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.hotPath.test.ts
  • internal-packages/run-store/src/redisSnapshotStore.gaps.test.ts
  • apps/webapp/test/snapshotStoreAlerts.test.ts
  • apps/webapp/test/snapshotStoreMode.test.ts
  • apps/webapp/app/v3/snapshotStoreMetrics.server.ts
  • apps/webapp/app/v3/snapshotStoreBoot.server.ts
  • internal-packages/run-store/src/circuitBreaker.test.ts
  • apps/webapp/app/v3/snapshotStoreMode.server.ts
  • internal-packages/run-store/src/circuitBreaker.ts
  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.transitions.test.ts
  • apps/webapp/app/env.server.ts
  • internal-packages/run-store/src/redisSnapshotStore.ts
  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.ts
When creating or editing OTEL metrics (counters, histograms, gauges), ensure metric attributes have low cardinality by using only enums, booleans, bounded error codes, or bounded shard IDs

📄 CodeRabbit inference engine (.cursor/rules/otel-metrics.mdc)

Files:

  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.hotPath.test.ts
  • internal-packages/run-store/src/redisSnapshotStore.gaps.test.ts
  • apps/webapp/test/snapshotStoreAlerts.test.ts
  • apps/webapp/test/snapshotStoreMode.test.ts
  • apps/webapp/app/v3/snapshotStoreMetrics.server.ts
  • apps/webapp/app/v3/snapshotStoreBoot.server.ts
  • internal-packages/run-store/src/circuitBreaker.test.ts
  • apps/webapp/app/v3/snapshotStoreMode.server.ts
  • internal-packages/run-store/src/circuitBreaker.ts
  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.transitions.test.ts
  • apps/webapp/app/env.server.ts
  • internal-packages/run-store/src/redisSnapshotStore.ts
  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.ts
🧠 Learnings (1)
📚 Learning: 2026-06-16T09:19:47.637Z
Learnt from: d-cs
Repo: triggerdotdev/trigger.dev PR: 3960
File: apps/webapp/test/prismaInfrastructureErrorCapture.test.ts:0-0
Timestamp: 2026-06-16T09:19:47.637Z
Learning: In this repo’s Vitest setup, `vitest.config.ts` uses `globals: true`, so identifiers like `vi`, `describe`, `it`, and `expect` are available as globals in Vitest test files. During code review, do not flag missing `vi`/`describe`/`it`/`expect` imports as a runtime error or correctness issue when they’re used in `*.test.ts/tsx` or `*.spec.ts/tsx` files. Explicit imports are still preferred for consistency, but they’re not required for runtime behavior.

Applied to files:

  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.hotPath.test.ts
  • internal-packages/run-store/src/redisSnapshotStore.gaps.test.ts
  • apps/webapp/test/snapshotStoreMode.test.ts
  • internal-packages/run-store/src/circuitBreaker.test.ts
🔇 Additional comments (6)
internal-packages/run-store/src/taskRunExecutionSnapshotStore.transitions.test.ts (1)

378-378: LGTM!

Also applies to: 389-389, 402-407

apps/webapp/app/v3/snapshotStoreMetrics.server.ts (1)

11-26: LGTM!

Also applies to: 53-105

apps/webapp/test/snapshotStoreAlerts.test.ts (1)

34-50: LGTM!

docker/config/alerts/snapshot-store.yml (2)

10-17: LGTM!


32-56: LGTM!

apps/webapp/app/v3/snapshotStoreBoot.server.ts (1)

38-108: LGTM!

Also applies to: 121-176, 198-227

Comment on lines +1336 to +1346
/**
* RETIRED. The hard stop is the snapshotStoreHalt feature flag and nothing else: an environment
* variable converged over a rolling deploy rather than a flag interval, and during that window a
* stopped process skips a transition while a running one asserts a head that was never written.
*
* Kept in the schema for one purpose only, so boot can REFUSE to start when it is still set to
* "1". A variable that no longer stops anything leaves an operator believing the mirror is
* stopped while it runs. Nothing else reads it, and a value of "0" carries no intent so it is
* ignored.
*/
RUN_ENGINE_SNAPSHOT_STORE_HALT: z.string().optional(),

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the contradictory active-halt documentation.

The preceding comment still says RUN_ENGINE_SNAPSHOT_STORE_HALT is an active hard stop that outranks snapshotStoreHalt. This block states that the variable is retired and only causes boot refusal when it is "1". Remove or update the preceding comment so operators do not rely on a runtime halt that no longer exists.

Comment on lines +152 to +158
primaryPending.set(organizationId, generation);
void load(organizationId, primaryClient, generation).finally(() => {
// Only if no NEWER save has claimed it since. Deleting unconditionally is what let an older
// read reopen the window for a newer one.
if (primaryPending.get(organizationId) === generation) {
primaryPending.delete(organizationId);
}

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep replica refreshes blocked after a failed primary refresh.

If the primary read rejects, load() catches the error and this finally() block clears primaryPending without populating the cache. A later refresh() can then read a lagging replica with the current generation. That result passes the generation check and can restore the pre-save mode for the cache TTL.

Keep this organization on a primary-only retry path until a primary read succeeds. Add a regression case for a failed primary read followed by a stale replica response.

Comment on lines +58 to +60
- alert: SnapshotStoreSweepNotCompleting
# The sweep is the only reaper for orphaned keyspaces. Its absence is silent by nature.
expr: sum(increase({__name__=~".*run_engine_snapshot_store_sweep_pass_total(_total)?",outcome="completed"}[24h])) == 0

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

printf '%s\n' '--- repository conventions ---'
find /tmp/coderabbit-repo-knowledge/triggerdotdev-trigger-dev-0bdd0019 -type f -name '*.md' -print
printf '%s\n' '--- alert file ---'
cat -n docker/config/alerts/snapshot-store.yml | sed -n '1,110p'
printf '%s\n' '--- related metric and snapshot-store references ---'
rg -n -S 'run_engine_snapshot_store_sweep_pass_total|snapshot.?store|SnapshotStore' --glob '!node_modules' --glob '!dist' --glob '!build' .

Repository: triggerdotdev/trigger.dev

Length of output: 50381


🏁 Script executed:

printf '%s\n' '--- applicable conventions ---'
cat /tmp/coderabbit-repo-knowledge/triggerdotdev-trigger-dev-0bdd0019/conventions/repo-wide.md
cat /tmp/coderabbit-repo-knowledge/triggerdotdev-trigger-dev-0bdd0019/conventions/rules.md
printf '%s\n' '--- sweep registration and wiring ---'
cat -n internal-packages/run-engine/src/engine/index.ts | sed -n '330,385p'
cat -n apps/webapp/app/v3/snapshotStoreWiring.server.ts | sed -n '1,75p'
printf '%s\n' '--- metric definitions ---'
cat -n apps/webapp/app/v3/snapshotStoreMetrics.server.ts | sed -n '45,95p'
printf '%s\n' '--- snapshot store construction/configuration ---'
cat -n apps/webapp/app/v3/snapshotStoreInstance.server.ts | sed -n '115,150p'
printf '%s\n' '--- alert tests ---'
cat -n apps/webapp/test/snapshotStoreAlerts.test.ts | sed -n '1,180p'
printf '%s\n' '--- collector metric pipeline ---'
cat -n docker/otel-collector-config.yaml | sed -n '1,65p'

Repository: triggerdotdev/trigger.dev

Length of output: 20196


🌐 Web query:

Prometheus documentation aggregation operators empty vector sum(increase(metric[24h])) == 0 absent series

💡 Result:

In Prometheus, aggregation operators like sum act on the set of elements provided to them [1][2]. If the input vector is empty—which often happens when a metric selector returns no time series—the aggregation operator will return an empty vector, not zero [3][4][5]. This is because Prometheus does not treat "missing data" as a numerical zero, but rather as an absence of series [5]. To handle cases where you expect a value of 0 instead of an empty result, you can use the OR operator to provide a fallback [3][4][5]. For example, if you want a sum to return 0 when no series exist, you can use: sum(increase(metric[24h])) or vector(0) [3][4] Regarding the absent function: 1. It is designed to detect the total absence of time series for a given metric and label combination [6][7]. 2. It returns a 1-element vector with the value 1 if the input vector is empty, and an empty vector if the input vector has any elements [6][7]. 3. It does not work well with aggregations inside the function (e.g., absent(sum(...))) because the aggregation itself often results in an empty vector, which can lead to complex matching issues or unexpected empty results [6][7]. If you are using this for alerting, the recommended pattern when you want to trigger an alert if a metric disappears is to use absent directly on the metric selector, rather than trying to aggregate first [6][7]. If you must ensure a result of 0 for mathematical operations in alerts or dashboards, the or vector(0) pattern is the standard approach [3][4][5].

Citations:


🏁 Script executed:

printf '%s\n' '--- sweep counter writes ---'
rg -n -C 8 'snapshotSweepPassCounter|runSweep' internal-packages/run-engine/src/engine/index.ts apps/webapp/app/v3/runEngine.server.ts apps/webapp/app/v3/snapshotSweepRunner.server.ts
printf '%s\n' '--- application snapshot-store option ---'
cat -n apps/webapp/app/v3/runEngine.server.ts | sed -n '232,258p'
printf '%s\n' '--- all snapshot-store metric instruments ---'
rg -n -C 3 'createCounter|createObservable|createGauge|snapshot_store' apps/webapp/app/v3/snapshotStoreMetrics.server.ts internal-packages/run-engine/src/engine/index.ts

Repository: triggerdotdev/trigger.dev

Length of output: 17533


Detect an absent completed-sweep series.

sum(increase(..., outcome="completed"[24h])) returns an empty vector when no completed series exists, so == 0 produces no alert. Add a stable enabled-store metric and gate a zero fallback with it. Do not use unconditional vector(0), because unconfigured deployments omit snapshotStore and must not alert. Add a fixture for an enabled store with no completed-sweep series.

Comment on lines +1047 to +1052
while misses < 8 and probe <= cycles + 512 do
if redis.call('DEL', wpKey(probe)) == 1 then
misses = 0
else
misses = misses + 1
end

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.

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Keep scanning sparse wait-cycle keys.

If seqKey is absent and only wp:10 survives, Line 1033 sets cycles to zero. Lines 1047-1052 stop after absent keys 1 through 8. Line 1056 then removes eKey, so wp:10 is no longer discoverable by the orphan sweep and can remain as leaked Redis data.

Delete sparse cycle keys throughout the bounded cleanup range. Add a regression test where only wp:10 remains before dropRun().

Comment on lines +315 to +318
(redis as unknown as Record<string, unknown>).getSnapshotWaitpointIds = async () => {
hydrationCalls += 1;
throw new Error("Command timed out");
};

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.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Use integration-backed dependencies instead of replacing production methods in these tests.

The tests currently hand-replace production Redis or database methods, so they do not exercise the real command and database boundaries. Use the repository’s Testcontainers-backed setup for the failure and lagging-replica scenarios instead.

📍 Affects 2 files
  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.hotPath.test.ts#L315-L318 (this comment)
  • apps/webapp/test/snapshotStoreMode.test.ts#L165-L168

Source: Coding guidelines

Comment on lines +992 to +1001
// Hydration is inside the boundary too. It makes a SECOND Redis call when the entry has a wait
// cycle whose ids the read did not carry, and a failure there is the same brownout the catch
// above exists for. Leaving it outside meant a waitpoint-bearing run still threw into the
// engine while a plain one fell back.
try {
return await this.#hydrate(read, runId, client, { hydrateWaitpointRows: true });
} catch (error) {
if (!this.#readMayFallBack()) throw error;
this.#reportReadUnavailable("findLatestExecutionSnapshot", runId, error);
return this.delegate.findLatestExecutionSnapshot(runId, client, environmentId);

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Record only the final read source.

Both paths record a Redis read before hydration completes. If hydration fails, they also record Postgres after returning the Postgres result. One logical read then increments both served_by series.

  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.ts#L992-L1001: record redis only after #hydrate() succeeds.
  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.ts#L1074-L1085: record redis only after all window entries hydrate successfully.
📍 Affects 1 file
  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.ts#L992-L1001 (this comment)
  • internal-packages/run-store/src/taskRunExecutionSnapshotStore.ts#L1074-L1085

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.

1 participant