Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/tall-moons-refuse.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
---
'@objectstack/service-automation': patch
---

Resume a paused flow run from the shared store, not from the replica's own memory of it

On a multi-replica deployment over one database, approving a level of a multi-level
approval flow could re-create the level that was just approved instead of opening the
next one — so the same approver had to approve each level twice, and a three-level flow
produced five approval requests. Landing the same stale read on the final level rolled
the run back to the previous one and left it parked forever instead of completing.

The engine kept paused runs in a per-process map and read that map before the durable
`sys_automation_run` row, so a replica that had handled the run earlier answered from
its own snapshot of the node the run was parked at — a snapshot nothing invalidates.
Whichever replica the next decision reached then traversed forward from a node the run
had already left. A single replica never showed it, because there is only one map and
it is never behind.

The resume path is now store-authoritative: with a `SuspendedRunStore` configured, the
store answers where a run is parked, and the in-memory map is consulted only for a run
whose durable save failed (the existing degradation, which keeps such a run resumable
in-process and reports the lost durability at `error`). The ordering of the resume
itself is unchanged — the suspension is still consumed before downstream traversal.

Two consequences worth knowing: every resume now reads the store, so an unreadable
store is reported as `STORE_UNAVAILABLE` for a run this process parked itself rather
than being served a possibly-stale snapshot; and the approvals pre-flight
(`hasSuspendedRun`) is answered from the same authoritative read, so a decision is no
longer recorded against a run that another replica has already advanced or finished.
32 changes: 19 additions & 13 deletions packages/services/service-automation/src/builtin/wait-node.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -375,13 +375,20 @@ describe('wait timer teardown when the pause ends another way (#5512)', () => {
* durable store unreadable, so per #4420 the pause is emphatically NOT gone —
* that cancelled the only thing left that would ever wake the run.
*
* Reachability is not equal across the two sites, and these tests are built to
* say so rather than to look symmetric: `resumeInternal` reads the durable store
* only on a hot-cache MISS, and a run that paused in this process stays cached
* for the life of its suspension. So the end-to-end specimen below is the
* **re-arm** callback (fresh process, empty cache, store consulted for real);
* the arming callback's branch is latent by construction and is pinned at the
* handler level, with the code injected rather than provoked.
* Reachability was not equal across the two sites when these tests were built,
* and they were shaped to say so rather than to look symmetric: `resumeInternal`
* read the durable store only on a MISS of the engine's in-memory map, and a run
* that paused in this process was answered from memory for the life of its
* suspension. So the end-to-end specimen below is the **re-arm** callback (fresh
* process, empty map, store consulted for real), while the arming callback's
* branch was latent by construction and is pinned at the handler level, with the
* code injected rather than provoked.
*
* [#13617] The asymmetry is gone — a store-backed engine now reads the store on
* every resume — but the arming-path specimen below is unchanged on purpose: it
* runs on an engine with NO store at all, where there is no store read to fail,
* so what it pins is still the handler's branch and the arming site's routing
* through it, not a reachability claim.
*/
describe('wait timer one-shot vs. a shot that never consumed the pause (#5529)', () => {
/** A logger that keeps its `error` lines so the diagnostic can be asserted. */
Expand DownExpand Up@@ -520,12 +527,11 @@ describe('wait timer one-shot vs. a shot that never consumed the pause (#5529)',

it('arming path: the same handler keeps the job armed on STORE_UNAVAILABLE', async () => {
// The arming callback shares one handler with the re-arm callback, so this
// pins the branch on THAT site too. The code is injected, not provoked: a run
// that paused in this process is in the engine's hot cache, so its own resume
// never reads the durable store and cannot produce STORE_UNAVAILABLE here.
// Fabricating a cache miss to "prove" otherwise would pin a scenario the
// engine does not have — what is verified is the handler's branch, and that
// the arming site routes through it rather than keeping its own `finally`.
// pins the branch on THAT site too. The code is injected, not provoked, and
// [#13617] did not change that: this engine is built with NO store, so no
// resume of it can produce STORE_UNAVAILABLE however it reads. What is
// verified is the handler's branch, and that the arming site routes through
// it rather than keeping its own `finally`.
const { ctx, scheduled, cancelled } = fakeJobCtx();
const engine = new AutomationEngine(silentLogger());
const ran: string[] = [];
Expand Down
17 changes: 10 additions & 7 deletions packages/services/service-automation/src/builtin/wait-node.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,13 +82,16 @@ interface WaitTimerLogger {
* found" and the only remaining path is the next boot's overdue re-arm pass.
* Both remedies are named in the log line for that reason.
*
* Reachability differs by site, and the honest note is that they are not equal.
* `resumeInternal` reads the durable store only on a hot-cache miss, and a run
* that paused in *this* process is cached for as long as the suspension lives —
* so the **re-arm** callback (a fresh process, empty cache) is where
* `STORE_UNAVAILABLE` is genuinely reachable today, while the arming callback's
* branch is latent by construction. It is shared anyway rather than special-cased:
* a second spelling of "settle the one-shot" is exactly the drift #5512 collapsed.
* Reachability was once unequal by site, and this note used to say so: while
* `resumeInternal` read the durable store only on a miss of the engine's
* in-memory map, a run that paused in *this* process was answered from memory
* for the life of its suspension, so only the **re-arm** callback (a fresh
* process, empty map) could genuinely produce `STORE_UNAVAILABLE`. [#13617]
* ended that: the resume path is store-authoritative whenever a
* `SuspendedRunStore` is configured, so BOTH callbacks read the store and both
* reach this branch — the arming site is no longer latent by construction. The
* handler was shared before that was true and stays shared: a second spelling
* of "settle the one-shot" is exactly the drift #5512 collapsed.
*/
function makeWaitTimerJobHandler(
engine: Pick<AutomationEngine, 'resume'>,
Expand Down
90 changes: 82 additions & 8 deletions packages/services/service-automation/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1622,11 +1622,31 @@ export class AutomationEngine implements IAutomationService {
private readonly runSummaryLog: RunSummaryLogLevel;
private logger: Logger;
/**
* Runs paused at a node, keyed by runId (ADR-0019). In-memory hot cache —
* mirrored to {@link store} when one is configured, so a pause survives a
* process restart. See {@link SuspendedRun}.
* Runs paused at a node, keyed by runId (ADR-0019). Process-local copy of
* the pause — mirrored to {@link store} when one is configured, so a pause
* survives a process restart. See {@link SuspendedRun}.
*
* [#13617] NOT a read-through cache sitting in front of the store. When a
* store is configured the STORE is the authority and this map answers only
* for the runs it never accepted ({@link cacheOnlySuspensions}). Reading
* this map first is what made a multi-replica approval flow re-create every
* level — the mechanism is in {@link loadSuspendedRunStrict}.
*/
private suspendedRuns = new Map<string, SuspendedRun>();
/**
* [#13617] Runs whose durable save FAILED, so {@link store} holds no row
* for them and its "no such run" says nothing about them. These are the
* only runs {@link loadSuspendedRunStrict} will answer out of
* {@link suspendedRuns} while a store is configured — which is what keeps
* {@link persistSuspendedRun}'s documented degradation (a save failure
* costs cross-restart durability, not in-process resumability) working.
*
* Written by {@link persistSuspendedRun} — added when a save throws,
* cleared when one lands — and dropped alongside the cache entry by
* {@link forgetSuspendedRun}, the single choke point every consumption
* passes through, so it is bounded by the map it qualifies.
*/
private cacheOnlySuspensions = new Set<string>();
/**
* Optional durable backing for {@link suspendedRuns}. When set, suspended
* runs are persisted on suspend and rehydrated on resume after a restart;
Expand DownExpand Up@@ -1788,7 +1808,17 @@ export class AutomationEngine implements IAutomationService {
if (this.store) {
try {
await this.store.save(run);
// [#13617] The store now holds this pause, so it — not this map
// — is the answer for it. Cleared here and not only on the
// failure path: a re-suspend whose save lands after an earlier
// one failed must stop being read out of memory.
this.cacheOnlySuspensions.delete(run.runId);
} catch (err) {
// [#13617] The store was never given the row, so its "no such
// run" is silence about this run rather than an answer. This is
// what lets `loadSuspendedRunStrict` keep serving it from the
// map — the in-process resumability the message below promises.
this.cacheOnlySuspensions.add(run.runId);
// #6499 — the cause is the datasource DRIVER's own text, so it
// goes to the logger's STRUCTURED slot, never spliced into the
// message; see `forgetSuspendedRun`'s catch below for the full
Expand DownExpand Up@@ -1831,6 +1861,10 @@ export class AutomationEngine implements IAutomationService {
*/
private async forgetSuspendedRun(run: SuspendedRun, reason: SuspensionReleaseReason): Promise<void> {
this.suspendedRuns.delete(run.runId);
// [#13617] The qualifier goes with the entry it qualifies — this is the
// one choke point every consumption passes through, so nothing can leave
// a run marked "the store never took this" after its map entry is gone.
this.cacheOnlySuspensions.delete(run.runId);
if (this.store) {
try {
await this.store.delete(run.runId);
Expand DownExpand Up@@ -4521,12 +4555,52 @@ export class AutomationEngine implements IAutomationService {
}

/** {@link loadSuspendedRun} without the degradation: a store read failure
* THROWS instead of reading as "no such run". */
* THROWS instead of reading as "no such run".
*
* [#13617] STORE-AUTHORITATIVE. When a {@link SuspendedRunStore} is
* configured, the store answers and {@link suspendedRuns} answers only for
* a run the store never accepted ({@link cacheOnlySuspensions}). It used
* to be the other way round — this process's map first, the store only on
* a miss — which is a correct read for exactly one deployment shape: a
* single process. Put several replicas behind a load balancer over one
* database and that map is a per-replica snapshot of the node a run was
* parked at THE LAST TIME THIS REPLICA TOUCHED IT, and nothing invalidates
* it, because there is no invalidation channel to it at all.
*
* The measured shape, a multi-level approval flow: replica A parks the run
* at `lv1` and keeps it in its map. The `lv1` decision round-robins to
* replica B, which advances the run to `lv2` in the store and in B's map;
* A's map still says `lv1`. The `lv2` decision lands back on A, which read
* its own map, resumed from `lv1`, and traversed to `lv2` a SECOND time —
* the same level re-created as a fresh pending request tens of
* milliseconds after the first one completed, so one approver approves
* every level twice. Land the same one-beat-stale read on the FINAL level
* and the run rolls back to the previous one instead of terminating. A
* single replica shows zero duplicates because there is one map and it is
* never behind.
*
* Both callers that must not be wrong funnel through here: `resumeInternal`
* (which node does this resume continue from) and {@link hasSuspendedRun}
* (the approvals pre-flight that decides whether to record a decision at
* all), so one seam settles both.
*
* ⛔ NOT a re-ordering of the resume path. The suspension is still consumed
* before `traverseNext` and {@link forgetSuspendedRun} is untouched —
* which ordering is right is #13937's question, and unruled. This changes
* only WHICH suspension is read, never when it is consumed. */
private async loadSuspendedRunStrict(runId: string): Promise<SuspendedRun | null> {
const cached = this.suspendedRuns.get(runId);
if (cached) return cached;
if (!this.store) return null;
return await this.store.load(runId);
if (!this.store) return this.suspendedRuns.get(runId) ?? null;
const stored = await this.store.load(runId);
if (stored) return stored;
// The store has no row. For every run it ever accepted that IS the
// answer — including the runs this process advanced past, whose stale
// map entries are the whole defect above. The lone exception is a run
// whose durable save failed here: the store was never handed that row,
// so its silence says nothing about it, and `persistSuspendedRun`
// deliberately keeps such a run resumable in-process (it reports the
// lost durability at `error`).
if (this.cacheOnlySuspensions.has(runId)) return this.suspendedRuns.get(runId) ?? null;
return null;
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/tall-moons-refuse.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
---
'@objectstack/service-automation': patch
---

Resume a paused flow run from the shared store, not from the replica's own memory of it

On a multi-replica deployment over one database, approving a level of a multi-level
approval flow could re-create the level that was just approved instead of opening the
next one — so the same approver had to approve each level twice, and a three-level flow
produced five approval requests. Landing the same stale read on the final level rolled
the run back to the previous one and left it parked forever instead of completing.

The engine kept paused runs in a per-process map and read that map before the durable
`sys_automation_run` row, so a replica that had handled the run earlier answered from
its own snapshot of the node the run was parked at — a snapshot nothing invalidates.
Whichever replica the next decision reached then traversed forward from a node the run
had already left. A single replica never showed it, because there is only one map and
it is never behind.

The resume path is now store-authoritative: with a `SuspendedRunStore` configured, the
store answers where a run is parked, and the in-memory map is consulted only for a run
whose durable save failed (the existing degradation, which keeps such a run resumable
in-process and reports the lost durability at `error`). The ordering of the resume
itself is unchanged — the suspension is still consumed before downstream traversal.

Two consequences worth knowing: every resume now reads the store, so an unreadable
store is reported as `STORE_UNAVAILABLE` for a run this process parked itself rather
than being served a possibly-stale snapshot; and the approvals pre-flight
(`hasSuspendedRun`) is answered from the same authoritative read, so a decision is no
longer recorded against a run that another replica has already advanced or finished.
32 changes: 19 additions & 13 deletions packages/services/service-automation/src/builtin/wait-node.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -375,13 +375,20 @@ describe('wait timer teardown when the pause ends another way (#5512)', () => {
* durable store unreadable, so per #4420 the pause is emphatically NOT gone —
* that cancelled the only thing left that would ever wake the run.
*
* Reachability is not equal across the two sites, and these tests are built to
* say so rather than to look symmetric: `resumeInternal` reads the durable store
* only on a hot-cache MISS, and a run that paused in this process stays cached
* for the life of its suspension. So the end-to-end specimen below is the
* **re-arm** callback (fresh process, empty cache, store consulted for real);
* the arming callback's branch is latent by construction and is pinned at the
* handler level, with the code injected rather than provoked.
* Reachability was not equal across the two sites when these tests were built,
* and they were shaped to say so rather than to look symmetric: `resumeInternal`
* read the durable store only on a MISS of the engine's in-memory map, and a run
* that paused in this process was answered from memory for the life of its
* suspension. So the end-to-end specimen below is the **re-arm** callback (fresh
* process, empty map, store consulted for real), while the arming callback's
* branch was latent by construction and is pinned at the handler level, with the
* code injected rather than provoked.
*
* [#13617] The asymmetry is gone — a store-backed engine now reads the store on
* every resume — but the arming-path specimen below is unchanged on purpose: it
* runs on an engine with NO store at all, where there is no store read to fail,
* so what it pins is still the handler's branch and the arming site's routing
* through it, not a reachability claim.
*/
describe('wait timer one-shot vs. a shot that never consumed the pause (#5529)', () => {
/** A logger that keeps its `error` lines so the diagnostic can be asserted. */
Expand DownExpand Up@@ -520,12 +527,11 @@ describe('wait timer one-shot vs. a shot that never consumed the pause (#5529)',

it('arming path: the same handler keeps the job armed on STORE_UNAVAILABLE', async () => {
// The arming callback shares one handler with the re-arm callback, so this
// pins the branch on THAT site too. The code is injected, not provoked: a run
// that paused in this process is in the engine's hot cache, so its own resume
// never reads the durable store and cannot produce STORE_UNAVAILABLE here.
// Fabricating a cache miss to "prove" otherwise would pin a scenario the
// engine does not have — what is verified is the handler's branch, and that
// the arming site routes through it rather than keeping its own `finally`.
// pins the branch on THAT site too. The code is injected, not provoked, and
// [#13617] did not change that: this engine is built with NO store, so no
// resume of it can produce STORE_UNAVAILABLE however it reads. What is
// verified is the handler's branch, and that the arming site routes through
// it rather than keeping its own `finally`.
const { ctx, scheduled, cancelled } = fakeJobCtx();
const engine = new AutomationEngine(silentLogger());
const ran: string[] = [];
Expand Down
17 changes: 10 additions & 7 deletions packages/services/service-automation/src/builtin/wait-node.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,13 +82,16 @@ interface WaitTimerLogger {
* found" and the only remaining path is the next boot's overdue re-arm pass.
* Both remedies are named in the log line for that reason.
*
* Reachability differs by site, and the honest note is that they are not equal.
* `resumeInternal` reads the durable store only on a hot-cache miss, and a run
* that paused in *this* process is cached for as long as the suspension lives —
* so the **re-arm** callback (a fresh process, empty cache) is where
* `STORE_UNAVAILABLE` is genuinely reachable today, while the arming callback's
* branch is latent by construction. It is shared anyway rather than special-cased:
* a second spelling of "settle the one-shot" is exactly the drift #5512 collapsed.
* Reachability was once unequal by site, and this note used to say so: while
* `resumeInternal` read the durable store only on a miss of the engine's
* in-memory map, a run that paused in *this* process was answered from memory
* for the life of its suspension, so only the **re-arm** callback (a fresh
* process, empty map) could genuinely produce `STORE_UNAVAILABLE`. [#13617]
* ended that: the resume path is store-authoritative whenever a
* `SuspendedRunStore` is configured, so BOTH callbacks read the store and both
* reach this branch — the arming site is no longer latent by construction. The
* handler was shared before that was true and stays shared: a second spelling
* of "settle the one-shot" is exactly the drift #5512 collapsed.
*/
function makeWaitTimerJobHandler(
engine: Pick<AutomationEngine, 'resume'>,
Expand Down
90 changes: 82 additions & 8 deletions packages/services/service-automation/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1622,11 +1622,31 @@ export class AutomationEngine implements IAutomationService {
private readonly runSummaryLog: RunSummaryLogLevel;
private logger: Logger;
/**
* Runs paused at a node, keyed by runId (ADR-0019). In-memory hot cache —
* mirrored to {@link store} when one is configured, so a pause survives a
* process restart. See {@link SuspendedRun}.
* Runs paused at a node, keyed by runId (ADR-0019). Process-local copy of
* the pause — mirrored to {@link store} when one is configured, so a pause
* survives a process restart. See {@link SuspendedRun}.
*
* [#13617] NOT a read-through cache sitting in front of the store. When a
* store is configured the STORE is the authority and this map answers only
* for the runs it never accepted ({@link cacheOnlySuspensions}). Reading
* this map first is what made a multi-replica approval flow re-create every
* level — the mechanism is in {@link loadSuspendedRunStrict}.
*/
private suspendedRuns = new Map<string, SuspendedRun>();
/**
* [#13617] Runs whose durable save FAILED, so {@link store} holds no row
* for them and its "no such run" says nothing about them. These are the
* only runs {@link loadSuspendedRunStrict} will answer out of
* {@link suspendedRuns} while a store is configured — which is what keeps
* {@link persistSuspendedRun}'s documented degradation (a save failure
* costs cross-restart durability, not in-process resumability) working.
*
* Written by {@link persistSuspendedRun} — added when a save throws,
* cleared when one lands — and dropped alongside the cache entry by
* {@link forgetSuspendedRun}, the single choke point every consumption
* passes through, so it is bounded by the map it qualifies.
*/
private cacheOnlySuspensions = new Set<string>();
/**
* Optional durable backing for {@link suspendedRuns}. When set, suspended
* runs are persisted on suspend and rehydrated on resume after a restart;
Expand DownExpand Up@@ -1788,7 +1808,17 @@ export class AutomationEngine implements IAutomationService {
if (this.store) {
try {
await this.store.save(run);
// [#13617] The store now holds this pause, so it — not this map
// — is the answer for it. Cleared here and not only on the
// failure path: a re-suspend whose save lands after an earlier
// one failed must stop being read out of memory.
this.cacheOnlySuspensions.delete(run.runId);
} catch (err) {
// [#13617] The store was never given the row, so its "no such
// run" is silence about this run rather than an answer. This is
// what lets `loadSuspendedRunStrict` keep serving it from the
// map — the in-process resumability the message below promises.
this.cacheOnlySuspensions.add(run.runId);
// #6499 — the cause is the datasource DRIVER's own text, so it
// goes to the logger's STRUCTURED slot, never spliced into the
// message; see `forgetSuspendedRun`'s catch below for the full
Expand DownExpand Up@@ -1831,6 +1861,10 @@ export class AutomationEngine implements IAutomationService {
*/
private async forgetSuspendedRun(run: SuspendedRun, reason: SuspensionReleaseReason): Promise<void> {
this.suspendedRuns.delete(run.runId);
// [#13617] The qualifier goes with the entry it qualifies — this is the
// one choke point every consumption passes through, so nothing can leave
// a run marked "the store never took this" after its map entry is gone.
this.cacheOnlySuspensions.delete(run.runId);
if (this.store) {
try {
await this.store.delete(run.runId);
Expand DownExpand Up@@ -4521,12 +4555,52 @@ export class AutomationEngine implements IAutomationService {
}

/** {@link loadSuspendedRun} without the degradation: a store read failure
* THROWS instead of reading as "no such run". */
* THROWS instead of reading as "no such run".
*
* [#13617] STORE-AUTHORITATIVE. When a {@link SuspendedRunStore} is
* configured, the store answers and {@link suspendedRuns} answers only for
* a run the store never accepted ({@link cacheOnlySuspensions}). It used
* to be the other way round — this process's map first, the store only on
* a miss — which is a correct read for exactly one deployment shape: a
* single process. Put several replicas behind a load balancer over one
* database and that map is a per-replica snapshot of the node a run was
* parked at THE LAST TIME THIS REPLICA TOUCHED IT, and nothing invalidates
* it, because there is no invalidation channel to it at all.
*
* The measured shape, a multi-level approval flow: replica A parks the run
* at `lv1` and keeps it in its map. The `lv1` decision round-robins to
* replica B, which advances the run to `lv2` in the store and in B's map;
* A's map still says `lv1`. The `lv2` decision lands back on A, which read
* its own map, resumed from `lv1`, and traversed to `lv2` a SECOND time —
* the same level re-created as a fresh pending request tens of
* milliseconds after the first one completed, so one approver approves
* every level twice. Land the same one-beat-stale read on the FINAL level
* and the run rolls back to the previous one instead of terminating. A
* single replica shows zero duplicates because there is one map and it is
* never behind.
*
* Both callers that must not be wrong funnel through here: `resumeInternal`
* (which node does this resume continue from) and {@link hasSuspendedRun}
* (the approvals pre-flight that decides whether to record a decision at
* all), so one seam settles both.
*
* ⛔ NOT a re-ordering of the resume path. The suspension is still consumed
* before `traverseNext` and {@link forgetSuspendedRun} is untouched —
* which ordering is right is #13937's question, and unruled. This changes
* only WHICH suspension is read, never when it is consumed. */
private async loadSuspendedRunStrict(runId: string): Promise<SuspendedRun | null> {
const cached = this.suspendedRuns.get(runId);
if (cached) return cached;
if (!this.store) return null;
return await this.store.load(runId);
if (!this.store) return this.suspendedRuns.get(runId) ?? null;
const stored = await this.store.load(runId);
if (stored) return stored;
// The store has no row. For every run it ever accepted that IS the
// answer — including the runs this process advanced past, whose stale
// map entries are the whole defect above. The lone exception is a run
// whose durable save failed here: the store was never handed that row,
// so its silence says nothing about it, and `persistSuspendedRun`
// deliberately keeps such a run resumable in-process (it reports the
// lost durability at `error`).
if (this.cacheOnlySuspensions.has(runId)) return this.suspendedRuns.get(runId) ?? null;
return null;
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/tall-moons-refuse.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
---
'@objectstack/service-automation': patch
---

Resume a paused flow run from the shared store, not from the replica's own memory of it

On a multi-replica deployment over one database, approving a level of a multi-level
approval flow could re-create the level that was just approved instead of opening the
next one — so the same approver had to approve each level twice, and a three-level flow
produced five approval requests. Landing the same stale read on the final level rolled
the run back to the previous one and left it parked forever instead of completing.

The engine kept paused runs in a per-process map and read that map before the durable
`sys_automation_run` row, so a replica that had handled the run earlier answered from
its own snapshot of the node the run was parked at — a snapshot nothing invalidates.
Whichever replica the next decision reached then traversed forward from a node the run
had already left. A single replica never showed it, because there is only one map and
it is never behind.

The resume path is now store-authoritative: with a `SuspendedRunStore` configured, the
store answers where a run is parked, and the in-memory map is consulted only for a run
whose durable save failed (the existing degradation, which keeps such a run resumable
in-process and reports the lost durability at `error`). The ordering of the resume
itself is unchanged — the suspension is still consumed before downstream traversal.

Two consequences worth knowing: every resume now reads the store, so an unreadable
store is reported as `STORE_UNAVAILABLE` for a run this process parked itself rather
than being served a possibly-stale snapshot; and the approvals pre-flight
(`hasSuspendedRun`) is answered from the same authoritative read, so a decision is no
longer recorded against a run that another replica has already advanced or finished.
32 changes: 19 additions & 13 deletions packages/services/service-automation/src/builtin/wait-node.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -375,13 +375,20 @@ describe('wait timer teardown when the pause ends another way (#5512)', () => {
* durable store unreadable, so per #4420 the pause is emphatically NOT gone —
* that cancelled the only thing left that would ever wake the run.
*
* Reachability is not equal across the two sites, and these tests are built to
* say so rather than to look symmetric: `resumeInternal` reads the durable store
* only on a hot-cache MISS, and a run that paused in this process stays cached
* for the life of its suspension. So the end-to-end specimen below is the
* **re-arm** callback (fresh process, empty cache, store consulted for real);
* the arming callback's branch is latent by construction and is pinned at the
* handler level, with the code injected rather than provoked.
* Reachability was not equal across the two sites when these tests were built,
* and they were shaped to say so rather than to look symmetric: `resumeInternal`
* read the durable store only on a MISS of the engine's in-memory map, and a run
* that paused in this process was answered from memory for the life of its
* suspension. So the end-to-end specimen below is the **re-arm** callback (fresh
* process, empty map, store consulted for real), while the arming callback's
* branch was latent by construction and is pinned at the handler level, with the
* code injected rather than provoked.
*
* [#13617] The asymmetry is gone — a store-backed engine now reads the store on
* every resume — but the arming-path specimen below is unchanged on purpose: it
* runs on an engine with NO store at all, where there is no store read to fail,
* so what it pins is still the handler's branch and the arming site's routing
* through it, not a reachability claim.
*/
describe('wait timer one-shot vs. a shot that never consumed the pause (#5529)', () => {
/** A logger that keeps its `error` lines so the diagnostic can be asserted. */
Expand DownExpand Up@@ -520,12 +527,11 @@ describe('wait timer one-shot vs. a shot that never consumed the pause (#5529)',

it('arming path: the same handler keeps the job armed on STORE_UNAVAILABLE', async () => {
// The arming callback shares one handler with the re-arm callback, so this
// pins the branch on THAT site too. The code is injected, not provoked: a run
// that paused in this process is in the engine's hot cache, so its own resume
// never reads the durable store and cannot produce STORE_UNAVAILABLE here.
// Fabricating a cache miss to "prove" otherwise would pin a scenario the
// engine does not have — what is verified is the handler's branch, and that
// the arming site routes through it rather than keeping its own `finally`.
// pins the branch on THAT site too. The code is injected, not provoked, and
// [#13617] did not change that: this engine is built with NO store, so no
// resume of it can produce STORE_UNAVAILABLE however it reads. What is
// verified is the handler's branch, and that the arming site routes through
// it rather than keeping its own `finally`.
const { ctx, scheduled, cancelled } = fakeJobCtx();
const engine = new AutomationEngine(silentLogger());
const ran: string[] = [];
Expand Down
17 changes: 10 additions & 7 deletions packages/services/service-automation/src/builtin/wait-node.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,13 +82,16 @@ interface WaitTimerLogger {
* found" and the only remaining path is the next boot's overdue re-arm pass.
* Both remedies are named in the log line for that reason.
*
* Reachability differs by site, and the honest note is that they are not equal.
* `resumeInternal` reads the durable store only on a hot-cache miss, and a run
* that paused in *this* process is cached for as long as the suspension lives —
* so the **re-arm** callback (a fresh process, empty cache) is where
* `STORE_UNAVAILABLE` is genuinely reachable today, while the arming callback's
* branch is latent by construction. It is shared anyway rather than special-cased:
* a second spelling of "settle the one-shot" is exactly the drift #5512 collapsed.
* Reachability was once unequal by site, and this note used to say so: while
* `resumeInternal` read the durable store only on a miss of the engine's
* in-memory map, a run that paused in *this* process was answered from memory
* for the life of its suspension, so only the **re-arm** callback (a fresh
* process, empty map) could genuinely produce `STORE_UNAVAILABLE`. [#13617]
* ended that: the resume path is store-authoritative whenever a
* `SuspendedRunStore` is configured, so BOTH callbacks read the store and both
* reach this branch — the arming site is no longer latent by construction. The
* handler was shared before that was true and stays shared: a second spelling
* of "settle the one-shot" is exactly the drift #5512 collapsed.
*/
function makeWaitTimerJobHandler(
engine: Pick<AutomationEngine, 'resume'>,
Expand Down
90 changes: 82 additions & 8 deletions packages/services/service-automation/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1622,11 +1622,31 @@ export class AutomationEngine implements IAutomationService {
private readonly runSummaryLog: RunSummaryLogLevel;
private logger: Logger;
/**
* Runs paused at a node, keyed by runId (ADR-0019). In-memory hot cache —
* mirrored to {@link store} when one is configured, so a pause survives a
* process restart. See {@link SuspendedRun}.
* Runs paused at a node, keyed by runId (ADR-0019). Process-local copy of
* the pause — mirrored to {@link store} when one is configured, so a pause
* survives a process restart. See {@link SuspendedRun}.
*
* [#13617] NOT a read-through cache sitting in front of the store. When a
* store is configured the STORE is the authority and this map answers only
* for the runs it never accepted ({@link cacheOnlySuspensions}). Reading
* this map first is what made a multi-replica approval flow re-create every
* level — the mechanism is in {@link loadSuspendedRunStrict}.
*/
private suspendedRuns = new Map<string, SuspendedRun>();
/**
* [#13617] Runs whose durable save FAILED, so {@link store} holds no row
* for them and its "no such run" says nothing about them. These are the
* only runs {@link loadSuspendedRunStrict} will answer out of
* {@link suspendedRuns} while a store is configured — which is what keeps
* {@link persistSuspendedRun}'s documented degradation (a save failure
* costs cross-restart durability, not in-process resumability) working.
*
* Written by {@link persistSuspendedRun} — added when a save throws,
* cleared when one lands — and dropped alongside the cache entry by
* {@link forgetSuspendedRun}, the single choke point every consumption
* passes through, so it is bounded by the map it qualifies.
*/
private cacheOnlySuspensions = new Set<string>();
/**
* Optional durable backing for {@link suspendedRuns}. When set, suspended
* runs are persisted on suspend and rehydrated on resume after a restart;
Expand DownExpand Up@@ -1788,7 +1808,17 @@ export class AutomationEngine implements IAutomationService {
if (this.store) {
try {
await this.store.save(run);
// [#13617] The store now holds this pause, so it — not this map
// — is the answer for it. Cleared here and not only on the
// failure path: a re-suspend whose save lands after an earlier
// one failed must stop being read out of memory.
this.cacheOnlySuspensions.delete(run.runId);
} catch (err) {
// [#13617] The store was never given the row, so its "no such
// run" is silence about this run rather than an answer. This is
// what lets `loadSuspendedRunStrict` keep serving it from the
// map — the in-process resumability the message below promises.
this.cacheOnlySuspensions.add(run.runId);
// #6499 — the cause is the datasource DRIVER's own text, so it
// goes to the logger's STRUCTURED slot, never spliced into the
// message; see `forgetSuspendedRun`'s catch below for the full
Expand DownExpand Up@@ -1831,6 +1861,10 @@ export class AutomationEngine implements IAutomationService {
*/
private async forgetSuspendedRun(run: SuspendedRun, reason: SuspensionReleaseReason): Promise<void> {
this.suspendedRuns.delete(run.runId);
// [#13617] The qualifier goes with the entry it qualifies — this is the
// one choke point every consumption passes through, so nothing can leave
// a run marked "the store never took this" after its map entry is gone.
this.cacheOnlySuspensions.delete(run.runId);
if (this.store) {
try {
await this.store.delete(run.runId);
Expand DownExpand Up@@ -4521,12 +4555,52 @@ export class AutomationEngine implements IAutomationService {
}

/** {@link loadSuspendedRun} without the degradation: a store read failure
* THROWS instead of reading as "no such run". */
* THROWS instead of reading as "no such run".
*
* [#13617] STORE-AUTHORITATIVE. When a {@link SuspendedRunStore} is
* configured, the store answers and {@link suspendedRuns} answers only for
* a run the store never accepted ({@link cacheOnlySuspensions}). It used
* to be the other way round — this process's map first, the store only on
* a miss — which is a correct read for exactly one deployment shape: a
* single process. Put several replicas behind a load balancer over one
* database and that map is a per-replica snapshot of the node a run was
* parked at THE LAST TIME THIS REPLICA TOUCHED IT, and nothing invalidates
* it, because there is no invalidation channel to it at all.
*
* The measured shape, a multi-level approval flow: replica A parks the run
* at `lv1` and keeps it in its map. The `lv1` decision round-robins to
* replica B, which advances the run to `lv2` in the store and in B's map;
* A's map still says `lv1`. The `lv2` decision lands back on A, which read
* its own map, resumed from `lv1`, and traversed to `lv2` a SECOND time —
* the same level re-created as a fresh pending request tens of
* milliseconds after the first one completed, so one approver approves
* every level twice. Land the same one-beat-stale read on the FINAL level
* and the run rolls back to the previous one instead of terminating. A
* single replica shows zero duplicates because there is one map and it is
* never behind.
*
* Both callers that must not be wrong funnel through here: `resumeInternal`
* (which node does this resume continue from) and {@link hasSuspendedRun}
* (the approvals pre-flight that decides whether to record a decision at
* all), so one seam settles both.
*
* ⛔ NOT a re-ordering of the resume path. The suspension is still consumed
* before `traverseNext` and {@link forgetSuspendedRun} is untouched —
* which ordering is right is #13937's question, and unruled. This changes
* only WHICH suspension is read, never when it is consumed. */
private async loadSuspendedRunStrict(runId: string): Promise<SuspendedRun | null> {
const cached = this.suspendedRuns.get(runId);
if (cached) return cached;
if (!this.store) return null;
return await this.store.load(runId);
if (!this.store) return this.suspendedRuns.get(runId) ?? null;
const stored = await this.store.load(runId);
if (stored) return stored;
// The store has no row. For every run it ever accepted that IS the
// answer — including the runs this process advanced past, whose stale
// map entries are the whole defect above. The lone exception is a run
// whose durable save failed here: the store was never handed that row,
// so its silence says nothing about it, and `persistSuspendedRun`
// deliberately keeps such a run resumable in-process (it reports the
// lost durability at `error`).
if (this.cacheOnlySuspensions.has(runId)) return this.suspendedRuns.get(runId) ?? null;
return null;
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/tall-moons-refuse.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
---
'@objectstack/service-automation': patch
---

Resume a paused flow run from the shared store, not from the replica's own memory of it

On a multi-replica deployment over one database, approving a level of a multi-level
approval flow could re-create the level that was just approved instead of opening the
next one — so the same approver had to approve each level twice, and a three-level flow
produced five approval requests. Landing the same stale read on the final level rolled
the run back to the previous one and left it parked forever instead of completing.

The engine kept paused runs in a per-process map and read that map before the durable
`sys_automation_run` row, so a replica that had handled the run earlier answered from
its own snapshot of the node the run was parked at — a snapshot nothing invalidates.
Whichever replica the next decision reached then traversed forward from a node the run
had already left. A single replica never showed it, because there is only one map and
it is never behind.

The resume path is now store-authoritative: with a `SuspendedRunStore` configured, the
store answers where a run is parked, and the in-memory map is consulted only for a run
whose durable save failed (the existing degradation, which keeps such a run resumable
in-process and reports the lost durability at `error`). The ordering of the resume
itself is unchanged — the suspension is still consumed before downstream traversal.

Two consequences worth knowing: every resume now reads the store, so an unreadable
store is reported as `STORE_UNAVAILABLE` for a run this process parked itself rather
than being served a possibly-stale snapshot; and the approvals pre-flight
(`hasSuspendedRun`) is answered from the same authoritative read, so a decision is no
longer recorded against a run that another replica has already advanced or finished.
32 changes: 19 additions & 13 deletions packages/services/service-automation/src/builtin/wait-node.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -375,13 +375,20 @@ describe('wait timer teardown when the pause ends another way (#5512)', () => {
* durable store unreadable, so per #4420 the pause is emphatically NOT gone —
* that cancelled the only thing left that would ever wake the run.
*
* Reachability is not equal across the two sites, and these tests are built to
* say so rather than to look symmetric: `resumeInternal` reads the durable store
* only on a hot-cache MISS, and a run that paused in this process stays cached
* for the life of its suspension. So the end-to-end specimen below is the
* **re-arm** callback (fresh process, empty cache, store consulted for real);
* the arming callback's branch is latent by construction and is pinned at the
* handler level, with the code injected rather than provoked.
* Reachability was not equal across the two sites when these tests were built,
* and they were shaped to say so rather than to look symmetric: `resumeInternal`
* read the durable store only on a MISS of the engine's in-memory map, and a run
* that paused in this process was answered from memory for the life of its
* suspension. So the end-to-end specimen below is the **re-arm** callback (fresh
* process, empty map, store consulted for real), while the arming callback's
* branch was latent by construction and is pinned at the handler level, with the
* code injected rather than provoked.
*
* [#13617] The asymmetry is gone — a store-backed engine now reads the store on
* every resume — but the arming-path specimen below is unchanged on purpose: it
* runs on an engine with NO store at all, where there is no store read to fail,
* so what it pins is still the handler's branch and the arming site's routing
* through it, not a reachability claim.
*/
describe('wait timer one-shot vs. a shot that never consumed the pause (#5529)', () => {
/** A logger that keeps its `error` lines so the diagnostic can be asserted. */
Expand DownExpand Up@@ -520,12 +527,11 @@ describe('wait timer one-shot vs. a shot that never consumed the pause (#5529)',

it('arming path: the same handler keeps the job armed on STORE_UNAVAILABLE', async () => {
// The arming callback shares one handler with the re-arm callback, so this
// pins the branch on THAT site too. The code is injected, not provoked: a run
// that paused in this process is in the engine's hot cache, so its own resume
// never reads the durable store and cannot produce STORE_UNAVAILABLE here.
// Fabricating a cache miss to "prove" otherwise would pin a scenario the
// engine does not have — what is verified is the handler's branch, and that
// the arming site routes through it rather than keeping its own `finally`.
// pins the branch on THAT site too. The code is injected, not provoked, and
// [#13617] did not change that: this engine is built with NO store, so no
// resume of it can produce STORE_UNAVAILABLE however it reads. What is
// verified is the handler's branch, and that the arming site routes through
// it rather than keeping its own `finally`.
const { ctx, scheduled, cancelled } = fakeJobCtx();
const engine = new AutomationEngine(silentLogger());
const ran: string[] = [];
Expand Down
17 changes: 10 additions & 7 deletions packages/services/service-automation/src/builtin/wait-node.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,13 +82,16 @@ interface WaitTimerLogger {
* found" and the only remaining path is the next boot's overdue re-arm pass.
* Both remedies are named in the log line for that reason.
*
* Reachability differs by site, and the honest note is that they are not equal.
* `resumeInternal` reads the durable store only on a hot-cache miss, and a run
* that paused in *this* process is cached for as long as the suspension lives —
* so the **re-arm** callback (a fresh process, empty cache) is where
* `STORE_UNAVAILABLE` is genuinely reachable today, while the arming callback's
* branch is latent by construction. It is shared anyway rather than special-cased:
* a second spelling of "settle the one-shot" is exactly the drift #5512 collapsed.
* Reachability was once unequal by site, and this note used to say so: while
* `resumeInternal` read the durable store only on a miss of the engine's
* in-memory map, a run that paused in *this* process was answered from memory
* for the life of its suspension, so only the **re-arm** callback (a fresh
* process, empty map) could genuinely produce `STORE_UNAVAILABLE`. [#13617]
* ended that: the resume path is store-authoritative whenever a
* `SuspendedRunStore` is configured, so BOTH callbacks read the store and both
* reach this branch — the arming site is no longer latent by construction. The
* handler was shared before that was true and stays shared: a second spelling
* of "settle the one-shot" is exactly the drift #5512 collapsed.
*/
function makeWaitTimerJobHandler(
engine: Pick<AutomationEngine, 'resume'>,
Expand Down
90 changes: 82 additions & 8 deletions packages/services/service-automation/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1622,11 +1622,31 @@ export class AutomationEngine implements IAutomationService {
private readonly runSummaryLog: RunSummaryLogLevel;
private logger: Logger;
/**
* Runs paused at a node, keyed by runId (ADR-0019). In-memory hot cache —
* mirrored to {@link store} when one is configured, so a pause survives a
* process restart. See {@link SuspendedRun}.
* Runs paused at a node, keyed by runId (ADR-0019). Process-local copy of
* the pause — mirrored to {@link store} when one is configured, so a pause
* survives a process restart. See {@link SuspendedRun}.
*
* [#13617] NOT a read-through cache sitting in front of the store. When a
* store is configured the STORE is the authority and this map answers only
* for the runs it never accepted ({@link cacheOnlySuspensions}). Reading
* this map first is what made a multi-replica approval flow re-create every
* level — the mechanism is in {@link loadSuspendedRunStrict}.
*/
private suspendedRuns = new Map<string, SuspendedRun>();
/**
* [#13617] Runs whose durable save FAILED, so {@link store} holds no row
* for them and its "no such run" says nothing about them. These are the
* only runs {@link loadSuspendedRunStrict} will answer out of
* {@link suspendedRuns} while a store is configured — which is what keeps
* {@link persistSuspendedRun}'s documented degradation (a save failure
* costs cross-restart durability, not in-process resumability) working.
*
* Written by {@link persistSuspendedRun} — added when a save throws,
* cleared when one lands — and dropped alongside the cache entry by
* {@link forgetSuspendedRun}, the single choke point every consumption
* passes through, so it is bounded by the map it qualifies.
*/
private cacheOnlySuspensions = new Set<string>();
/**
* Optional durable backing for {@link suspendedRuns}. When set, suspended
* runs are persisted on suspend and rehydrated on resume after a restart;
Expand DownExpand Up@@ -1788,7 +1808,17 @@ export class AutomationEngine implements IAutomationService {
if (this.store) {
try {
await this.store.save(run);
// [#13617] The store now holds this pause, so it — not this map
// — is the answer for it. Cleared here and not only on the
// failure path: a re-suspend whose save lands after an earlier
// one failed must stop being read out of memory.
this.cacheOnlySuspensions.delete(run.runId);
} catch (err) {
// [#13617] The store was never given the row, so its "no such
// run" is silence about this run rather than an answer. This is
// what lets `loadSuspendedRunStrict` keep serving it from the
// map — the in-process resumability the message below promises.
this.cacheOnlySuspensions.add(run.runId);
// #6499 — the cause is the datasource DRIVER's own text, so it
// goes to the logger's STRUCTURED slot, never spliced into the
// message; see `forgetSuspendedRun`'s catch below for the full
Expand DownExpand Up@@ -1831,6 +1861,10 @@ export class AutomationEngine implements IAutomationService {
*/
private async forgetSuspendedRun(run: SuspendedRun, reason: SuspensionReleaseReason): Promise<void> {
this.suspendedRuns.delete(run.runId);
// [#13617] The qualifier goes with the entry it qualifies — this is the
// one choke point every consumption passes through, so nothing can leave
// a run marked "the store never took this" after its map entry is gone.
this.cacheOnlySuspensions.delete(run.runId);
if (this.store) {
try {
await this.store.delete(run.runId);
Expand DownExpand Up@@ -4521,12 +4555,52 @@ export class AutomationEngine implements IAutomationService {
}

/** {@link loadSuspendedRun} without the degradation: a store read failure
* THROWS instead of reading as "no such run". */
* THROWS instead of reading as "no such run".
*
* [#13617] STORE-AUTHORITATIVE. When a {@link SuspendedRunStore} is
* configured, the store answers and {@link suspendedRuns} answers only for
* a run the store never accepted ({@link cacheOnlySuspensions}). It used
* to be the other way round — this process's map first, the store only on
* a miss — which is a correct read for exactly one deployment shape: a
* single process. Put several replicas behind a load balancer over one
* database and that map is a per-replica snapshot of the node a run was
* parked at THE LAST TIME THIS REPLICA TOUCHED IT, and nothing invalidates
* it, because there is no invalidation channel to it at all.
*
* The measured shape, a multi-level approval flow: replica A parks the run
* at `lv1` and keeps it in its map. The `lv1` decision round-robins to
* replica B, which advances the run to `lv2` in the store and in B's map;
* A's map still says `lv1`. The `lv2` decision lands back on A, which read
* its own map, resumed from `lv1`, and traversed to `lv2` a SECOND time —
* the same level re-created as a fresh pending request tens of
* milliseconds after the first one completed, so one approver approves
* every level twice. Land the same one-beat-stale read on the FINAL level
* and the run rolls back to the previous one instead of terminating. A
* single replica shows zero duplicates because there is one map and it is
* never behind.
*
* Both callers that must not be wrong funnel through here: `resumeInternal`
* (which node does this resume continue from) and {@link hasSuspendedRun}
* (the approvals pre-flight that decides whether to record a decision at
* all), so one seam settles both.
*
* ⛔ NOT a re-ordering of the resume path. The suspension is still consumed
* before `traverseNext` and {@link forgetSuspendedRun} is untouched —
* which ordering is right is #13937's question, and unruled. This changes
* only WHICH suspension is read, never when it is consumed. */
private async loadSuspendedRunStrict(runId: string): Promise<SuspendedRun | null> {
const cached = this.suspendedRuns.get(runId);
if (cached) return cached;
if (!this.store) return null;
return await this.store.load(runId);
if (!this.store) return this.suspendedRuns.get(runId) ?? null;
const stored = await this.store.load(runId);
if (stored) return stored;
// The store has no row. For every run it ever accepted that IS the
// answer — including the runs this process advanced past, whose stale
// map entries are the whole defect above. The lone exception is a run
// whose durable save failed here: the store was never handed that row,
// so its silence says nothing about it, and `persistSuspendedRun`
// deliberately keeps such a run resumable in-process (it reports the
// lost durability at `error`).
if (this.cacheOnlySuspensions.has(runId)) return this.suspendedRuns.get(runId) ?? null;
return null;
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/tall-moons-refuse.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
---
'@objectstack/service-automation': patch
---

Resume a paused flow run from the shared store, not from the replica's own memory of it

On a multi-replica deployment over one database, approving a level of a multi-level
approval flow could re-create the level that was just approved instead of opening the
next one — so the same approver had to approve each level twice, and a three-level flow
produced five approval requests. Landing the same stale read on the final level rolled
the run back to the previous one and left it parked forever instead of completing.

The engine kept paused runs in a per-process map and read that map before the durable
`sys_automation_run` row, so a replica that had handled the run earlier answered from
its own snapshot of the node the run was parked at — a snapshot nothing invalidates.
Whichever replica the next decision reached then traversed forward from a node the run
had already left. A single replica never showed it, because there is only one map and
it is never behind.

The resume path is now store-authoritative: with a `SuspendedRunStore` configured, the
store answers where a run is parked, and the in-memory map is consulted only for a run
whose durable save failed (the existing degradation, which keeps such a run resumable
in-process and reports the lost durability at `error`). The ordering of the resume
itself is unchanged — the suspension is still consumed before downstream traversal.

Two consequences worth knowing: every resume now reads the store, so an unreadable
store is reported as `STORE_UNAVAILABLE` for a run this process parked itself rather
than being served a possibly-stale snapshot; and the approvals pre-flight
(`hasSuspendedRun`) is answered from the same authoritative read, so a decision is no
longer recorded against a run that another replica has already advanced or finished.
32 changes: 19 additions & 13 deletions packages/services/service-automation/src/builtin/wait-node.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -375,13 +375,20 @@ describe('wait timer teardown when the pause ends another way (#5512)', () => {
* durable store unreadable, so per #4420 the pause is emphatically NOT gone —
* that cancelled the only thing left that would ever wake the run.
*
* Reachability is not equal across the two sites, and these tests are built to
* say so rather than to look symmetric: `resumeInternal` reads the durable store
* only on a hot-cache MISS, and a run that paused in this process stays cached
* for the life of its suspension. So the end-to-end specimen below is the
* **re-arm** callback (fresh process, empty cache, store consulted for real);
* the arming callback's branch is latent by construction and is pinned at the
* handler level, with the code injected rather than provoked.
* Reachability was not equal across the two sites when these tests were built,
* and they were shaped to say so rather than to look symmetric: `resumeInternal`
* read the durable store only on a MISS of the engine's in-memory map, and a run
* that paused in this process was answered from memory for the life of its
* suspension. So the end-to-end specimen below is the **re-arm** callback (fresh
* process, empty map, store consulted for real), while the arming callback's
* branch was latent by construction and is pinned at the handler level, with the
* code injected rather than provoked.
*
* [#13617] The asymmetry is gone — a store-backed engine now reads the store on
* every resume — but the arming-path specimen below is unchanged on purpose: it
* runs on an engine with NO store at all, where there is no store read to fail,
* so what it pins is still the handler's branch and the arming site's routing
* through it, not a reachability claim.
*/
describe('wait timer one-shot vs. a shot that never consumed the pause (#5529)', () => {
/** A logger that keeps its `error` lines so the diagnostic can be asserted. */
Expand DownExpand Up@@ -520,12 +527,11 @@ describe('wait timer one-shot vs. a shot that never consumed the pause (#5529)',

it('arming path: the same handler keeps the job armed on STORE_UNAVAILABLE', async () => {
// The arming callback shares one handler with the re-arm callback, so this
// pins the branch on THAT site too. The code is injected, not provoked: a run
// that paused in this process is in the engine's hot cache, so its own resume
// never reads the durable store and cannot produce STORE_UNAVAILABLE here.
// Fabricating a cache miss to "prove" otherwise would pin a scenario the
// engine does not have — what is verified is the handler's branch, and that
// the arming site routes through it rather than keeping its own `finally`.
// pins the branch on THAT site too. The code is injected, not provoked, and
// [#13617] did not change that: this engine is built with NO store, so no
// resume of it can produce STORE_UNAVAILABLE however it reads. What is
// verified is the handler's branch, and that the arming site routes through
// it rather than keeping its own `finally`.
const { ctx, scheduled, cancelled } = fakeJobCtx();
const engine = new AutomationEngine(silentLogger());
const ran: string[] = [];
Expand Down
17 changes: 10 additions & 7 deletions packages/services/service-automation/src/builtin/wait-node.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,13 +82,16 @@ interface WaitTimerLogger {
* found" and the only remaining path is the next boot's overdue re-arm pass.
* Both remedies are named in the log line for that reason.
*
* Reachability differs by site, and the honest note is that they are not equal.
* `resumeInternal` reads the durable store only on a hot-cache miss, and a run
* that paused in *this* process is cached for as long as the suspension lives —
* so the **re-arm** callback (a fresh process, empty cache) is where
* `STORE_UNAVAILABLE` is genuinely reachable today, while the arming callback's
* branch is latent by construction. It is shared anyway rather than special-cased:
* a second spelling of "settle the one-shot" is exactly the drift #5512 collapsed.
* Reachability was once unequal by site, and this note used to say so: while
* `resumeInternal` read the durable store only on a miss of the engine's
* in-memory map, a run that paused in *this* process was answered from memory
* for the life of its suspension, so only the **re-arm** callback (a fresh
* process, empty map) could genuinely produce `STORE_UNAVAILABLE`. [#13617]
* ended that: the resume path is store-authoritative whenever a
* `SuspendedRunStore` is configured, so BOTH callbacks read the store and both
* reach this branch — the arming site is no longer latent by construction. The
* handler was shared before that was true and stays shared: a second spelling
* of "settle the one-shot" is exactly the drift #5512 collapsed.
*/
function makeWaitTimerJobHandler(
engine: Pick<AutomationEngine, 'resume'>,
Expand Down
90 changes: 82 additions & 8 deletions packages/services/service-automation/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1622,11 +1622,31 @@ export class AutomationEngine implements IAutomationService {
private readonly runSummaryLog: RunSummaryLogLevel;
private logger: Logger;
/**
* Runs paused at a node, keyed by runId (ADR-0019). In-memory hot cache —
* mirrored to {@link store} when one is configured, so a pause survives a
* process restart. See {@link SuspendedRun}.
* Runs paused at a node, keyed by runId (ADR-0019). Process-local copy of
* the pause — mirrored to {@link store} when one is configured, so a pause
* survives a process restart. See {@link SuspendedRun}.
*
* [#13617] NOT a read-through cache sitting in front of the store. When a
* store is configured the STORE is the authority and this map answers only
* for the runs it never accepted ({@link cacheOnlySuspensions}). Reading
* this map first is what made a multi-replica approval flow re-create every
* level — the mechanism is in {@link loadSuspendedRunStrict}.
*/
private suspendedRuns = new Map<string, SuspendedRun>();
/**
* [#13617] Runs whose durable save FAILED, so {@link store} holds no row
* for them and its "no such run" says nothing about them. These are the
* only runs {@link loadSuspendedRunStrict} will answer out of
* {@link suspendedRuns} while a store is configured — which is what keeps
* {@link persistSuspendedRun}'s documented degradation (a save failure
* costs cross-restart durability, not in-process resumability) working.
*
* Written by {@link persistSuspendedRun} — added when a save throws,
* cleared when one lands — and dropped alongside the cache entry by
* {@link forgetSuspendedRun}, the single choke point every consumption
* passes through, so it is bounded by the map it qualifies.
*/
private cacheOnlySuspensions = new Set<string>();
/**
* Optional durable backing for {@link suspendedRuns}. When set, suspended
* runs are persisted on suspend and rehydrated on resume after a restart;
Expand DownExpand Up@@ -1788,7 +1808,17 @@ export class AutomationEngine implements IAutomationService {
if (this.store) {
try {
await this.store.save(run);
// [#13617] The store now holds this pause, so it — not this map
// — is the answer for it. Cleared here and not only on the
// failure path: a re-suspend whose save lands after an earlier
// one failed must stop being read out of memory.
this.cacheOnlySuspensions.delete(run.runId);
} catch (err) {
// [#13617] The store was never given the row, so its "no such
// run" is silence about this run rather than an answer. This is
// what lets `loadSuspendedRunStrict` keep serving it from the
// map — the in-process resumability the message below promises.
this.cacheOnlySuspensions.add(run.runId);
// #6499 — the cause is the datasource DRIVER's own text, so it
// goes to the logger's STRUCTURED slot, never spliced into the
// message; see `forgetSuspendedRun`'s catch below for the full
Expand DownExpand Up@@ -1831,6 +1861,10 @@ export class AutomationEngine implements IAutomationService {
*/
private async forgetSuspendedRun(run: SuspendedRun, reason: SuspensionReleaseReason): Promise<void> {
this.suspendedRuns.delete(run.runId);
// [#13617] The qualifier goes with the entry it qualifies — this is the
// one choke point every consumption passes through, so nothing can leave
// a run marked "the store never took this" after its map entry is gone.
this.cacheOnlySuspensions.delete(run.runId);
if (this.store) {
try {
await this.store.delete(run.runId);
Expand DownExpand Up@@ -4521,12 +4555,52 @@ export class AutomationEngine implements IAutomationService {
}

/** {@link loadSuspendedRun} without the degradation: a store read failure
* THROWS instead of reading as "no such run". */
* THROWS instead of reading as "no such run".
*
* [#13617] STORE-AUTHORITATIVE. When a {@link SuspendedRunStore} is
* configured, the store answers and {@link suspendedRuns} answers only for
* a run the store never accepted ({@link cacheOnlySuspensions}). It used
* to be the other way round — this process's map first, the store only on
* a miss — which is a correct read for exactly one deployment shape: a
* single process. Put several replicas behind a load balancer over one
* database and that map is a per-replica snapshot of the node a run was
* parked at THE LAST TIME THIS REPLICA TOUCHED IT, and nothing invalidates
* it, because there is no invalidation channel to it at all.
*
* The measured shape, a multi-level approval flow: replica A parks the run
* at `lv1` and keeps it in its map. The `lv1` decision round-robins to
* replica B, which advances the run to `lv2` in the store and in B's map;
* A's map still says `lv1`. The `lv2` decision lands back on A, which read
* its own map, resumed from `lv1`, and traversed to `lv2` a SECOND time —
* the same level re-created as a fresh pending request tens of
* milliseconds after the first one completed, so one approver approves
* every level twice. Land the same one-beat-stale read on the FINAL level
* and the run rolls back to the previous one instead of terminating. A
* single replica shows zero duplicates because there is one map and it is
* never behind.
*
* Both callers that must not be wrong funnel through here: `resumeInternal`
* (which node does this resume continue from) and {@link hasSuspendedRun}
* (the approvals pre-flight that decides whether to record a decision at
* all), so one seam settles both.
*
* ⛔ NOT a re-ordering of the resume path. The suspension is still consumed
* before `traverseNext` and {@link forgetSuspendedRun} is untouched —
* which ordering is right is #13937's question, and unruled. This changes
* only WHICH suspension is read, never when it is consumed. */
private async loadSuspendedRunStrict(runId: string): Promise<SuspendedRun | null> {
const cached = this.suspendedRuns.get(runId);
if (cached) return cached;
if (!this.store) return null;
return await this.store.load(runId);
if (!this.store) return this.suspendedRuns.get(runId) ?? null;
const stored = await this.store.load(runId);
if (stored) return stored;
// The store has no row. For every run it ever accepted that IS the
// answer — including the runs this process advanced past, whose stale
// map entries are the whole defect above. The lone exception is a run
// whose durable save failed here: the store was never handed that row,
// so its silence says nothing about it, and `persistSuspendedRun`
// deliberately keeps such a run resumable in-process (it reports the
// lost durability at `error`).
if (this.cacheOnlySuspensions.has(runId)) return this.suspendedRuns.get(runId) ?? null;
return null;
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/tall-moons-refuse.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
---
'@objectstack/service-automation': patch
---

Resume a paused flow run from the shared store, not from the replica's own memory of it

On a multi-replica deployment over one database, approving a level of a multi-level
approval flow could re-create the level that was just approved instead of opening the
next one — so the same approver had to approve each level twice, and a three-level flow
produced five approval requests. Landing the same stale read on the final level rolled
the run back to the previous one and left it parked forever instead of completing.

The engine kept paused runs in a per-process map and read that map before the durable
`sys_automation_run` row, so a replica that had handled the run earlier answered from
its own snapshot of the node the run was parked at — a snapshot nothing invalidates.
Whichever replica the next decision reached then traversed forward from a node the run
had already left. A single replica never showed it, because there is only one map and
it is never behind.

The resume path is now store-authoritative: with a `SuspendedRunStore` configured, the
store answers where a run is parked, and the in-memory map is consulted only for a run
whose durable save failed (the existing degradation, which keeps such a run resumable
in-process and reports the lost durability at `error`). The ordering of the resume
itself is unchanged — the suspension is still consumed before downstream traversal.

Two consequences worth knowing: every resume now reads the store, so an unreadable
store is reported as `STORE_UNAVAILABLE` for a run this process parked itself rather
than being served a possibly-stale snapshot; and the approvals pre-flight
(`hasSuspendedRun`) is answered from the same authoritative read, so a decision is no
longer recorded against a run that another replica has already advanced or finished.
32 changes: 19 additions & 13 deletions packages/services/service-automation/src/builtin/wait-node.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -375,13 +375,20 @@ describe('wait timer teardown when the pause ends another way (#5512)', () => {
* durable store unreadable, so per #4420 the pause is emphatically NOT gone —
* that cancelled the only thing left that would ever wake the run.
*
* Reachability is not equal across the two sites, and these tests are built to
* say so rather than to look symmetric: `resumeInternal` reads the durable store
* only on a hot-cache MISS, and a run that paused in this process stays cached
* for the life of its suspension. So the end-to-end specimen below is the
* **re-arm** callback (fresh process, empty cache, store consulted for real);
* the arming callback's branch is latent by construction and is pinned at the
* handler level, with the code injected rather than provoked.
* Reachability was not equal across the two sites when these tests were built,
* and they were shaped to say so rather than to look symmetric: `resumeInternal`
* read the durable store only on a MISS of the engine's in-memory map, and a run
* that paused in this process was answered from memory for the life of its
* suspension. So the end-to-end specimen below is the **re-arm** callback (fresh
* process, empty map, store consulted for real), while the arming callback's
* branch was latent by construction and is pinned at the handler level, with the
* code injected rather than provoked.
*
* [#13617] The asymmetry is gone — a store-backed engine now reads the store on
* every resume — but the arming-path specimen below is unchanged on purpose: it
* runs on an engine with NO store at all, where there is no store read to fail,
* so what it pins is still the handler's branch and the arming site's routing
* through it, not a reachability claim.
*/
describe('wait timer one-shot vs. a shot that never consumed the pause (#5529)', () => {
/** A logger that keeps its `error` lines so the diagnostic can be asserted. */
Expand DownExpand Up@@ -520,12 +527,11 @@ describe('wait timer one-shot vs. a shot that never consumed the pause (#5529)',

it('arming path: the same handler keeps the job armed on STORE_UNAVAILABLE', async () => {
// The arming callback shares one handler with the re-arm callback, so this
// pins the branch on THAT site too. The code is injected, not provoked: a run
// that paused in this process is in the engine's hot cache, so its own resume
// never reads the durable store and cannot produce STORE_UNAVAILABLE here.
// Fabricating a cache miss to "prove" otherwise would pin a scenario the
// engine does not have — what is verified is the handler's branch, and that
// the arming site routes through it rather than keeping its own `finally`.
// pins the branch on THAT site too. The code is injected, not provoked, and
// [#13617] did not change that: this engine is built with NO store, so no
// resume of it can produce STORE_UNAVAILABLE however it reads. What is
// verified is the handler's branch, and that the arming site routes through
// it rather than keeping its own `finally`.
const { ctx, scheduled, cancelled } = fakeJobCtx();
const engine = new AutomationEngine(silentLogger());
const ran: string[] = [];
Expand Down
17 changes: 10 additions & 7 deletions packages/services/service-automation/src/builtin/wait-node.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,13 +82,16 @@ interface WaitTimerLogger {
* found" and the only remaining path is the next boot's overdue re-arm pass.
* Both remedies are named in the log line for that reason.
*
* Reachability differs by site, and the honest note is that they are not equal.
* `resumeInternal` reads the durable store only on a hot-cache miss, and a run
* that paused in *this* process is cached for as long as the suspension lives —
* so the **re-arm** callback (a fresh process, empty cache) is where
* `STORE_UNAVAILABLE` is genuinely reachable today, while the arming callback's
* branch is latent by construction. It is shared anyway rather than special-cased:
* a second spelling of "settle the one-shot" is exactly the drift #5512 collapsed.
* Reachability was once unequal by site, and this note used to say so: while
* `resumeInternal` read the durable store only on a miss of the engine's
* in-memory map, a run that paused in *this* process was answered from memory
* for the life of its suspension, so only the **re-arm** callback (a fresh
* process, empty map) could genuinely produce `STORE_UNAVAILABLE`. [#13617]
* ended that: the resume path is store-authoritative whenever a
* `SuspendedRunStore` is configured, so BOTH callbacks read the store and both
* reach this branch — the arming site is no longer latent by construction. The
* handler was shared before that was true and stays shared: a second spelling
* of "settle the one-shot" is exactly the drift #5512 collapsed.
*/
function makeWaitTimerJobHandler(
engine: Pick<AutomationEngine, 'resume'>,
Expand Down
90 changes: 82 additions & 8 deletions packages/services/service-automation/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1622,11 +1622,31 @@ export class AutomationEngine implements IAutomationService {
private readonly runSummaryLog: RunSummaryLogLevel;
private logger: Logger;
/**
* Runs paused at a node, keyed by runId (ADR-0019). In-memory hot cache —
* mirrored to {@link store} when one is configured, so a pause survives a
* process restart. See {@link SuspendedRun}.
* Runs paused at a node, keyed by runId (ADR-0019). Process-local copy of
* the pause — mirrored to {@link store} when one is configured, so a pause
* survives a process restart. See {@link SuspendedRun}.
*
* [#13617] NOT a read-through cache sitting in front of the store. When a
* store is configured the STORE is the authority and this map answers only
* for the runs it never accepted ({@link cacheOnlySuspensions}). Reading
* this map first is what made a multi-replica approval flow re-create every
* level — the mechanism is in {@link loadSuspendedRunStrict}.
*/
private suspendedRuns = new Map<string, SuspendedRun>();
/**
* [#13617] Runs whose durable save FAILED, so {@link store} holds no row
* for them and its "no such run" says nothing about them. These are the
* only runs {@link loadSuspendedRunStrict} will answer out of
* {@link suspendedRuns} while a store is configured — which is what keeps
* {@link persistSuspendedRun}'s documented degradation (a save failure
* costs cross-restart durability, not in-process resumability) working.
*
* Written by {@link persistSuspendedRun} — added when a save throws,
* cleared when one lands — and dropped alongside the cache entry by
* {@link forgetSuspendedRun}, the single choke point every consumption
* passes through, so it is bounded by the map it qualifies.
*/
private cacheOnlySuspensions = new Set<string>();
/**
* Optional durable backing for {@link suspendedRuns}. When set, suspended
* runs are persisted on suspend and rehydrated on resume after a restart;
Expand DownExpand Up@@ -1788,7 +1808,17 @@ export class AutomationEngine implements IAutomationService {
if (this.store) {
try {
await this.store.save(run);
// [#13617] The store now holds this pause, so it — not this map
// — is the answer for it. Cleared here and not only on the
// failure path: a re-suspend whose save lands after an earlier
// one failed must stop being read out of memory.
this.cacheOnlySuspensions.delete(run.runId);
} catch (err) {
// [#13617] The store was never given the row, so its "no such
// run" is silence about this run rather than an answer. This is
// what lets `loadSuspendedRunStrict` keep serving it from the
// map — the in-process resumability the message below promises.
this.cacheOnlySuspensions.add(run.runId);
// #6499 — the cause is the datasource DRIVER's own text, so it
// goes to the logger's STRUCTURED slot, never spliced into the
// message; see `forgetSuspendedRun`'s catch below for the full
Expand DownExpand Up@@ -1831,6 +1861,10 @@ export class AutomationEngine implements IAutomationService {
*/
private async forgetSuspendedRun(run: SuspendedRun, reason: SuspensionReleaseReason): Promise<void> {
this.suspendedRuns.delete(run.runId);
// [#13617] The qualifier goes with the entry it qualifies — this is the
// one choke point every consumption passes through, so nothing can leave
// a run marked "the store never took this" after its map entry is gone.
this.cacheOnlySuspensions.delete(run.runId);
if (this.store) {
try {
await this.store.delete(run.runId);
Expand DownExpand Up@@ -4521,12 +4555,52 @@ export class AutomationEngine implements IAutomationService {
}

/** {@link loadSuspendedRun} without the degradation: a store read failure
* THROWS instead of reading as "no such run". */
* THROWS instead of reading as "no such run".
*
* [#13617] STORE-AUTHORITATIVE. When a {@link SuspendedRunStore} is
* configured, the store answers and {@link suspendedRuns} answers only for
* a run the store never accepted ({@link cacheOnlySuspensions}). It used
* to be the other way round — this process's map first, the store only on
* a miss — which is a correct read for exactly one deployment shape: a
* single process. Put several replicas behind a load balancer over one
* database and that map is a per-replica snapshot of the node a run was
* parked at THE LAST TIME THIS REPLICA TOUCHED IT, and nothing invalidates
* it, because there is no invalidation channel to it at all.
*
* The measured shape, a multi-level approval flow: replica A parks the run
* at `lv1` and keeps it in its map. The `lv1` decision round-robins to
* replica B, which advances the run to `lv2` in the store and in B's map;
* A's map still says `lv1`. The `lv2` decision lands back on A, which read
* its own map, resumed from `lv1`, and traversed to `lv2` a SECOND time —
* the same level re-created as a fresh pending request tens of
* milliseconds after the first one completed, so one approver approves
* every level twice. Land the same one-beat-stale read on the FINAL level
* and the run rolls back to the previous one instead of terminating. A
* single replica shows zero duplicates because there is one map and it is
* never behind.
*
* Both callers that must not be wrong funnel through here: `resumeInternal`
* (which node does this resume continue from) and {@link hasSuspendedRun}
* (the approvals pre-flight that decides whether to record a decision at
* all), so one seam settles both.
*
* ⛔ NOT a re-ordering of the resume path. The suspension is still consumed
* before `traverseNext` and {@link forgetSuspendedRun} is untouched —
* which ordering is right is #13937's question, and unruled. This changes
* only WHICH suspension is read, never when it is consumed. */
private async loadSuspendedRunStrict(runId: string): Promise<SuspendedRun | null> {
const cached = this.suspendedRuns.get(runId);
if (cached) return cached;
if (!this.store) return null;
return await this.store.load(runId);
if (!this.store) return this.suspendedRuns.get(runId) ?? null;
const stored = await this.store.load(runId);
if (stored) return stored;
// The store has no row. For every run it ever accepted that IS the
// answer — including the runs this process advanced past, whose stale
// map entries are the whole defect above. The lone exception is a run
// whose durable save failed here: the store was never handed that row,
// so its silence says nothing about it, and `persistSuspendedRun`
// deliberately keeps such a run resumable in-process (it reports the
// lost durability at `error`).
if (this.cacheOnlySuspensions.has(runId)) return this.suspendedRuns.get(runId) ?? null;
return null;
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/tall-moons-refuse.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
---
'@objectstack/service-automation': patch
---

Resume a paused flow run from the shared store, not from the replica's own memory of it

On a multi-replica deployment over one database, approving a level of a multi-level
approval flow could re-create the level that was just approved instead of opening the
next one — so the same approver had to approve each level twice, and a three-level flow
produced five approval requests. Landing the same stale read on the final level rolled
the run back to the previous one and left it parked forever instead of completing.

The engine kept paused runs in a per-process map and read that map before the durable
`sys_automation_run` row, so a replica that had handled the run earlier answered from
its own snapshot of the node the run was parked at — a snapshot nothing invalidates.
Whichever replica the next decision reached then traversed forward from a node the run
had already left. A single replica never showed it, because there is only one map and
it is never behind.

The resume path is now store-authoritative: with a `SuspendedRunStore` configured, the
store answers where a run is parked, and the in-memory map is consulted only for a run
whose durable save failed (the existing degradation, which keeps such a run resumable
in-process and reports the lost durability at `error`). The ordering of the resume
itself is unchanged — the suspension is still consumed before downstream traversal.

Two consequences worth knowing: every resume now reads the store, so an unreadable
store is reported as `STORE_UNAVAILABLE` for a run this process parked itself rather
than being served a possibly-stale snapshot; and the approvals pre-flight
(`hasSuspendedRun`) is answered from the same authoritative read, so a decision is no
longer recorded against a run that another replica has already advanced or finished.
32 changes: 19 additions & 13 deletions packages/services/service-automation/src/builtin/wait-node.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -375,13 +375,20 @@ describe('wait timer teardown when the pause ends another way (#5512)', () => {
* durable store unreadable, so per #4420 the pause is emphatically NOT gone —
* that cancelled the only thing left that would ever wake the run.
*
* Reachability is not equal across the two sites, and these tests are built to
* say so rather than to look symmetric: `resumeInternal` reads the durable store
* only on a hot-cache MISS, and a run that paused in this process stays cached
* for the life of its suspension. So the end-to-end specimen below is the
* **re-arm** callback (fresh process, empty cache, store consulted for real);
* the arming callback's branch is latent by construction and is pinned at the
* handler level, with the code injected rather than provoked.
* Reachability was not equal across the two sites when these tests were built,
* and they were shaped to say so rather than to look symmetric: `resumeInternal`
* read the durable store only on a MISS of the engine's in-memory map, and a run
* that paused in this process was answered from memory for the life of its
* suspension. So the end-to-end specimen below is the **re-arm** callback (fresh
* process, empty map, store consulted for real), while the arming callback's
* branch was latent by construction and is pinned at the handler level, with the
* code injected rather than provoked.
*
* [#13617] The asymmetry is gone — a store-backed engine now reads the store on
* every resume — but the arming-path specimen below is unchanged on purpose: it
* runs on an engine with NO store at all, where there is no store read to fail,
* so what it pins is still the handler's branch and the arming site's routing
* through it, not a reachability claim.
*/
describe('wait timer one-shot vs. a shot that never consumed the pause (#5529)', () => {
/** A logger that keeps its `error` lines so the diagnostic can be asserted. */
Expand DownExpand Up@@ -520,12 +527,11 @@ describe('wait timer one-shot vs. a shot that never consumed the pause (#5529)',

it('arming path: the same handler keeps the job armed on STORE_UNAVAILABLE', async () => {
// The arming callback shares one handler with the re-arm callback, so this
// pins the branch on THAT site too. The code is injected, not provoked: a run
// that paused in this process is in the engine's hot cache, so its own resume
// never reads the durable store and cannot produce STORE_UNAVAILABLE here.
// Fabricating a cache miss to "prove" otherwise would pin a scenario the
// engine does not have — what is verified is the handler's branch, and that
// the arming site routes through it rather than keeping its own `finally`.
// pins the branch on THAT site too. The code is injected, not provoked, and
// [#13617] did not change that: this engine is built with NO store, so no
// resume of it can produce STORE_UNAVAILABLE however it reads. What is
// verified is the handler's branch, and that the arming site routes through
// it rather than keeping its own `finally`.
const { ctx, scheduled, cancelled } = fakeJobCtx();
const engine = new AutomationEngine(silentLogger());
const ran: string[] = [];
Expand Down
17 changes: 10 additions & 7 deletions packages/services/service-automation/src/builtin/wait-node.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,13 +82,16 @@ interface WaitTimerLogger {
* found" and the only remaining path is the next boot's overdue re-arm pass.
* Both remedies are named in the log line for that reason.
*
* Reachability differs by site, and the honest note is that they are not equal.
* `resumeInternal` reads the durable store only on a hot-cache miss, and a run
* that paused in *this* process is cached for as long as the suspension lives —
* so the **re-arm** callback (a fresh process, empty cache) is where
* `STORE_UNAVAILABLE` is genuinely reachable today, while the arming callback's
* branch is latent by construction. It is shared anyway rather than special-cased:
* a second spelling of "settle the one-shot" is exactly the drift #5512 collapsed.
* Reachability was once unequal by site, and this note used to say so: while
* `resumeInternal` read the durable store only on a miss of the engine's
* in-memory map, a run that paused in *this* process was answered from memory
* for the life of its suspension, so only the **re-arm** callback (a fresh
* process, empty map) could genuinely produce `STORE_UNAVAILABLE`. [#13617]
* ended that: the resume path is store-authoritative whenever a
* `SuspendedRunStore` is configured, so BOTH callbacks read the store and both
* reach this branch — the arming site is no longer latent by construction. The
* handler was shared before that was true and stays shared: a second spelling
* of "settle the one-shot" is exactly the drift #5512 collapsed.
*/
function makeWaitTimerJobHandler(
engine: Pick<AutomationEngine, 'resume'>,
Expand Down
90 changes: 82 additions & 8 deletions packages/services/service-automation/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1622,11 +1622,31 @@ export class AutomationEngine implements IAutomationService {
private readonly runSummaryLog: RunSummaryLogLevel;
private logger: Logger;
/**
* Runs paused at a node, keyed by runId (ADR-0019). In-memory hot cache —
* mirrored to {@link store} when one is configured, so a pause survives a
* process restart. See {@link SuspendedRun}.
* Runs paused at a node, keyed by runId (ADR-0019). Process-local copy of
* the pause — mirrored to {@link store} when one is configured, so a pause
* survives a process restart. See {@link SuspendedRun}.
*
* [#13617] NOT a read-through cache sitting in front of the store. When a
* store is configured the STORE is the authority and this map answers only
* for the runs it never accepted ({@link cacheOnlySuspensions}). Reading
* this map first is what made a multi-replica approval flow re-create every
* level — the mechanism is in {@link loadSuspendedRunStrict}.
*/
private suspendedRuns = new Map<string, SuspendedRun>();
/**
* [#13617] Runs whose durable save FAILED, so {@link store} holds no row
* for them and its "no such run" says nothing about them. These are the
* only runs {@link loadSuspendedRunStrict} will answer out of
* {@link suspendedRuns} while a store is configured — which is what keeps
* {@link persistSuspendedRun}'s documented degradation (a save failure
* costs cross-restart durability, not in-process resumability) working.
*
* Written by {@link persistSuspendedRun} — added when a save throws,
* cleared when one lands — and dropped alongside the cache entry by
* {@link forgetSuspendedRun}, the single choke point every consumption
* passes through, so it is bounded by the map it qualifies.
*/
private cacheOnlySuspensions = new Set<string>();
/**
* Optional durable backing for {@link suspendedRuns}. When set, suspended
* runs are persisted on suspend and rehydrated on resume after a restart;
Expand DownExpand Up@@ -1788,7 +1808,17 @@ export class AutomationEngine implements IAutomationService {
if (this.store) {
try {
await this.store.save(run);
// [#13617] The store now holds this pause, so it — not this map
// — is the answer for it. Cleared here and not only on the
// failure path: a re-suspend whose save lands after an earlier
// one failed must stop being read out of memory.
this.cacheOnlySuspensions.delete(run.runId);
} catch (err) {
// [#13617] The store was never given the row, so its "no such
// run" is silence about this run rather than an answer. This is
// what lets `loadSuspendedRunStrict` keep serving it from the
// map — the in-process resumability the message below promises.
this.cacheOnlySuspensions.add(run.runId);
// #6499 — the cause is the datasource DRIVER's own text, so it
// goes to the logger's STRUCTURED slot, never spliced into the
// message; see `forgetSuspendedRun`'s catch below for the full
Expand DownExpand Up@@ -1831,6 +1861,10 @@ export class AutomationEngine implements IAutomationService {
*/
private async forgetSuspendedRun(run: SuspendedRun, reason: SuspensionReleaseReason): Promise<void> {
this.suspendedRuns.delete(run.runId);
// [#13617] The qualifier goes with the entry it qualifies — this is the
// one choke point every consumption passes through, so nothing can leave
// a run marked "the store never took this" after its map entry is gone.
this.cacheOnlySuspensions.delete(run.runId);
if (this.store) {
try {
await this.store.delete(run.runId);
Expand DownExpand Up@@ -4521,12 +4555,52 @@ export class AutomationEngine implements IAutomationService {
}

/** {@link loadSuspendedRun} without the degradation: a store read failure
* THROWS instead of reading as "no such run". */
* THROWS instead of reading as "no such run".
*
* [#13617] STORE-AUTHORITATIVE. When a {@link SuspendedRunStore} is
* configured, the store answers and {@link suspendedRuns} answers only for
* a run the store never accepted ({@link cacheOnlySuspensions}). It used
* to be the other way round — this process's map first, the store only on
* a miss — which is a correct read for exactly one deployment shape: a
* single process. Put several replicas behind a load balancer over one
* database and that map is a per-replica snapshot of the node a run was
* parked at THE LAST TIME THIS REPLICA TOUCHED IT, and nothing invalidates
* it, because there is no invalidation channel to it at all.
*
* The measured shape, a multi-level approval flow: replica A parks the run
* at `lv1` and keeps it in its map. The `lv1` decision round-robins to
* replica B, which advances the run to `lv2` in the store and in B's map;
* A's map still says `lv1`. The `lv2` decision lands back on A, which read
* its own map, resumed from `lv1`, and traversed to `lv2` a SECOND time —
* the same level re-created as a fresh pending request tens of
* milliseconds after the first one completed, so one approver approves
* every level twice. Land the same one-beat-stale read on the FINAL level
* and the run rolls back to the previous one instead of terminating. A
* single replica shows zero duplicates because there is one map and it is
* never behind.
*
* Both callers that must not be wrong funnel through here: `resumeInternal`
* (which node does this resume continue from) and {@link hasSuspendedRun}
* (the approvals pre-flight that decides whether to record a decision at
* all), so one seam settles both.
*
* ⛔ NOT a re-ordering of the resume path. The suspension is still consumed
* before `traverseNext` and {@link forgetSuspendedRun} is untouched —
* which ordering is right is #13937's question, and unruled. This changes
* only WHICH suspension is read, never when it is consumed. */
private async loadSuspendedRunStrict(runId: string): Promise<SuspendedRun | null> {
const cached = this.suspendedRuns.get(runId);
if (cached) return cached;
if (!this.store) return null;
return await this.store.load(runId);
if (!this.store) return this.suspendedRuns.get(runId) ?? null;
const stored = await this.store.load(runId);
if (stored) return stored;
// The store has no row. For every run it ever accepted that IS the
// answer — including the runs this process advanced past, whose stale
// map entries are the whole defect above. The lone exception is a run
// whose durable save failed here: the store was never handed that row,
// so its silence says nothing about it, and `persistSuspendedRun`
// deliberately keeps such a run resumable in-process (it reports the
// lost durability at `error`).
if (this.cacheOnlySuspensions.has(runId)) return this.suspendedRuns.get(runId) ?? null;
return null;
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/tall-moons-refuse.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
---
'@objectstack/service-automation': patch
---

Resume a paused flow run from the shared store, not from the replica's own memory of it

On a multi-replica deployment over one database, approving a level of a multi-level
approval flow could re-create the level that was just approved instead of opening the
next one — so the same approver had to approve each level twice, and a three-level flow
produced five approval requests. Landing the same stale read on the final level rolled
the run back to the previous one and left it parked forever instead of completing.

The engine kept paused runs in a per-process map and read that map before the durable
`sys_automation_run` row, so a replica that had handled the run earlier answered from
its own snapshot of the node the run was parked at — a snapshot nothing invalidates.
Whichever replica the next decision reached then traversed forward from a node the run
had already left. A single replica never showed it, because there is only one map and
it is never behind.

The resume path is now store-authoritative: with a `SuspendedRunStore` configured, the
store answers where a run is parked, and the in-memory map is consulted only for a run
whose durable save failed (the existing degradation, which keeps such a run resumable
in-process and reports the lost durability at `error`). The ordering of the resume
itself is unchanged — the suspension is still consumed before downstream traversal.

Two consequences worth knowing: every resume now reads the store, so an unreadable
store is reported as `STORE_UNAVAILABLE` for a run this process parked itself rather
than being served a possibly-stale snapshot; and the approvals pre-flight
(`hasSuspendedRun`) is answered from the same authoritative read, so a decision is no
longer recorded against a run that another replica has already advanced or finished.
32 changes: 19 additions & 13 deletions packages/services/service-automation/src/builtin/wait-node.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -375,13 +375,20 @@ describe('wait timer teardown when the pause ends another way (#5512)', () => {
* durable store unreadable, so per #4420 the pause is emphatically NOT gone —
* that cancelled the only thing left that would ever wake the run.
*
* Reachability is not equal across the two sites, and these tests are built to
* say so rather than to look symmetric: `resumeInternal` reads the durable store
* only on a hot-cache MISS, and a run that paused in this process stays cached
* for the life of its suspension. So the end-to-end specimen below is the
* **re-arm** callback (fresh process, empty cache, store consulted for real);
* the arming callback's branch is latent by construction and is pinned at the
* handler level, with the code injected rather than provoked.
* Reachability was not equal across the two sites when these tests were built,
* and they were shaped to say so rather than to look symmetric: `resumeInternal`
* read the durable store only on a MISS of the engine's in-memory map, and a run
* that paused in this process was answered from memory for the life of its
* suspension. So the end-to-end specimen below is the **re-arm** callback (fresh
* process, empty map, store consulted for real), while the arming callback's
* branch was latent by construction and is pinned at the handler level, with the
* code injected rather than provoked.
*
* [#13617] The asymmetry is gone — a store-backed engine now reads the store on
* every resume — but the arming-path specimen below is unchanged on purpose: it
* runs on an engine with NO store at all, where there is no store read to fail,
* so what it pins is still the handler's branch and the arming site's routing
* through it, not a reachability claim.
*/
describe('wait timer one-shot vs. a shot that never consumed the pause (#5529)', () => {
/** A logger that keeps its `error` lines so the diagnostic can be asserted. */
Expand DownExpand Up@@ -520,12 +527,11 @@ describe('wait timer one-shot vs. a shot that never consumed the pause (#5529)',

it('arming path: the same handler keeps the job armed on STORE_UNAVAILABLE', async () => {
// The arming callback shares one handler with the re-arm callback, so this
// pins the branch on THAT site too. The code is injected, not provoked: a run
// that paused in this process is in the engine's hot cache, so its own resume
// never reads the durable store and cannot produce STORE_UNAVAILABLE here.
// Fabricating a cache miss to "prove" otherwise would pin a scenario the
// engine does not have — what is verified is the handler's branch, and that
// the arming site routes through it rather than keeping its own `finally`.
// pins the branch on THAT site too. The code is injected, not provoked, and
// [#13617] did not change that: this engine is built with NO store, so no
// resume of it can produce STORE_UNAVAILABLE however it reads. What is
// verified is the handler's branch, and that the arming site routes through
// it rather than keeping its own `finally`.
const { ctx, scheduled, cancelled } = fakeJobCtx();
const engine = new AutomationEngine(silentLogger());
const ran: string[] = [];
Expand Down
17 changes: 10 additions & 7 deletions packages/services/service-automation/src/builtin/wait-node.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,13 +82,16 @@ interface WaitTimerLogger {
* found" and the only remaining path is the next boot's overdue re-arm pass.
* Both remedies are named in the log line for that reason.
*
* Reachability differs by site, and the honest note is that they are not equal.
* `resumeInternal` reads the durable store only on a hot-cache miss, and a run
* that paused in *this* process is cached for as long as the suspension lives —
* so the **re-arm** callback (a fresh process, empty cache) is where
* `STORE_UNAVAILABLE` is genuinely reachable today, while the arming callback's
* branch is latent by construction. It is shared anyway rather than special-cased:
* a second spelling of "settle the one-shot" is exactly the drift #5512 collapsed.
* Reachability was once unequal by site, and this note used to say so: while
* `resumeInternal` read the durable store only on a miss of the engine's
* in-memory map, a run that paused in *this* process was answered from memory
* for the life of its suspension, so only the **re-arm** callback (a fresh
* process, empty map) could genuinely produce `STORE_UNAVAILABLE`. [#13617]
* ended that: the resume path is store-authoritative whenever a
* `SuspendedRunStore` is configured, so BOTH callbacks read the store and both
* reach this branch — the arming site is no longer latent by construction. The
* handler was shared before that was true and stays shared: a second spelling
* of "settle the one-shot" is exactly the drift #5512 collapsed.
*/
function makeWaitTimerJobHandler(
engine: Pick<AutomationEngine, 'resume'>,
Expand Down
90 changes: 82 additions & 8 deletions packages/services/service-automation/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1622,11 +1622,31 @@ export class AutomationEngine implements IAutomationService {
private readonly runSummaryLog: RunSummaryLogLevel;
private logger: Logger;
/**
* Runs paused at a node, keyed by runId (ADR-0019). In-memory hot cache —
* mirrored to {@link store} when one is configured, so a pause survives a
* process restart. See {@link SuspendedRun}.
* Runs paused at a node, keyed by runId (ADR-0019). Process-local copy of
* the pause — mirrored to {@link store} when one is configured, so a pause
* survives a process restart. See {@link SuspendedRun}.
*
* [#13617] NOT a read-through cache sitting in front of the store. When a
* store is configured the STORE is the authority and this map answers only
* for the runs it never accepted ({@link cacheOnlySuspensions}). Reading
* this map first is what made a multi-replica approval flow re-create every
* level — the mechanism is in {@link loadSuspendedRunStrict}.
*/
private suspendedRuns = new Map<string, SuspendedRun>();
/**
* [#13617] Runs whose durable save FAILED, so {@link store} holds no row
* for them and its "no such run" says nothing about them. These are the
* only runs {@link loadSuspendedRunStrict} will answer out of
* {@link suspendedRuns} while a store is configured — which is what keeps
* {@link persistSuspendedRun}'s documented degradation (a save failure
* costs cross-restart durability, not in-process resumability) working.
*
* Written by {@link persistSuspendedRun} — added when a save throws,
* cleared when one lands — and dropped alongside the cache entry by
* {@link forgetSuspendedRun}, the single choke point every consumption
* passes through, so it is bounded by the map it qualifies.
*/
private cacheOnlySuspensions = new Set<string>();
/**
* Optional durable backing for {@link suspendedRuns}. When set, suspended
* runs are persisted on suspend and rehydrated on resume after a restart;
Expand DownExpand Up@@ -1788,7 +1808,17 @@ export class AutomationEngine implements IAutomationService {
if (this.store) {
try {
await this.store.save(run);
// [#13617] The store now holds this pause, so it — not this map
// — is the answer for it. Cleared here and not only on the
// failure path: a re-suspend whose save lands after an earlier
// one failed must stop being read out of memory.
this.cacheOnlySuspensions.delete(run.runId);
} catch (err) {
// [#13617] The store was never given the row, so its "no such
// run" is silence about this run rather than an answer. This is
// what lets `loadSuspendedRunStrict` keep serving it from the
// map — the in-process resumability the message below promises.
this.cacheOnlySuspensions.add(run.runId);
// #6499 — the cause is the datasource DRIVER's own text, so it
// goes to the logger's STRUCTURED slot, never spliced into the
// message; see `forgetSuspendedRun`'s catch below for the full
Expand DownExpand Up@@ -1831,6 +1861,10 @@ export class AutomationEngine implements IAutomationService {
*/
private async forgetSuspendedRun(run: SuspendedRun, reason: SuspensionReleaseReason): Promise<void> {
this.suspendedRuns.delete(run.runId);
// [#13617] The qualifier goes with the entry it qualifies — this is the
// one choke point every consumption passes through, so nothing can leave
// a run marked "the store never took this" after its map entry is gone.
this.cacheOnlySuspensions.delete(run.runId);
if (this.store) {
try {
await this.store.delete(run.runId);
Expand DownExpand Up@@ -4521,12 +4555,52 @@ export class AutomationEngine implements IAutomationService {
}

/** {@link loadSuspendedRun} without the degradation: a store read failure
* THROWS instead of reading as "no such run". */
* THROWS instead of reading as "no such run".
*
* [#13617] STORE-AUTHORITATIVE. When a {@link SuspendedRunStore} is
* configured, the store answers and {@link suspendedRuns} answers only for
* a run the store never accepted ({@link cacheOnlySuspensions}). It used
* to be the other way round — this process's map first, the store only on
* a miss — which is a correct read for exactly one deployment shape: a
* single process. Put several replicas behind a load balancer over one
* database and that map is a per-replica snapshot of the node a run was
* parked at THE LAST TIME THIS REPLICA TOUCHED IT, and nothing invalidates
* it, because there is no invalidation channel to it at all.
*
* The measured shape, a multi-level approval flow: replica A parks the run
* at `lv1` and keeps it in its map. The `lv1` decision round-robins to
* replica B, which advances the run to `lv2` in the store and in B's map;
* A's map still says `lv1`. The `lv2` decision lands back on A, which read
* its own map, resumed from `lv1`, and traversed to `lv2` a SECOND time —
* the same level re-created as a fresh pending request tens of
* milliseconds after the first one completed, so one approver approves
* every level twice. Land the same one-beat-stale read on the FINAL level
* and the run rolls back to the previous one instead of terminating. A
* single replica shows zero duplicates because there is one map and it is
* never behind.
*
* Both callers that must not be wrong funnel through here: `resumeInternal`
* (which node does this resume continue from) and {@link hasSuspendedRun}
* (the approvals pre-flight that decides whether to record a decision at
* all), so one seam settles both.
*
* ⛔ NOT a re-ordering of the resume path. The suspension is still consumed
* before `traverseNext` and {@link forgetSuspendedRun} is untouched —
* which ordering is right is #13937's question, and unruled. This changes
* only WHICH suspension is read, never when it is consumed. */
private async loadSuspendedRunStrict(runId: string): Promise<SuspendedRun | null> {
const cached = this.suspendedRuns.get(runId);
if (cached) return cached;
if (!this.store) return null;
return await this.store.load(runId);
if (!this.store) return this.suspendedRuns.get(runId) ?? null;
const stored = await this.store.load(runId);
if (stored) return stored;
// The store has no row. For every run it ever accepted that IS the
// answer — including the runs this process advanced past, whose stale
// map entries are the whole defect above. The lone exception is a run
// whose durable save failed here: the store was never handed that row,
// so its silence says nothing about it, and `persistSuspendedRun`
// deliberately keeps such a run resumable in-process (it reports the
// lost durability at `error`).
if (this.cacheOnlySuspensions.has(runId)) return this.suspendedRuns.get(runId) ?? null;
return null;
}

/**
Expand Down
Loading
Loading