@cat-factory/worker 0.175.0 → 0.177.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/infrastructure/config/execution.d.ts +1 -1
- package/dist/infrastructure/config/execution.d.ts.map +1 -1
- package/dist/infrastructure/config/execution.js +12 -3
- package/dist/infrastructure/config/execution.js.map +1 -1
- package/dist/infrastructure/containers/CloudflareContainerTransport.d.ts +15 -1
- package/dist/infrastructure/containers/CloudflareContainerTransport.d.ts.map +1 -1
- package/dist/infrastructure/containers/CloudflareContainerTransport.js +73 -17
- package/dist/infrastructure/containers/CloudflareContainerTransport.js.map +1 -1
- package/dist/infrastructure/containers/DeployContainer.d.ts +2 -20
- package/dist/infrastructure/containers/DeployContainer.d.ts.map +1 -1
- package/dist/infrastructure/containers/DeployContainer.js +7 -56
- package/dist/infrastructure/containers/DeployContainer.js.map +1 -1
- package/dist/infrastructure/containers/ExecutionContainer.d.ts +2 -51
- package/dist/infrastructure/containers/ExecutionContainer.d.ts.map +1 -1
- package/dist/infrastructure/containers/ExecutionContainer.js +9 -93
- package/dist/infrastructure/containers/ExecutionContainer.js.map +1 -1
- package/dist/infrastructure/containers/RunContainer.d.ts +150 -0
- package/dist/infrastructure/containers/RunContainer.d.ts.map +1 -0
- package/dist/infrastructure/containers/RunContainer.js +229 -0
- package/dist/infrastructure/containers/RunContainer.js.map +1 -0
- package/dist/infrastructure/containers/stopCause.d.ts +240 -0
- package/dist/infrastructure/containers/stopCause.d.ts.map +1 -0
- package/dist/infrastructure/containers/stopCause.js +313 -0
- package/dist/infrastructure/containers/stopCause.js.map +1 -0
- package/dist/infrastructure/env.d.ts +10 -0
- package/dist/infrastructure/env.d.ts.map +1 -1
- package/dist/infrastructure/env.js.map +1 -1
- package/dist/infrastructure/repositories/D1BinaryArtifactMetadataStore.d.ts +4 -1
- package/dist/infrastructure/repositories/D1BinaryArtifactMetadataStore.d.ts.map +1 -1
- package/dist/infrastructure/repositories/D1BinaryArtifactMetadataStore.js +68 -5
- package/dist/infrastructure/repositories/D1BinaryArtifactMetadataStore.js.map +1 -1
- package/dist/infrastructure/repositories/D1DocumentRepository.d.ts.map +1 -1
- package/dist/infrastructure/repositories/D1DocumentRepository.js +5 -3
- package/dist/infrastructure/repositories/D1DocumentRepository.js.map +1 -1
- package/dist/infrastructure/repositories/chunk.d.ts +10 -2
- package/dist/infrastructure/repositories/chunk.d.ts.map +1 -1
- package/dist/infrastructure/repositories/chunk.js +13 -4
- package/dist/infrastructure/repositories/chunk.js.map +1 -1
- package/dist/infrastructure/workflows/ExecutionWorkflow.d.ts.map +1 -1
- package/dist/infrastructure/workflows/ExecutionWorkflow.js +17 -8
- package/dist/infrastructure/workflows/ExecutionWorkflow.js.map +1 -1
- package/migrations/0087_document_renders.sql +28 -0
- package/package.json +18 -18
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { Container } from '@cloudflare/containers';
|
|
2
|
+
import type { StopParams } from '@cloudflare/containers';
|
|
3
|
+
import type { Env } from '../env';
|
|
4
|
+
import { type StopObservation } from './stopCause';
|
|
5
|
+
/**
|
|
6
|
+
* The behaviour every per-run Cloudflare Container shares: one Durable Object instance per run
|
|
7
|
+
* id hosts that run's sequence of jobs, the harness listens on 8080, and the base
|
|
8
|
+
* `Container.fetch` proxies inbound requests there once it has booted.
|
|
9
|
+
*
|
|
10
|
+
* The two concrete classes ({@link import('./ExecutionContainer').ExecutionContainer} and
|
|
11
|
+
* {@link import('./DeployContainer').DeployContainer}) exist only because a Cloudflare
|
|
12
|
+
* Container's IMAGE is pinned per container class by the wrangler `[[containers]]` block. They
|
|
13
|
+
* are two bindings onto one behaviour, which is why that behaviour lives here rather than being
|
|
14
|
+
* kept in step by hand across both.
|
|
15
|
+
*
|
|
16
|
+
* No long-lived secrets are configured: the image carries none, and every per-job credential
|
|
17
|
+
* (the VCS token, the LLM session token, an apiserver token) arrives in the `/jobs` request
|
|
18
|
+
* body at dispatch time. The one exception is `HARNESS_SHARED_SECRET` below, an inbound-auth
|
|
19
|
+
* shared secret rather than a tenant credential.
|
|
20
|
+
*/
|
|
21
|
+
export declare abstract class RunContainer extends Container<Env> {
|
|
22
|
+
/** The harness HTTP server port (matches each image's Dockerfile ENTRYPOINT/EXPOSE). */
|
|
23
|
+
defaultPort: number;
|
|
24
|
+
envVars: Record<string, string>;
|
|
25
|
+
sleepAfter: string;
|
|
26
|
+
/**
|
|
27
|
+
* Whether the stop this container is about to observe is one WE asked for: the idle reclaim in
|
|
28
|
+
* {@link onActivityExpired} and the deliberate teardown in {@link shutdown}, both of which stop
|
|
29
|
+
* the container by signalling it.
|
|
30
|
+
*
|
|
31
|
+
* It exists because the resulting exit state is evidence about nothing. We sent the signal, so
|
|
32
|
+
* the code that comes back describes our own request (and escalates to a SIGKILL 137 whenever
|
|
33
|
+
* the harness does not exit inside the platform's grace period), while the account the run
|
|
34
|
+
* actually needs is the cause already recorded beside it. Left recorded, that exit is read back
|
|
35
|
+
* as the container's cause of death and reported as "most often an out-of-memory kill" under a
|
|
36
|
+
* verdict that says the platform reclaimed an idle container.
|
|
37
|
+
*
|
|
38
|
+
* In-memory rather than persisted, and reset by {@link onStart}: it describes ONE stop of ONE
|
|
39
|
+
* container life, and the isolate cannot outlive the stop it is set for. A container that comes
|
|
40
|
+
* back up has a real death ahead of it again.
|
|
41
|
+
*/
|
|
42
|
+
private selfInitiatedStop;
|
|
43
|
+
/**
|
|
44
|
+
* A container life is starting, so nothing from here on is a stop we asked for.
|
|
45
|
+
*
|
|
46
|
+
* The base class flushes any deferred `onStop` from the PREVIOUS life before it calls this, so
|
|
47
|
+
* the reset can never land between a self-initiated stop and the hook that observes it.
|
|
48
|
+
*/
|
|
49
|
+
onStart(): Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* Record that THIS run's container was drained by a new-version rollout (a deploy, exit 143)
|
|
52
|
+
* rather than crashing. The transport's next job poll (which 404s once the container restarts
|
|
53
|
+
* empty) reads this back through {@link recentStopObservation}, so the engine
|
|
54
|
+
* recovers it on the larger transient budget instead of failing the run as a crash.
|
|
55
|
+
* Persisted to DO storage (not in-memory) so it survives the isolate reset a combined
|
|
56
|
+
* worker+container deploy causes.
|
|
57
|
+
*/
|
|
58
|
+
onError(error: unknown): Promise<unknown>;
|
|
59
|
+
/**
|
|
60
|
+
* Record EVERY stop, with whatever this hook can say about it.
|
|
61
|
+
*
|
|
62
|
+
* Two things ride the same write. A `runtime_signal` SIGTERM (143) is a rollout drain, which
|
|
63
|
+
* depending on the runtime version surfaces here instead of (or as well as) through `onError`,
|
|
64
|
+
* so it is recorded as that cause the same way. And the `{ exitCode, reason }` pair itself is
|
|
65
|
+
* recorded for every stop, cause or not, because it is the ONLY account of a death this
|
|
66
|
+
* runtime can keep: a Cloudflare Container's stdout goes to the deployment's Workers logs and
|
|
67
|
+
* nothing here can read it back, so without this an OOM-killed agent reaches the operator as
|
|
68
|
+
* "container evicted or crashed" and nothing else (finding D1).
|
|
69
|
+
*
|
|
70
|
+
* Recording an exit changes no verdict: the transient/crash classification still comes from
|
|
71
|
+
* the cause alone, so a plain crash keeps spending the crash budget and merely says why.
|
|
72
|
+
*
|
|
73
|
+
* A stop WE asked for is the exception, and records nothing. Its exit state is the echo of our
|
|
74
|
+
* own signal rather than an observation, and the cause that explains it has already been
|
|
75
|
+
* recorded by whoever asked (see {@link selfInitiatedStop}).
|
|
76
|
+
*
|
|
77
|
+
* Both rules live in {@link observationForStop}, beside the other half of "what does this stop
|
|
78
|
+
* mean" (`isRolloutSignal`) and where a plain unit test can reach them: nothing in this class
|
|
79
|
+
* can be exercised without a real Durable Object.
|
|
80
|
+
*/
|
|
81
|
+
onStop(params: StopParams): Promise<void>;
|
|
82
|
+
/**
|
|
83
|
+
* The idle window elapsed. Record it as the reclaim cause it is, then reclaim as the base
|
|
84
|
+
* class would.
|
|
85
|
+
*
|
|
86
|
+
* This container is kept warm ONLY by the driver's job polls, so an idle expiry with a job
|
|
87
|
+
* still outstanding means the backend stopped polling for longer than `sleepAfter`: a
|
|
88
|
+
* poll-scheduling hiccup, not the workload dying. Unrecorded, the resulting 404 poll is
|
|
89
|
+
* indistinguishable from an OOM: it spends the single crash-eviction budget, and a second
|
|
90
|
+
* hiccup in the same step then fails a healthy run (stuck-run audit F12).
|
|
91
|
+
*
|
|
92
|
+
* This hook cannot tell that case from the ROUTINE one (the run parked on a human decision and
|
|
93
|
+
* nothing is running), because only the harness knows whether a job is still live and asking
|
|
94
|
+
* it would itself be activity. So the marker is minted for both, and what keeps that honest is
|
|
95
|
+
* the boundary in {@link fetch}: the routine marker is dropped by the next dispatch, so it
|
|
96
|
+
* cannot still be lying around to excuse the following step's crash.
|
|
97
|
+
*
|
|
98
|
+
* Nothing is recorded when the container is already gone: the base class stops nothing in
|
|
99
|
+
* that case, so a marker would be attributing a reclaim that never happened.
|
|
100
|
+
*/
|
|
101
|
+
onActivityExpired(): Promise<void>;
|
|
102
|
+
/**
|
|
103
|
+
* Proxy an inbound harness call, and take a JOB ACCEPTANCE as the end of whatever this
|
|
104
|
+
* container previously observed about itself.
|
|
105
|
+
*
|
|
106
|
+
* A stop cause explains the death of a container that was serving the jobs outstanding when it
|
|
107
|
+
* was recorded. `POST /jobs` answering 2xx says a new job starts here, so nothing recorded
|
|
108
|
+
* before it can account for that job's death. Without the boundary the routine case poisons
|
|
109
|
+
* the rare one: a run parks on a human decision, its container idles out with nothing running,
|
|
110
|
+
* and the `idle` marker that leaves behind is still inside its (deliberately wide) window to
|
|
111
|
+
* excuse a genuine OOM in the NEXT step as transient churn.
|
|
112
|
+
*
|
|
113
|
+
* It has to be the acceptance rather than the container starting: a 404 poll BOOTS the
|
|
114
|
+
* container on its way to discovering the job is gone, so clearing on start would drop the
|
|
115
|
+
* record moments before the read that exists to consume it.
|
|
116
|
+
*/
|
|
117
|
+
fetch(request: Request): Promise<Response>;
|
|
118
|
+
/**
|
|
119
|
+
* What this run's container observed about its own stop, for the transport to read over RPC
|
|
120
|
+
* after `jobId`'s poll 404s: the `cause` that tells a reclaim apart from a crash, and the
|
|
121
|
+
* `exit` state that is the only surviving account of the death itself. An EMPTY observation
|
|
122
|
+
* means nothing this container saw explains that 404, so the caller reports a bare crash.
|
|
123
|
+
*
|
|
124
|
+
* Claimed by the polling job rather than deleted, so a retried durable poll step re-reads the
|
|
125
|
+
* same answer while a different job still finds it spent. See {@link takeStopCause}.
|
|
126
|
+
*/
|
|
127
|
+
recentStopObservation(jobId: string): Promise<StopObservation>;
|
|
128
|
+
/**
|
|
129
|
+
* Reclaim this container now (SIGKILL via the base class), rather than waiting for the
|
|
130
|
+
* `sleepAfter` idle timer. Called over RPC when a run settles or faults, so a leaked instance
|
|
131
|
+
* isn't billed while idle. Best-effort and idempotent: destroying an already-stopped
|
|
132
|
+
* container is a no-op, and we swallow any error so the caller's failure handling is never
|
|
133
|
+
* derailed by cleanup.
|
|
134
|
+
*
|
|
135
|
+
* It is also where the stop record's life ENDS. A record is written on every stop and deleted
|
|
136
|
+
* only when a NEW job is accepted, so without this the last one a run ever observed sits in
|
|
137
|
+
* that run's Durable Object for good: the run is over, no dispatch is coming to clear it, and
|
|
138
|
+
* the value is a diagnostic nobody will read again. Deleting it here is what keeps the whole
|
|
139
|
+
* mechanism transient rather than a per-run key that accumulates for the lifetime of the
|
|
140
|
+
* deployment.
|
|
141
|
+
*
|
|
142
|
+
* Ordered after the teardown so the destroy's own `onStop` cannot land behind the delete,
|
|
143
|
+
* belt-and-braces with {@link selfInitiatedStop}, which stops that hook writing at all.
|
|
144
|
+
*/
|
|
145
|
+
shutdown(): Promise<void>;
|
|
146
|
+
private record;
|
|
147
|
+
/** The DO storage, narrowed to what the stop-cause bookkeeping uses. */
|
|
148
|
+
private get storage();
|
|
149
|
+
}
|
|
150
|
+
//# sourceMappingURL=RunContainer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RunContainer.d.ts","sourceRoot":"","sources":["../../../src/infrastructure/containers/RunContainer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAA;AAClD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAA;AAExD,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,QAAQ,CAAA;AAEjC,OAAO,EAML,KAAK,eAAe,EAErB,MAAM,aAAa,CAAA;AAEpB;;;;;;;;;;;;;;;GAeG;AACH,8BAAsB,YAAa,SAAQ,SAAS,CAAC,GAAG,CAAC;IACvD,wFAAwF;IAC/E,WAAW,SAAO;IAKlB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAElC;IAMG,UAAU,SAAQ;IAE3B;;;;;;;;;;;;;;;OAeG;IACH,OAAO,CAAC,iBAAiB,CAAQ;IAEjC;;;;;OAKG;IACY,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAGtC;IAED;;;;;;;OAOG;IACY,OAAO,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAIvD;IAED;;;;;;;;;;;;;;;;;;;;;OAqBG;IACY,MAAM,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAGvD;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACY,iBAAiB,IAAI,OAAO,CAAC,IAAI,CAAC,CAShD;IAED;;;;;;;;;;;;;;OAcG;IACY,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAWxD;IAED;;;;;;;;OAQG;IACG,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC,CAEnE;IAED;;;;;;;;;;;;;;;;OAgBG;IACG,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAc9B;IAED,OAAO,CAAC,MAAM;IAId,wEAAwE;IACxE,OAAO,KAAK,OAAO,GAElB;CACF"}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { Container } from '@cloudflare/containers';
|
|
2
|
+
import { runBestEffort } from '@cat-factory/kernel';
|
|
3
|
+
import { logger } from '../observability/logger';
|
|
4
|
+
import { clearStopCause, isRolloutSignal, observationForStop, recordStopCause, takeStopCause, } from './stopCause';
|
|
5
|
+
/**
|
|
6
|
+
* The behaviour every per-run Cloudflare Container shares: one Durable Object instance per run
|
|
7
|
+
* id hosts that run's sequence of jobs, the harness listens on 8080, and the base
|
|
8
|
+
* `Container.fetch` proxies inbound requests there once it has booted.
|
|
9
|
+
*
|
|
10
|
+
* The two concrete classes ({@link import('./ExecutionContainer').ExecutionContainer} and
|
|
11
|
+
* {@link import('./DeployContainer').DeployContainer}) exist only because a Cloudflare
|
|
12
|
+
* Container's IMAGE is pinned per container class by the wrangler `[[containers]]` block. They
|
|
13
|
+
* are two bindings onto one behaviour, which is why that behaviour lives here rather than being
|
|
14
|
+
* kept in step by hand across both.
|
|
15
|
+
*
|
|
16
|
+
* No long-lived secrets are configured: the image carries none, and every per-job credential
|
|
17
|
+
* (the VCS token, the LLM session token, an apiserver token) arrives in the `/jobs` request
|
|
18
|
+
* body at dispatch time. The one exception is `HARNESS_SHARED_SECRET` below, an inbound-auth
|
|
19
|
+
* shared secret rather than a tenant credential.
|
|
20
|
+
*/
|
|
21
|
+
export class RunContainer extends Container {
|
|
22
|
+
/** The harness HTTP server port (matches each image's Dockerfile ENTRYPOINT/EXPOSE). */
|
|
23
|
+
defaultPort = 8080;
|
|
24
|
+
// When configured, hand the inbound-auth shared secret to the harness so it rejects any /jobs
|
|
25
|
+
// call that doesn't present the matching `x-harness-secret` header (which the transport
|
|
26
|
+
// sends). Omitted when unset, leaving the harness open as before.
|
|
27
|
+
envVars = this.env.HARNESS_SHARED_SECRET
|
|
28
|
+
? { HARNESS_SHARED_SECRET: this.env.HARNESS_SHARED_SECRET }
|
|
29
|
+
: {};
|
|
30
|
+
// A job is dispatched, then polled every ~15s while it runs, so the instance stays warm for
|
|
31
|
+
// the job's duration without holding a single request open. Polling is the ONLY thing that
|
|
32
|
+
// keeps it warm, which is why an elapsed window is recorded as a reclaim cause rather than
|
|
33
|
+
// left to read as a crash. See `onActivityExpired`.
|
|
34
|
+
sleepAfter = '10m';
|
|
35
|
+
/**
|
|
36
|
+
* Whether the stop this container is about to observe is one WE asked for: the idle reclaim in
|
|
37
|
+
* {@link onActivityExpired} and the deliberate teardown in {@link shutdown}, both of which stop
|
|
38
|
+
* the container by signalling it.
|
|
39
|
+
*
|
|
40
|
+
* It exists because the resulting exit state is evidence about nothing. We sent the signal, so
|
|
41
|
+
* the code that comes back describes our own request (and escalates to a SIGKILL 137 whenever
|
|
42
|
+
* the harness does not exit inside the platform's grace period), while the account the run
|
|
43
|
+
* actually needs is the cause already recorded beside it. Left recorded, that exit is read back
|
|
44
|
+
* as the container's cause of death and reported as "most often an out-of-memory kill" under a
|
|
45
|
+
* verdict that says the platform reclaimed an idle container.
|
|
46
|
+
*
|
|
47
|
+
* In-memory rather than persisted, and reset by {@link onStart}: it describes ONE stop of ONE
|
|
48
|
+
* container life, and the isolate cannot outlive the stop it is set for. A container that comes
|
|
49
|
+
* back up has a real death ahead of it again.
|
|
50
|
+
*/
|
|
51
|
+
selfInitiatedStop = false;
|
|
52
|
+
/**
|
|
53
|
+
* A container life is starting, so nothing from here on is a stop we asked for.
|
|
54
|
+
*
|
|
55
|
+
* The base class flushes any deferred `onStop` from the PREVIOUS life before it calls this, so
|
|
56
|
+
* the reset can never land between a self-initiated stop and the hook that observes it.
|
|
57
|
+
*/
|
|
58
|
+
async onStart() {
|
|
59
|
+
this.selfInitiatedStop = false;
|
|
60
|
+
await super.onStart();
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Record that THIS run's container was drained by a new-version rollout (a deploy, exit 143)
|
|
64
|
+
* rather than crashing. The transport's next job poll (which 404s once the container restarts
|
|
65
|
+
* empty) reads this back through {@link recentStopObservation}, so the engine
|
|
66
|
+
* recovers it on the larger transient budget instead of failing the run as a crash.
|
|
67
|
+
* Persisted to DO storage (not in-memory) so it survives the isolate reset a combined
|
|
68
|
+
* worker+container deploy causes.
|
|
69
|
+
*/
|
|
70
|
+
async onError(error) {
|
|
71
|
+
if (isRolloutSignal(error))
|
|
72
|
+
await this.record({ cause: 'rollout' });
|
|
73
|
+
// Preserve the base behaviour (log + rethrow) so nothing else changes.
|
|
74
|
+
return super.onError(error);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Record EVERY stop, with whatever this hook can say about it.
|
|
78
|
+
*
|
|
79
|
+
* Two things ride the same write. A `runtime_signal` SIGTERM (143) is a rollout drain, which
|
|
80
|
+
* depending on the runtime version surfaces here instead of (or as well as) through `onError`,
|
|
81
|
+
* so it is recorded as that cause the same way. And the `{ exitCode, reason }` pair itself is
|
|
82
|
+
* recorded for every stop, cause or not, because it is the ONLY account of a death this
|
|
83
|
+
* runtime can keep: a Cloudflare Container's stdout goes to the deployment's Workers logs and
|
|
84
|
+
* nothing here can read it back, so without this an OOM-killed agent reaches the operator as
|
|
85
|
+
* "container evicted or crashed" and nothing else (finding D1).
|
|
86
|
+
*
|
|
87
|
+
* Recording an exit changes no verdict: the transient/crash classification still comes from
|
|
88
|
+
* the cause alone, so a plain crash keeps spending the crash budget and merely says why.
|
|
89
|
+
*
|
|
90
|
+
* A stop WE asked for is the exception, and records nothing. Its exit state is the echo of our
|
|
91
|
+
* own signal rather than an observation, and the cause that explains it has already been
|
|
92
|
+
* recorded by whoever asked (see {@link selfInitiatedStop}).
|
|
93
|
+
*
|
|
94
|
+
* Both rules live in {@link observationForStop}, beside the other half of "what does this stop
|
|
95
|
+
* mean" (`isRolloutSignal`) and where a plain unit test can reach them: nothing in this class
|
|
96
|
+
* can be exercised without a real Durable Object.
|
|
97
|
+
*/
|
|
98
|
+
async onStop(params) {
|
|
99
|
+
const observed = observationForStop(params, this.selfInitiatedStop);
|
|
100
|
+
if (observed)
|
|
101
|
+
await this.record(observed);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The idle window elapsed. Record it as the reclaim cause it is, then reclaim as the base
|
|
105
|
+
* class would.
|
|
106
|
+
*
|
|
107
|
+
* This container is kept warm ONLY by the driver's job polls, so an idle expiry with a job
|
|
108
|
+
* still outstanding means the backend stopped polling for longer than `sleepAfter`: a
|
|
109
|
+
* poll-scheduling hiccup, not the workload dying. Unrecorded, the resulting 404 poll is
|
|
110
|
+
* indistinguishable from an OOM: it spends the single crash-eviction budget, and a second
|
|
111
|
+
* hiccup in the same step then fails a healthy run (stuck-run audit F12).
|
|
112
|
+
*
|
|
113
|
+
* This hook cannot tell that case from the ROUTINE one (the run parked on a human decision and
|
|
114
|
+
* nothing is running), because only the harness knows whether a job is still live and asking
|
|
115
|
+
* it would itself be activity. So the marker is minted for both, and what keeps that honest is
|
|
116
|
+
* the boundary in {@link fetch}: the routine marker is dropped by the next dispatch, so it
|
|
117
|
+
* cannot still be lying around to excuse the following step's crash.
|
|
118
|
+
*
|
|
119
|
+
* Nothing is recorded when the container is already gone: the base class stops nothing in
|
|
120
|
+
* that case, so a marker would be attributing a reclaim that never happened.
|
|
121
|
+
*/
|
|
122
|
+
async onActivityExpired() {
|
|
123
|
+
if (this.ctx.container?.running) {
|
|
124
|
+
await this.record({ cause: 'idle' });
|
|
125
|
+
// The reclaim below is us signalling the container, so the exit it reports is our own
|
|
126
|
+
// request coming back (a SIGKILL 137 whenever the harness does not exit inside the grace
|
|
127
|
+
// period) and not a death to attribute. The cause just recorded is the whole account.
|
|
128
|
+
this.selfInitiatedStop = true;
|
|
129
|
+
}
|
|
130
|
+
await super.onActivityExpired();
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Proxy an inbound harness call, and take a JOB ACCEPTANCE as the end of whatever this
|
|
134
|
+
* container previously observed about itself.
|
|
135
|
+
*
|
|
136
|
+
* A stop cause explains the death of a container that was serving the jobs outstanding when it
|
|
137
|
+
* was recorded. `POST /jobs` answering 2xx says a new job starts here, so nothing recorded
|
|
138
|
+
* before it can account for that job's death. Without the boundary the routine case poisons
|
|
139
|
+
* the rare one: a run parks on a human decision, its container idles out with nothing running,
|
|
140
|
+
* and the `idle` marker that leaves behind is still inside its (deliberately wide) window to
|
|
141
|
+
* excuse a genuine OOM in the NEXT step as transient churn.
|
|
142
|
+
*
|
|
143
|
+
* It has to be the acceptance rather than the container starting: a 404 poll BOOTS the
|
|
144
|
+
* container on its way to discovering the job is gone, so clearing on start would drop the
|
|
145
|
+
* record moments before the read that exists to consume it.
|
|
146
|
+
*/
|
|
147
|
+
async fetch(request) {
|
|
148
|
+
const res = await super.fetch(request);
|
|
149
|
+
if (isJobDispatch(request) && res.ok) {
|
|
150
|
+
// Best-effort: the job is accepted and running by now, so bookkeeping must never turn a
|
|
151
|
+
// live dispatch into a failure. A drop is reported rather than swallowed, because the only
|
|
152
|
+
// symptom otherwise is a crash quietly misread as churn some minutes later.
|
|
153
|
+
await runBestEffort(logger, 'clear container stop cause on dispatch', () => clearStopCause(this.storage));
|
|
154
|
+
}
|
|
155
|
+
return res;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* What this run's container observed about its own stop, for the transport to read over RPC
|
|
159
|
+
* after `jobId`'s poll 404s: the `cause` that tells a reclaim apart from a crash, and the
|
|
160
|
+
* `exit` state that is the only surviving account of the death itself. An EMPTY observation
|
|
161
|
+
* means nothing this container saw explains that 404, so the caller reports a bare crash.
|
|
162
|
+
*
|
|
163
|
+
* Claimed by the polling job rather than deleted, so a retried durable poll step re-reads the
|
|
164
|
+
* same answer while a different job still finds it spent. See {@link takeStopCause}.
|
|
165
|
+
*/
|
|
166
|
+
async recentStopObservation(jobId) {
|
|
167
|
+
return takeStopCause(this.storage, Date.now(), jobId);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Reclaim this container now (SIGKILL via the base class), rather than waiting for the
|
|
171
|
+
* `sleepAfter` idle timer. Called over RPC when a run settles or faults, so a leaked instance
|
|
172
|
+
* isn't billed while idle. Best-effort and idempotent: destroying an already-stopped
|
|
173
|
+
* container is a no-op, and we swallow any error so the caller's failure handling is never
|
|
174
|
+
* derailed by cleanup.
|
|
175
|
+
*
|
|
176
|
+
* It is also where the stop record's life ENDS. A record is written on every stop and deleted
|
|
177
|
+
* only when a NEW job is accepted, so without this the last one a run ever observed sits in
|
|
178
|
+
* that run's Durable Object for good: the run is over, no dispatch is coming to clear it, and
|
|
179
|
+
* the value is a diagnostic nobody will read again. Deleting it here is what keeps the whole
|
|
180
|
+
* mechanism transient rather than a per-run key that accumulates for the lifetime of the
|
|
181
|
+
* deployment.
|
|
182
|
+
*
|
|
183
|
+
* Ordered after the teardown so the destroy's own `onStop` cannot land behind the delete,
|
|
184
|
+
* belt-and-braces with {@link selfInitiatedStop}, which stops that hook writing at all.
|
|
185
|
+
*/
|
|
186
|
+
async shutdown() {
|
|
187
|
+
this.selfInitiatedStop = true;
|
|
188
|
+
try {
|
|
189
|
+
await this.destroy();
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// silent-catch-ok: already gone / not running, so there is nothing to reclaim and nothing
|
|
193
|
+
// for an operator to do about it.
|
|
194
|
+
}
|
|
195
|
+
// Best-effort: the container is reclaimed either way, and a retained key is a tidiness
|
|
196
|
+
// problem, never a correctness one. Reported rather than swallowed so a storage backend
|
|
197
|
+
// failing every delete does not stay invisible.
|
|
198
|
+
await runBestEffort(logger, 'clear container stop cause on shutdown', () => clearStopCause(this.storage));
|
|
199
|
+
}
|
|
200
|
+
record(observed) {
|
|
201
|
+
return recordStopCause(this.storage, observed, Date.now());
|
|
202
|
+
}
|
|
203
|
+
/** The DO storage, narrowed to what the stop-cause bookkeeping uses. */
|
|
204
|
+
get storage() {
|
|
205
|
+
return this.ctx.storage;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Whether an inbound request is the transport starting a job (`POST /jobs`), as opposed to
|
|
210
|
+
* polling or stopping one (`GET`/`DELETE /jobs/{id}`).
|
|
211
|
+
*
|
|
212
|
+
* The harness route is matched here rather than announced over a separate RPC because the
|
|
213
|
+
* dispatch already passes through this object, and an extra round trip would be one more thing
|
|
214
|
+
* that can fail between "job accepted" and "record cleared". The URL is parsed rather than
|
|
215
|
+
* string-compared so a query string or a trailing slash cannot make a dispatch look like
|
|
216
|
+
* something else.
|
|
217
|
+
*/
|
|
218
|
+
function isJobDispatch(request) {
|
|
219
|
+
if (request.method !== 'POST')
|
|
220
|
+
return false;
|
|
221
|
+
try {
|
|
222
|
+
return new URL(request.url).pathname.replace(/\/+$/, '') === '/jobs';
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// silent-catch-ok: an unparseable URL is not a dispatch, which is the whole question here.
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
//# sourceMappingURL=RunContainer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RunContainer.js","sourceRoot":"","sources":["../../../src/infrastructure/containers/RunContainer.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reclaim causes a container can observe about itself.
|
|
3
|
+
*
|
|
4
|
+
* - `rollout`: a deploy drained it (exit 143). Expected churn during a release.
|
|
5
|
+
* - `idle`: its own `sleepAfter` window elapsed with nothing talking to it. The container is
|
|
6
|
+
* kept warm ONLY by the driver's job polls, so this means the backend stopped polling for
|
|
7
|
+
* longer than that window: a poll-scheduling hiccup (a Workflows instance evicted and
|
|
8
|
+
* re-driven by the cron sweeper), not the workload failing. Left unrecorded it reads as a
|
|
9
|
+
* crash, and two hiccups in one step then exhaust the single crash-eviction budget and fail
|
|
10
|
+
* a healthy run (stuck-run audit F12).
|
|
11
|
+
*/
|
|
12
|
+
export type ContainerStopCause = 'rollout' | 'idle';
|
|
13
|
+
/** DO-storage key holding the {@link StopCauseRecord} of the most recent self-observed stop. */
|
|
14
|
+
export declare const STOP_CAUSE_KEY = "containerStopCause";
|
|
15
|
+
/**
|
|
16
|
+
* What the runtime reported when the workload stopped, mirroring the container base class's
|
|
17
|
+
* `StopParams`. Recorded for EVERY stop, including the ones no {@link ContainerStopCause}
|
|
18
|
+
* explains, because it answers a different question: the cause decides the recovery BUDGET, the
|
|
19
|
+
* exit state is the only account of the death anyone gets afterwards.
|
|
20
|
+
*
|
|
21
|
+
* A Cloudflare Container's stdout is delivered to the deployment's Workers logs and is not
|
|
22
|
+
* readable back from inside the Durable Object, so this is the whole of the post-mortem this
|
|
23
|
+
* runtime can mint. It is still the difference between "container evicted or crashed" and "exit
|
|
24
|
+
* code 137", i.e. between a run nobody can diagnose and an out-of-memory kill.
|
|
25
|
+
*/
|
|
26
|
+
export interface ContainerExitState {
|
|
27
|
+
code: number;
|
|
28
|
+
reason: 'exit' | 'runtime_signal';
|
|
29
|
+
}
|
|
30
|
+
/** What the container persists when it observes its own stop. */
|
|
31
|
+
export interface StopCauseRecord {
|
|
32
|
+
/**
|
|
33
|
+
* The infrastructure-churn cause, when the container recognised one. Absent for a stop it
|
|
34
|
+
* cannot explain (a crash, an OOM kill), which is exactly the case {@link exit} exists for.
|
|
35
|
+
*/
|
|
36
|
+
cause?: ContainerStopCause;
|
|
37
|
+
/** What the runtime reported about the stop, when a hook that carries it fired. */
|
|
38
|
+
exit?: ContainerExitState;
|
|
39
|
+
/** Epoch ms the stop was observed. */
|
|
40
|
+
at: number;
|
|
41
|
+
/**
|
|
42
|
+
* The job id this record has already been spent explaining, once one has claimed it.
|
|
43
|
+
*
|
|
44
|
+
* A claim rather than a delete, because the caller runs inside a RETRYING durable step. A
|
|
45
|
+
* destructive read is not replay-safe: a `step.do` that reads the record and then throws (a
|
|
46
|
+
* contended persist, a failed emit) re-runs with the attribution already gone and reports the
|
|
47
|
+
* `crash` this whole mechanism exists to spare. Keyed by the claimant so a REPLAY of the same
|
|
48
|
+
* poll re-reads the same answer, while a different job's poll finds it spent — which is the
|
|
49
|
+
* "one reclaim explains exactly one eviction" rule, now stated in a way a retry cannot break.
|
|
50
|
+
*/
|
|
51
|
+
claimedBy?: string;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* How long after each cause a 404 poll may still be attributed to it.
|
|
55
|
+
*
|
|
56
|
+
* They differ because the two causes are observed at different distances from the poll that
|
|
57
|
+
* finds the container gone. A rollout drain interrupts an IN-FLIGHT poll, so the very next one
|
|
58
|
+
* lands seconds later. An idle reclaim happens precisely because polling stopped, so the poll
|
|
59
|
+
* that discovers it arrives however long the gap outran the idle window: minutes, not seconds.
|
|
60
|
+
* A window sized for the rollout case would therefore read every real idle reclaim as a crash,
|
|
61
|
+
* which is the finding itself.
|
|
62
|
+
*
|
|
63
|
+
* The window is a BACKSTOP, not the primary bound on what a record may excuse. What actually
|
|
64
|
+
* scopes it is the pair of rules around it: {@link clearStopCause} drops the record the moment a
|
|
65
|
+
* new job is accepted, and {@link takeStopCause} lets exactly one job spend it. So the wide
|
|
66
|
+
* `idle` window buys the poll gap it exists for without also handing the next step's crash an
|
|
67
|
+
* alibi.
|
|
68
|
+
*/
|
|
69
|
+
export declare const ATTRIBUTION_WINDOW_MS: {
|
|
70
|
+
rollout: number;
|
|
71
|
+
idle: number;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* How long a stop this container could NOT attribute (a crash, an OOM kill) may still explain a
|
|
75
|
+
* 404 poll, i.e. the window governing a record that carries only an {@link ContainerExitState}.
|
|
76
|
+
*
|
|
77
|
+
* The same reasoning as the `idle` window, and deliberately the same number: what separates the
|
|
78
|
+
* death from the poll that discovers it is the POLL GAP, and a crash is discovered by the same
|
|
79
|
+
* ~15s tick an idle reclaim is, stretched by whatever delayed the driver. Sizing this to the
|
|
80
|
+
* rollout case instead would read every crash discovered after a re-drive as unexplained, which
|
|
81
|
+
* is the class this record exists to explain.
|
|
82
|
+
*
|
|
83
|
+
* Wide is affordable here for a reason the cause windows do not share: an exit state changes no
|
|
84
|
+
* verdict. It is attached to the failure `detail`, so the cost of over-attributing is a
|
|
85
|
+
* misleading sentence, never a wrongly-extended recovery budget.
|
|
86
|
+
*/
|
|
87
|
+
export declare const EXIT_ATTRIBUTION_WINDOW_MS: number;
|
|
88
|
+
/**
|
|
89
|
+
* How far apart two hook calls may be and still be treated as observations of ONE stop, i.e. the
|
|
90
|
+
* bound on {@link recordStopCause}'s merge.
|
|
91
|
+
*
|
|
92
|
+
* The merge exists for a single reason: `onError` and `onStop` are two views of the same death,
|
|
93
|
+
* fired by the runtime within moments of each other. Anything older is a DIFFERENT, earlier
|
|
94
|
+
* stop, and merging onto it is not conservative, it is destructive twice over: the new
|
|
95
|
+
* observation inherits the old record's `at`, so it is born already aged, and both halves then
|
|
96
|
+
* fall out of their own attribution windows and explain nothing. A record that is 40 minutes old
|
|
97
|
+
* would silently swallow the crash that just happened.
|
|
98
|
+
*
|
|
99
|
+
* Sized generously against what it models (two hooks, milliseconds apart) and far below the
|
|
100
|
+
* narrowest attribution window, so it cannot be the thing that decides an attribution: what a
|
|
101
|
+
* merge may do is complete an account of one stop, never date it.
|
|
102
|
+
*/
|
|
103
|
+
export declare const STOP_MERGE_WINDOW_MS = 60000;
|
|
104
|
+
/** What a container observed about its own stop, once attributed to the job that reads it. */
|
|
105
|
+
export interface StopObservation {
|
|
106
|
+
cause?: ContainerStopCause;
|
|
107
|
+
exit?: ContainerExitState;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The message the base container class surfaces when the RUNTIME (not the workload) stopped a
|
|
111
|
+
* container: a deploy draining the old version. Its own exit-code parser is keyed on the plain
|
|
112
|
+
* "runtime signalled the container to exit:" form and does not recognise the rollout wording,
|
|
113
|
+
* which is why that case reaches `onError` at all.
|
|
114
|
+
*/
|
|
115
|
+
export declare function isRolloutSignal(error: unknown): boolean;
|
|
116
|
+
/**
|
|
117
|
+
* What a container should record about a stop the runtime just reported, or undefined when it
|
|
118
|
+
* should record nothing.
|
|
119
|
+
*
|
|
120
|
+
* Two rules, both about what the stop MEANS rather than what it says:
|
|
121
|
+
*
|
|
122
|
+
* - A `runtime_signal` 143 is the rollout drain surfacing through this hook instead of (or as
|
|
123
|
+
* well as) `onError`, depending on the runtime version, so it names the same cause.
|
|
124
|
+
* - A stop the container ASKED for records nothing at all. Its idle reclaim and its shutdown RPC
|
|
125
|
+
* both work by signalling the container, so the exit that comes back is this container's own
|
|
126
|
+
* request echoed at it (escalating to a SIGKILL 137 when the workload does not exit inside the
|
|
127
|
+
* grace period), not an observation of how anything died. Recorded anyway, it is read back as
|
|
128
|
+
* the container's cause of death and reported to the operator as "most often an out-of-memory
|
|
129
|
+
* kill" underneath a verdict that says the platform reclaimed an idle container. The cause the
|
|
130
|
+
* caller recorded when it decided to stop is the whole account, and it is already stored.
|
|
131
|
+
*/
|
|
132
|
+
export declare function observationForStop(params: {
|
|
133
|
+
exitCode: number;
|
|
134
|
+
reason: 'exit' | 'runtime_signal';
|
|
135
|
+
}, selfInitiated: boolean): StopObservation | undefined;
|
|
136
|
+
/**
|
|
137
|
+
* What a stored record still explains at `now`, or an EMPTY observation when it explains
|
|
138
|
+
* nothing.
|
|
139
|
+
*
|
|
140
|
+
* An empty observation covers three things that all mean the same to the caller: nothing was
|
|
141
|
+
* recorded, the record outlived its window (the container recovered and ran on, so a later death
|
|
142
|
+
* is its own), or the record names a cause this build no longer has. The last is possible
|
|
143
|
+
* because the record is PERSISTED and the vocabulary is closed: a deploy that retires a member
|
|
144
|
+
* leaves rows behind. All three land on the caller's existing default, reporting the eviction
|
|
145
|
+
* as a `crash`, which is the honest reading of "no attribution" and the conservative one:
|
|
146
|
+
* it costs a run one restart of its recovery budget, never a wrongly-extended one.
|
|
147
|
+
*
|
|
148
|
+
* The two halves are attributed INDEPENDENTLY, on their own windows: a record can be too old to
|
|
149
|
+
* excuse the eviction as churn while still being the only account of how the container died, and
|
|
150
|
+
* dropping the exit state with the cause would throw away the diagnostic to protect a budget the
|
|
151
|
+
* diagnostic never touches.
|
|
152
|
+
*/
|
|
153
|
+
export declare function attributeStopCause(record: unknown, now: number, claimant: string): StopObservation;
|
|
154
|
+
/**
|
|
155
|
+
* The slice of DO storage the bookkeeping below needs. Narrowed to three methods so the rules
|
|
156
|
+
* can be exercised against a plain fake. The alternative is asserting them through a real
|
|
157
|
+
* Durable Object, which means reaching into the container base class's private fields.
|
|
158
|
+
*/
|
|
159
|
+
export interface StopCauseStorage {
|
|
160
|
+
get: (key: string) => Promise<unknown>;
|
|
161
|
+
put: (key: string, value: StopCauseRecord) => Promise<void>;
|
|
162
|
+
delete: (key: string) => Promise<unknown>;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Persist what this container just observed about its own stop, MERGING onto an unclaimed record
|
|
166
|
+
* of the SAME stop rather than replacing it.
|
|
167
|
+
*
|
|
168
|
+
* Merging is the point. One stop reaches this container through up to two hooks carrying
|
|
169
|
+
* different halves of the answer: `onError` recognises a rollout drain and knows no exit state,
|
|
170
|
+
* `onStop` carries `{ exitCode, reason }` and cannot name the churn. They fire in either order
|
|
171
|
+
* (and, on some runtime versions, both), so a plain overwrite means whichever landed second
|
|
172
|
+
* silently discarded the other's half: either the recovery budget or the only account of the
|
|
173
|
+
* death, depending on the ordering that day.
|
|
174
|
+
*
|
|
175
|
+
* Two records are never merged:
|
|
176
|
+
*
|
|
177
|
+
* - a CLAIMED one, which has already explained somebody's eviction, so anything observed after
|
|
178
|
+
* it belongs to the next one;
|
|
179
|
+
* - one older than {@link STOP_MERGE_WINDOW_MS}, which is a different stop entirely. This
|
|
180
|
+
* container's records are not reliably cleared between stops (`takeStopCause` deliberately
|
|
181
|
+
* leaves an expired record in place, and only an accepted dispatch deletes one), so a stale
|
|
182
|
+
* record is the ORDINARY state of a container that idled out and was re-driven, not an edge
|
|
183
|
+
* case. Merging onto it would back-date the new observation to the old stop's `at` and age it
|
|
184
|
+
* out of its own attribution window on arrival: the crash that just happened would be
|
|
185
|
+
* recorded, read back, and dropped as too old to explain the eviction it caused.
|
|
186
|
+
*
|
|
187
|
+
* The kept `at` is the EARLIEST of the two observations, which within the merge window is a
|
|
188
|
+
* choice between timestamps moments apart: both describe one stop, and the window measures how
|
|
189
|
+
* long ago that stop happened.
|
|
190
|
+
*/
|
|
191
|
+
export declare function recordStopCause(storage: StopCauseStorage, observed: StopObservation, now: number): Promise<void>;
|
|
192
|
+
/**
|
|
193
|
+
* The one-line account of a container's stop for the eviction `detail`, or undefined when
|
|
194
|
+
* nothing was recorded.
|
|
195
|
+
*
|
|
196
|
+
* `cause` is the account the run has ALREADY been given, and passing it is what keeps the detail
|
|
197
|
+
* from arguing with the verdict beside it. The two halves are recorded independently and the
|
|
198
|
+
* exit state is attached whether or not a cause was recognised, so they routinely describe one
|
|
199
|
+
* event twice: an idle reclaim is performed BY the platform with a SIGKILL, so a run correctly
|
|
200
|
+
* reported as "idle container reclaimed between polls" carries exit 137, and read as an
|
|
201
|
+
* unexplained death that code says "most often an out-of-memory kill". The operator is then
|
|
202
|
+
* holding a verdict and a detail that cannot both be true, which is worse than either alone,
|
|
203
|
+
* because there is no way to tell which one to act on.
|
|
204
|
+
*
|
|
205
|
+
* So the interpretation is offered only where it is the only account there is. Where a cause was
|
|
206
|
+
* named, the exit code is reported as the MECHANICS of that stop: still the difference between
|
|
207
|
+
* "reclaimed" and "reclaimed with a SIGKILL", and never a second, competing cause of death.
|
|
208
|
+
*
|
|
209
|
+
* Either way it states the platform's own limit rather than leaving it to be inferred: a
|
|
210
|
+
* Cloudflare Container writes its stdout to the deployment's Workers logs, and nothing inside
|
|
211
|
+
* the Durable Object can read it back, so an operator who reads this and goes looking for a log
|
|
212
|
+
* tail here should be told where the tail actually is.
|
|
213
|
+
*/
|
|
214
|
+
export declare function describeContainerExit(exit: ContainerExitState | undefined, cause?: ContainerStopCause): string | undefined;
|
|
215
|
+
/**
|
|
216
|
+
* Forget whatever this container observed before now.
|
|
217
|
+
*
|
|
218
|
+
* Called when a NEW job is accepted, which is the moment the record stops being able to explain
|
|
219
|
+
* anything: a stop cause accounts for the death of a container that was serving the jobs
|
|
220
|
+
* outstanding when it was observed, and a job dispatched afterwards is not one of them. Without
|
|
221
|
+
* this the common benign case poisons the rare dangerous one — a run parks on a human decision,
|
|
222
|
+
* its container idles out with nothing running, and the `idle` marker that leaves behind is
|
|
223
|
+
* still there half an hour later to excuse a genuine OOM in the NEXT step as transient churn.
|
|
224
|
+
*/
|
|
225
|
+
export declare function clearStopCause(storage: StopCauseStorage): Promise<void>;
|
|
226
|
+
/**
|
|
227
|
+
* Read what explains `claimant`'s 404 poll, CLAIMING it for that job.
|
|
228
|
+
*
|
|
229
|
+
* One stop explains exactly one job's eviction: the engine answers one by re-dispatching onto
|
|
230
|
+
* a fresh container under the same DO id, so an unclaimed record would still be sitting there to
|
|
231
|
+
* excuse the next death, whenever and for whatever reason it came. The claim is written rather
|
|
232
|
+
* than the record deleted so a REPLAYED poll for the same job reads back the same answer (see
|
|
233
|
+
* {@link StopCauseRecord.claimedBy}).
|
|
234
|
+
*
|
|
235
|
+
* The claim covers the record, not one half of it: an exit state read here is spent as surely as
|
|
236
|
+
* a cause is, because attaching one container's death to two runs' failures is the same lie
|
|
237
|
+
* either way.
|
|
238
|
+
*/
|
|
239
|
+
export declare function takeStopCause(storage: StopCauseStorage, now: number, claimant: string): Promise<StopObservation>;
|
|
240
|
+
//# sourceMappingURL=stopCause.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stopCause.d.ts","sourceRoot":"","sources":["../../../src/infrastructure/containers/stopCause.ts"],"names":[],"mappings":"AAOA;;;;;;;;;;GAUG;AACH,MAAM,MAAM,kBAAkB,GAAG,SAAS,GAAG,MAAM,CAAA;AAEnD,gGAAgG;AAChG,eAAO,MAAM,cAAc,uBAAuB,CAAA;AAElD;;;;;;;;;;GAUG;AACH,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,MAAM,GAAG,gBAAgB,CAAA;CAClC;AAED,iEAAiE;AACjE,MAAM,WAAW,eAAe;IAC9B;;;OAGG;IACH,KAAK,CAAC,EAAE,kBAAkB,CAAA;IAC1B,mFAAmF;IACnF,IAAI,CAAC,EAAE,kBAAkB,CAAA;IACzB,sCAAsC;IACtC,EAAE,EAAE,MAAM,CAAA;IACV;;;;;;;;;OASG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,qBAAqB;;;CAGY,CAAA;AAE9C;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,0BAA0B,QAAc,CAAA;AAErD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,oBAAoB,QAAS,CAAA;AAE1C,8FAA8F;AAC9F,MAAM,WAAW,eAAe;IAC9B,KAAK,CAAC,EAAE,kBAAkB,CAAA;IAC1B,IAAI,CAAC,EAAE,kBAAkB,CAAA;CAC1B;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAIvD;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,GAAG,gBAAgB,CAAA;CAAE,EAC/D,aAAa,EAAE,OAAO,GACrB,eAAe,GAAG,SAAS,CAO7B;AAwBD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,OAAO,EACf,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,GACf,eAAe,CAYjB;AAOD;;;;GAIG;AACH,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;IACtC,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,eAAe,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IAC3D,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;CAC1C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,gBAAgB,EACzB,QAAQ,EAAE,eAAe,EACzB,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,IAAI,CAAC,CAUf;AAmDD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,kBAAkB,GAAG,SAAS,EACpC,KAAK,CAAC,EAAE,kBAAkB,GACzB,MAAM,GAAG,SAAS,CAgBpB;AAED;;;;;;;;;GASG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAE7E;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,aAAa,CACjC,OAAO,EAAE,gBAAgB,EACzB,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,eAAe,CAAC,CAS1B"}
|