@smthrs/triggers 0.0.0-stage → 1.0.0-rc.4
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/CHANGELOG.md +219 -0
- package/LICENSE +21 -0
- package/README.md +109 -2
- package/dist/cjs/CatchUp.d.ts +25 -0
- package/dist/cjs/CatchUp.d.ts.map +1 -0
- package/dist/cjs/CatchUp.js +72 -0
- package/dist/cjs/CatchUp.js.map +7 -0
- package/dist/cjs/Channel.d.ts +108 -0
- package/dist/cjs/Channel.d.ts.map +1 -0
- package/dist/cjs/Channel.js +25 -0
- package/dist/cjs/Channel.js.map +7 -0
- package/dist/cjs/ClaimDecision.d.ts +183 -0
- package/dist/cjs/ClaimDecision.d.ts.map +1 -0
- package/dist/cjs/ClaimDecision.js +144 -0
- package/dist/cjs/ClaimDecision.js.map +7 -0
- package/dist/cjs/Cron.d.ts +91 -0
- package/dist/cjs/Cron.d.ts.map +1 -0
- package/dist/cjs/Cron.js +108 -0
- package/dist/cjs/Cron.js.map +7 -0
- package/dist/cjs/DispatchReader.d.ts +77 -0
- package/dist/cjs/DispatchReader.d.ts.map +1 -0
- package/dist/cjs/DispatchReader.js +121 -0
- package/dist/cjs/DispatchReader.js.map +7 -0
- package/dist/cjs/Overlap.d.ts +40 -0
- package/dist/cjs/Overlap.d.ts.map +1 -0
- package/dist/cjs/Overlap.js +37 -0
- package/dist/cjs/Overlap.js.map +7 -0
- package/dist/cjs/Schedule.d.ts +96 -0
- package/dist/cjs/Schedule.d.ts.map +1 -0
- package/dist/cjs/Schedule.js +61 -0
- package/dist/cjs/Schedule.js.map +7 -0
- package/dist/cjs/Scheduler.d.ts +248 -0
- package/dist/cjs/Scheduler.d.ts.map +1 -0
- package/dist/cjs/Scheduler.js +647 -0
- package/dist/cjs/Scheduler.js.map +7 -0
- package/dist/cjs/SqlTriggerStore.d.ts +29 -0
- package/dist/cjs/SqlTriggerStore.d.ts.map +1 -0
- package/dist/cjs/SqlTriggerStore.js +483 -0
- package/dist/cjs/SqlTriggerStore.js.map +7 -0
- package/dist/cjs/Trigger.d.ts +81 -0
- package/dist/cjs/Trigger.d.ts.map +1 -0
- package/dist/cjs/Trigger.js +54 -0
- package/dist/cjs/Trigger.js.map +7 -0
- package/dist/cjs/TriggerError.d.ts +58 -0
- package/dist/cjs/TriggerError.d.ts.map +1 -0
- package/dist/cjs/TriggerError.js +83 -0
- package/dist/cjs/TriggerError.js.map +7 -0
- package/dist/cjs/TriggerStore.d.ts +411 -0
- package/dist/cjs/TriggerStore.d.ts.map +1 -0
- package/dist/cjs/TriggerStore.js +127 -0
- package/dist/cjs/TriggerStore.js.map +7 -0
- package/dist/cjs/Webhook.d.ts +118 -0
- package/dist/cjs/Webhook.d.ts.map +1 -0
- package/dist/cjs/Webhook.js +153 -0
- package/dist/cjs/Webhook.js.map +7 -0
- package/dist/cjs/index.d.ts +30 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +57 -0
- package/dist/cjs/index.js.map +7 -0
- package/dist/cjs/internal/ActiveRuns.d.ts +51 -0
- package/dist/cjs/internal/ActiveRuns.d.ts.map +1 -0
- package/dist/cjs/internal/ActiveRuns.js +56 -0
- package/dist/cjs/internal/ActiveRuns.js.map +7 -0
- package/dist/cjs/internal/DueOccurrences.d.ts +32 -0
- package/dist/cjs/internal/DueOccurrences.d.ts.map +1 -0
- package/dist/cjs/internal/DueOccurrences.js +80 -0
- package/dist/cjs/internal/DueOccurrences.js.map +7 -0
- package/dist/cjs/internal/WallClock.d.ts +59 -0
- package/dist/cjs/internal/WallClock.d.ts.map +1 -0
- package/dist/cjs/internal/WallClock.js +132 -0
- package/dist/cjs/internal/WallClock.js.map +7 -0
- package/dist/cjs/migrations/0001_triggers.d.ts +15 -0
- package/dist/cjs/migrations/0001_triggers.d.ts.map +1 -0
- package/dist/cjs/migrations/0001_triggers.js +64 -0
- package/dist/cjs/migrations/0001_triggers.js.map +7 -0
- package/dist/cjs/migrations/0002_reservation_lease.d.ts +15 -0
- package/dist/cjs/migrations/0002_reservation_lease.d.ts.map +1 -0
- package/dist/cjs/migrations/0002_reservation_lease.js +41 -0
- package/dist/cjs/migrations/0002_reservation_lease.js.map +7 -0
- package/dist/cjs/migrations/0003_heartbeat.d.ts +16 -0
- package/dist/cjs/migrations/0003_heartbeat.d.ts.map +1 -0
- package/dist/cjs/migrations/0003_heartbeat.js +44 -0
- package/dist/cjs/migrations/0003_heartbeat.js.map +7 -0
- package/dist/cjs/migrations/0004_fire_run_index.d.ts +17 -0
- package/dist/cjs/migrations/0004_fire_run_index.d.ts.map +1 -0
- package/dist/cjs/migrations/0004_fire_run_index.js +40 -0
- package/dist/cjs/migrations/0004_fire_run_index.js.map +7 -0
- package/dist/cjs/migrations/index.d.ts +33 -0
- package/dist/cjs/migrations/index.d.ts.map +1 -0
- package/dist/cjs/migrations/index.js +50 -0
- package/dist/cjs/migrations/index.js.map +7 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/test/TestTriggers.d.ts +12 -0
- package/dist/cjs/test/TestTriggers.d.ts.map +1 -0
- package/dist/cjs/test/TestTriggers.js +421 -0
- package/dist/cjs/test/TestTriggers.js.map +7 -0
- package/dist/esm/CatchUp.d.ts +25 -0
- package/dist/esm/CatchUp.d.ts.map +1 -0
- package/dist/esm/CatchUp.js +52 -0
- package/dist/esm/CatchUp.js.map +1 -0
- package/dist/esm/Channel.d.ts +108 -0
- package/dist/esm/Channel.d.ts.map +1 -0
- package/dist/esm/Channel.js +19 -0
- package/dist/esm/Channel.js.map +1 -0
- package/dist/esm/ClaimDecision.d.ts +183 -0
- package/dist/esm/ClaimDecision.d.ts.map +1 -0
- package/dist/esm/ClaimDecision.js +172 -0
- package/dist/esm/ClaimDecision.js.map +1 -0
- package/dist/esm/Cron.d.ts +91 -0
- package/dist/esm/Cron.d.ts.map +1 -0
- package/dist/esm/Cron.js +132 -0
- package/dist/esm/Cron.js.map +1 -0
- package/dist/esm/DispatchReader.d.ts +77 -0
- package/dist/esm/DispatchReader.d.ts.map +1 -0
- package/dist/esm/DispatchReader.js +152 -0
- package/dist/esm/DispatchReader.js.map +1 -0
- package/dist/esm/Overlap.d.ts +40 -0
- package/dist/esm/Overlap.d.ts.map +1 -0
- package/dist/esm/Overlap.js +31 -0
- package/dist/esm/Overlap.js.map +1 -0
- package/dist/esm/Schedule.d.ts +96 -0
- package/dist/esm/Schedule.d.ts.map +1 -0
- package/dist/esm/Schedule.js +73 -0
- package/dist/esm/Schedule.js.map +1 -0
- package/dist/esm/Scheduler.d.ts +248 -0
- package/dist/esm/Scheduler.d.ts.map +1 -0
- package/dist/esm/Scheduler.js +734 -0
- package/dist/esm/Scheduler.js.map +1 -0
- package/dist/esm/SqlTriggerStore.d.ts +29 -0
- package/dist/esm/SqlTriggerStore.d.ts.map +1 -0
- package/dist/esm/SqlTriggerStore.js +483 -0
- package/dist/esm/SqlTriggerStore.js.map +1 -0
- package/dist/esm/Trigger.d.ts +81 -0
- package/dist/esm/Trigger.d.ts.map +1 -0
- package/dist/esm/Trigger.js +56 -0
- package/dist/esm/Trigger.js.map +1 -0
- package/dist/esm/TriggerError.d.ts +58 -0
- package/dist/esm/TriggerError.d.ts.map +1 -0
- package/dist/esm/TriggerError.js +110 -0
- package/dist/esm/TriggerError.js.map +1 -0
- package/dist/esm/TriggerStore.d.ts +411 -0
- package/dist/esm/TriggerStore.d.ts.map +1 -0
- package/dist/esm/TriggerStore.js +209 -0
- package/dist/esm/TriggerStore.js.map +1 -0
- package/dist/esm/Webhook.d.ts +118 -0
- package/dist/esm/Webhook.d.ts.map +1 -0
- package/dist/esm/Webhook.js +186 -0
- package/dist/esm/Webhook.js.map +1 -0
- package/dist/esm/index.d.ts +30 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +30 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/ActiveRuns.d.ts +51 -0
- package/dist/esm/internal/ActiveRuns.d.ts.map +1 -0
- package/dist/esm/internal/ActiveRuns.js +34 -0
- package/dist/esm/internal/ActiveRuns.js.map +1 -0
- package/dist/esm/internal/DueOccurrences.d.ts +32 -0
- package/dist/esm/internal/DueOccurrences.d.ts.map +1 -0
- package/dist/esm/internal/DueOccurrences.js +55 -0
- package/dist/esm/internal/DueOccurrences.js.map +1 -0
- package/dist/esm/internal/WallClock.d.ts +59 -0
- package/dist/esm/internal/WallClock.d.ts.map +1 -0
- package/dist/esm/internal/WallClock.js +155 -0
- package/dist/esm/internal/WallClock.js.map +1 -0
- package/dist/esm/migrations/0001_triggers.d.ts +15 -0
- package/dist/esm/migrations/0001_triggers.d.ts.map +1 -0
- package/dist/esm/migrations/0001_triggers.js +42 -0
- package/dist/esm/migrations/0001_triggers.js.map +1 -0
- package/dist/esm/migrations/0002_reservation_lease.d.ts +15 -0
- package/dist/esm/migrations/0002_reservation_lease.d.ts.map +1 -0
- package/dist/esm/migrations/0002_reservation_lease.js +19 -0
- package/dist/esm/migrations/0002_reservation_lease.js.map +1 -0
- package/dist/esm/migrations/0003_heartbeat.d.ts +16 -0
- package/dist/esm/migrations/0003_heartbeat.d.ts.map +1 -0
- package/dist/esm/migrations/0003_heartbeat.js +23 -0
- package/dist/esm/migrations/0003_heartbeat.js.map +1 -0
- package/dist/esm/migrations/0004_fire_run_index.d.ts +17 -0
- package/dist/esm/migrations/0004_fire_run_index.d.ts.map +1 -0
- package/dist/esm/migrations/0004_fire_run_index.js +20 -0
- package/dist/esm/migrations/0004_fire_run_index.js.map +1 -0
- package/dist/esm/migrations/index.d.ts +33 -0
- package/dist/esm/migrations/index.d.ts.map +1 -0
- package/dist/esm/migrations/index.js +37 -0
- package/dist/esm/migrations/index.js.map +1 -0
- package/dist/esm/test/TestTriggers.d.ts +12 -0
- package/dist/esm/test/TestTriggers.d.ts.map +1 -0
- package/dist/esm/test/TestTriggers.js +439 -0
- package/dist/esm/test/TestTriggers.js.map +1 -0
- package/docs/README.md +140 -0
- package/docs/api.md +1186 -0
- package/docs/concepts/channels.md +103 -0
- package/docs/concepts/claim-protocol.md +229 -0
- package/docs/concepts/policies.md +124 -0
- package/docs/concepts/schedules.md +134 -0
- package/docs/guides/choose-a-policy.md +113 -0
- package/docs/guides/declare-a-trigger.md +136 -0
- package/docs/guides/ingest-a-webhook.md +164 -0
- package/docs/guides/run-the-scheduler.md +173 -0
- package/docs/guides/testing.md +157 -0
- package/docs/installation.md +81 -0
- package/docs/quickstart.md +163 -0
- package/docs/troubleshooting.md +281 -0
- package/package.json +241 -3
- package/src/CatchUp.ts +73 -0
- package/src/Channel.ts +121 -0
- package/src/ClaimDecision.ts +303 -0
- package/src/Cron.ts +173 -0
- package/src/DispatchReader.ts +179 -0
- package/src/Overlap.ts +53 -0
- package/src/Schedule.ts +112 -0
- package/src/Scheduler.ts +1154 -0
- package/src/SqlTriggerStore.ts +638 -0
- package/src/Trigger.ts +84 -0
- package/src/TriggerError.ts +129 -0
- package/src/TriggerStore.ts +514 -0
- package/src/Webhook.ts +270 -0
- package/src/index.ts +30 -0
- package/src/internal/ActiveRuns.ts +83 -0
- package/src/internal/DueOccurrences.ts +101 -0
- package/src/internal/WallClock.ts +172 -0
- package/src/migrations/0001_triggers.ts +43 -0
- package/src/migrations/0002_reservation_lease.ts +20 -0
- package/src/migrations/0003_heartbeat.ts +24 -0
- package/src/migrations/0004_fire_run_index.ts +21 -0
- package/src/migrations/index.ts +40 -0
- package/src/test/TestTriggers.ts +563 -0
package/src/Scheduler.ts
ADDED
|
@@ -0,0 +1,1154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable, Clock-driven trigger scheduling.
|
|
3
|
+
*
|
|
4
|
+
* @see packages/smithers/agent/triggers/docs/api.md
|
|
5
|
+
*
|
|
6
|
+
* @since 0.1.0
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as Control from "@smthrs/control/Control"
|
|
10
|
+
import type { PlanCard, Receipt, RunStatus } from "@smthrs/control/ControlSchema"
|
|
11
|
+
import * as Cause from "effect/Cause"
|
|
12
|
+
import * as Clock from "effect/Clock"
|
|
13
|
+
import * as Context from "effect/Context"
|
|
14
|
+
import * as Duration from "effect/Duration"
|
|
15
|
+
import * as Effect from "effect/Effect"
|
|
16
|
+
import * as Fiber from "effect/Fiber"
|
|
17
|
+
import * as Layer from "effect/Layer"
|
|
18
|
+
import * as Metric from "effect/Metric"
|
|
19
|
+
import * as Option from "effect/Option"
|
|
20
|
+
import * as Ref from "effect/Ref"
|
|
21
|
+
import * as Result from "effect/Result"
|
|
22
|
+
import type * as Scope from "effect/Scope"
|
|
23
|
+
import * as Semaphore from "effect/Semaphore"
|
|
24
|
+
import * as ActiveRuns from "./internal/ActiveRuns.ts"
|
|
25
|
+
import * as DueOccurrences from "./internal/DueOccurrences.ts"
|
|
26
|
+
import { TriggerError } from "./TriggerError.ts"
|
|
27
|
+
import {
|
|
28
|
+
type Claim,
|
|
29
|
+
type Held,
|
|
30
|
+
isReservation,
|
|
31
|
+
type Registered,
|
|
32
|
+
reservationOccurrence,
|
|
33
|
+
TriggerStore
|
|
34
|
+
} from "./TriggerStore.ts"
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Arguments used to launch one scheduled flow.
|
|
38
|
+
*
|
|
39
|
+
* @category models
|
|
40
|
+
* @since 0.1.0
|
|
41
|
+
*/
|
|
42
|
+
export interface StartInput {
|
|
43
|
+
readonly flowId: string
|
|
44
|
+
readonly input: unknown
|
|
45
|
+
readonly idempotencyKey: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What the runtime says about one scheduled run.
|
|
50
|
+
*
|
|
51
|
+
* `active` keeps the monitor polling. `completed` is the only state recorded as
|
|
52
|
+
* a completed occurrence; `failed`, `cancelled`, and `missing` (the runtime has
|
|
53
|
+
* no record of the run) are recorded as `failed` with the state in the error.
|
|
54
|
+
*
|
|
55
|
+
* @category models
|
|
56
|
+
* @since 1.0.0-rc.1
|
|
57
|
+
*/
|
|
58
|
+
export type RunState = "active" | "completed" | "failed" | "cancelled" | "missing"
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Runtime operations required by the scheduler.
|
|
62
|
+
*
|
|
63
|
+
* @category models
|
|
64
|
+
* @since 0.1.0
|
|
65
|
+
*/
|
|
66
|
+
export interface RunnerService {
|
|
67
|
+
readonly start: (input: StartInput) => Effect.Effect<string, TriggerError>
|
|
68
|
+
readonly inspect: (runId: string) => Effect.Effect<RunState, TriggerError>
|
|
69
|
+
readonly cancel: (runId: string) => Effect.Effect<void, TriggerError>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Injectable scheduled-run launcher.
|
|
74
|
+
*
|
|
75
|
+
* Constructed with {@link makeRunner}, {@link makeNoopRunner}, or
|
|
76
|
+
* {@link layerNoopRunner}; the tag itself carries no constructors, so there is
|
|
77
|
+
* one spelling of each and the module's own `make`/`makeNoop` can only mean
|
|
78
|
+
* the scheduler.
|
|
79
|
+
*
|
|
80
|
+
* @category services
|
|
81
|
+
* @since 0.1.0
|
|
82
|
+
*/
|
|
83
|
+
export class Runner extends Context.Service<Runner, RunnerService>()("flows/triggers/Scheduler/Runner") {}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Constructs a scheduled-run launcher.
|
|
87
|
+
*
|
|
88
|
+
* @category constructors
|
|
89
|
+
* @since 0.1.0
|
|
90
|
+
*/
|
|
91
|
+
export const makeRunner = (implementation: RunnerService): RunnerService => Runner.of(implementation)
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Constructs a launcher that returns the idempotency key as a terminal run.
|
|
95
|
+
*
|
|
96
|
+
* @category constructors
|
|
97
|
+
* @since 0.1.0
|
|
98
|
+
*/
|
|
99
|
+
export const makeNoopRunner = (overrides: Partial<RunnerService> = {}): RunnerService =>
|
|
100
|
+
makeRunner({
|
|
101
|
+
start: (input) => Effect.succeed(input.idempotencyKey),
|
|
102
|
+
inspect: () => Effect.succeed("completed"),
|
|
103
|
+
cancel: () => Effect.void,
|
|
104
|
+
...overrides
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Provides the terminal no-op launcher.
|
|
109
|
+
*
|
|
110
|
+
* @category layers
|
|
111
|
+
* @since 0.1.0
|
|
112
|
+
*/
|
|
113
|
+
export const layerNoopRunner = (
|
|
114
|
+
overrides: Partial<RunnerService> = {}
|
|
115
|
+
): Layer.Layer<Runner> => Layer.succeed(Runner)(makeNoopRunner(overrides))
|
|
116
|
+
|
|
117
|
+
const runnerError = (message: string, cause?: unknown): TriggerError =>
|
|
118
|
+
new TriggerError({
|
|
119
|
+
code: "runner",
|
|
120
|
+
message,
|
|
121
|
+
...(cause === undefined ? {} : { cause })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const translateRunnerFailure = <A, E, R>(
|
|
125
|
+
effect: Effect.Effect<A, E, R>,
|
|
126
|
+
message: string
|
|
127
|
+
): Effect.Effect<A, TriggerError, R> =>
|
|
128
|
+
effect.pipe(
|
|
129
|
+
Effect.mapError((error) => error instanceof TriggerError ? error : runnerError(message, error)),
|
|
130
|
+
Effect.catchDefect((defect) => Effect.fail(runnerError(message, defect)))
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
const invalidOption = (field: string, requirement: string): TriggerError =>
|
|
134
|
+
new TriggerError({
|
|
135
|
+
code: "invalid_options",
|
|
136
|
+
message: `${field} must be ${requirement}`,
|
|
137
|
+
path: field
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
const duration = (
|
|
141
|
+
input: Duration.Input,
|
|
142
|
+
field: string
|
|
143
|
+
): Effect.Effect<Duration.Duration, TriggerError> =>
|
|
144
|
+
Option.match(Duration.fromInput(input), {
|
|
145
|
+
onNone: () => Effect.fail(invalidOption(field, "a valid Effect duration")),
|
|
146
|
+
onSome: (value) =>
|
|
147
|
+
// Zero polls a CPU-tight loop and an infinite interval never completes,
|
|
148
|
+
// and `Duration.fromInput` accepts both.
|
|
149
|
+
Duration.isFinite(value) && Duration.toMillis(value) > 0
|
|
150
|
+
? Effect.succeed(value)
|
|
151
|
+
: Effect.fail(invalidOption(field, "a finite positive duration"))
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
const stoppedMessage = (runId: string, state: Exclude<RunState, "active" | "completed">): string => {
|
|
155
|
+
switch (state) {
|
|
156
|
+
case "failed":
|
|
157
|
+
return `Run ${runId} failed`
|
|
158
|
+
case "cancelled":
|
|
159
|
+
return `Run ${runId} was cancelled`
|
|
160
|
+
case "missing":
|
|
161
|
+
return `Run ${runId} is unknown to the runner`
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const runIdFromReceipt = (
|
|
166
|
+
receipt: Exclude<Receipt, { readonly _tag: "Parked" }>
|
|
167
|
+
): Effect.Effect<string, TriggerError> => {
|
|
168
|
+
switch (receipt._tag) {
|
|
169
|
+
case "Accepted":
|
|
170
|
+
case "AlreadyApplied":
|
|
171
|
+
return receipt.runId === undefined
|
|
172
|
+
? Effect.fail(runnerError(`Control ${receipt._tag} receipt did not include a run id`))
|
|
173
|
+
: Effect.succeed(receipt.runId)
|
|
174
|
+
case "Terminal":
|
|
175
|
+
return Effect.succeed(receipt.runId)
|
|
176
|
+
case "Conflict":
|
|
177
|
+
return Effect.fail(runnerError(`Control rejected the scheduled run: ${receipt.message}`))
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* How many times a parked plan is re-offered before the launch is abandoned.
|
|
183
|
+
*
|
|
184
|
+
* The delay doubles from one second, so the eighth attempt lands a little over
|
|
185
|
+
* two minutes in. A plan nobody approves used to be re-offered once a second
|
|
186
|
+
* for the life of the scope while the launch reservation behind it expired.
|
|
187
|
+
*
|
|
188
|
+
* @category constants
|
|
189
|
+
* @since 0.1.0
|
|
190
|
+
*/
|
|
191
|
+
export const parkedAttempts = 8
|
|
192
|
+
|
|
193
|
+
const runApprovedPlan = (
|
|
194
|
+
control: Control.Service,
|
|
195
|
+
plan: PlanCard,
|
|
196
|
+
key: string,
|
|
197
|
+
attempt: number
|
|
198
|
+
): Effect.Effect<string, TriggerError> =>
|
|
199
|
+
control.run({
|
|
200
|
+
_tag: "Plan",
|
|
201
|
+
planId: plan.planId,
|
|
202
|
+
digest: plan.digest,
|
|
203
|
+
envelope: plan.envelope,
|
|
204
|
+
idempotencyKey: key
|
|
205
|
+
}).pipe(
|
|
206
|
+
Effect.mapError((error) => runnerError("Control could not launch the scheduled run", error)),
|
|
207
|
+
Effect.flatMap((receipt) => {
|
|
208
|
+
if (receipt._tag !== "Parked") return runIdFromReceipt(receipt)
|
|
209
|
+
if (attempt >= parkedAttempts) {
|
|
210
|
+
return Effect.fail(
|
|
211
|
+
runnerError(
|
|
212
|
+
`Control plan ${plan.planId} is still parked awaiting approval after ${attempt} attempts`
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
return Effect.logInfo(`A scheduled plan is parked awaiting approval, attempt ${attempt}`).pipe(
|
|
217
|
+
Effect.andThen(Effect.sleep(Duration.millis(1000 * 2 ** (attempt - 1)))),
|
|
218
|
+
Effect.andThen(Effect.suspend(() => runApprovedPlan(control, plan, key, attempt + 1)))
|
|
219
|
+
)
|
|
220
|
+
})
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The statuses that mean a run has stopped for good.
|
|
225
|
+
*
|
|
226
|
+
* Liveness is stated as the complement of this set rather than as a list of
|
|
227
|
+
* live statuses, so a status Control adds later is treated as live until this
|
|
228
|
+
* package says otherwise. Reading it the other way round is what dropped
|
|
229
|
+
* `accepted`, the status every run holds between its claim and its first
|
|
230
|
+
* executed step (`packages/smithers/control/src/ControlLive.ts`).
|
|
231
|
+
*/
|
|
232
|
+
const settledState = (status: RunStatus): RunState | undefined =>
|
|
233
|
+
status === "cancelled" || status === "completed" || status === "failed" ? status : undefined
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Runner layer backed by the authoritative Control plan/run/list/cancel API.
|
|
237
|
+
*
|
|
238
|
+
* A parked plan waits for approval and retries the same idempotent run request
|
|
239
|
+
* a bounded number of times; this adapter never approves it or reconstructs an
|
|
240
|
+
* execution envelope.
|
|
241
|
+
*
|
|
242
|
+
* @category layers
|
|
243
|
+
* @since 0.1.0
|
|
244
|
+
*/
|
|
245
|
+
export const layerControlRunner: Layer.Layer<Runner, never, Control.Control> = Layer.effect(
|
|
246
|
+
Runner,
|
|
247
|
+
Effect.gen(function*() {
|
|
248
|
+
const control = yield* Control.Control
|
|
249
|
+
return makeRunner({
|
|
250
|
+
start: (input) =>
|
|
251
|
+
translateRunnerFailure(
|
|
252
|
+
control.plan(input).pipe(
|
|
253
|
+
Effect.flatMap((plan) => runApprovedPlan(control, plan, input.idempotencyKey, 1))
|
|
254
|
+
),
|
|
255
|
+
"Control could not launch the scheduled run"
|
|
256
|
+
),
|
|
257
|
+
inspect: (runId) =>
|
|
258
|
+
translateRunnerFailure(
|
|
259
|
+
control.list({ _tag: "runs", filters: { runId }, limit: 1 }).pipe(
|
|
260
|
+
Effect.map((response): RunState => {
|
|
261
|
+
const run = response._tag === "runs"
|
|
262
|
+
? response.items.find((candidate) => candidate.runId === runId)
|
|
263
|
+
: undefined
|
|
264
|
+
if (run === undefined) return "missing"
|
|
265
|
+
return settledState(run.status) ?? "active"
|
|
266
|
+
})
|
|
267
|
+
),
|
|
268
|
+
`Control could not inspect run ${runId}`
|
|
269
|
+
),
|
|
270
|
+
cancel: (runId) =>
|
|
271
|
+
translateRunnerFailure(
|
|
272
|
+
control.cancel({
|
|
273
|
+
runId,
|
|
274
|
+
idempotencyKey: `trigger-cancel:${runId}`
|
|
275
|
+
}).pipe(
|
|
276
|
+
Effect.flatMap((receipt): Effect.Effect<void, TriggerError> => {
|
|
277
|
+
switch (receipt._tag) {
|
|
278
|
+
case "Accepted":
|
|
279
|
+
case "AlreadyApplied":
|
|
280
|
+
case "Terminal":
|
|
281
|
+
return Effect.void
|
|
282
|
+
case "Conflict":
|
|
283
|
+
return Effect.fail(runnerError(`Control refused scheduled cancellation: ${receipt.message}`))
|
|
284
|
+
case "Parked":
|
|
285
|
+
return Effect.fail(runnerError(`Control returned a Parked receipt for cancellation of run ${runId}`))
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
),
|
|
289
|
+
`Control could not cancel run ${runId}`
|
|
290
|
+
)
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Scheduler timing configuration.
|
|
297
|
+
*
|
|
298
|
+
* @category models
|
|
299
|
+
* @since 0.1.0
|
|
300
|
+
*/
|
|
301
|
+
export interface Options {
|
|
302
|
+
readonly pollInterval?: Duration.Input | undefined
|
|
303
|
+
readonly runPollInterval?: Duration.Input | undefined
|
|
304
|
+
/**
|
|
305
|
+
* How many triggers one tick processes at the same time. Defaults to
|
|
306
|
+
* {@link defaultConcurrency}. A tick never waits for a launch: the claim is
|
|
307
|
+
* durable and the launch runs on its own fiber.
|
|
308
|
+
*/
|
|
309
|
+
readonly concurrency?: number | undefined
|
|
310
|
+
/**
|
|
311
|
+
* How long one `Runner.start` may take before the launch is abandoned with
|
|
312
|
+
* `runner_timeout`. Defaults to four minutes: above the Control adapter's
|
|
313
|
+
* bounded parked-approval retries and below the five-minute reservation
|
|
314
|
+
* lease, so an abandoned launch still owns its reservation and re-arms the
|
|
315
|
+
* occurrence itself. The next tick retries it under the same idempotency
|
|
316
|
+
* key. A deadline above the lease is allowed; a start that answers after
|
|
317
|
+
* its lease is reconciled as a late launch.
|
|
318
|
+
*/
|
|
319
|
+
readonly startTimeout?: Duration.Input | undefined
|
|
320
|
+
/**
|
|
321
|
+
* How long one `Runner.inspect` may take. Defaults to thirty seconds. A
|
|
322
|
+
* timeout is an inspection failure: the monitor retries and then detaches,
|
|
323
|
+
* retaining the durable owner.
|
|
324
|
+
*/
|
|
325
|
+
readonly inspectTimeout?: Duration.Input | undefined
|
|
326
|
+
/**
|
|
327
|
+
* How long one `Runner.cancel` may take. Defaults to thirty seconds. A
|
|
328
|
+
* timeout fails the supersede, which restores the prior run as active and
|
|
329
|
+
* queues the replacement.
|
|
330
|
+
*/
|
|
331
|
+
readonly cancelTimeout?: Duration.Input | undefined
|
|
332
|
+
/**
|
|
333
|
+
* The name this scheduler records its heartbeat under, so a listing can say
|
|
334
|
+
* which host last polled the store. Defaults to {@link defaultHost}.
|
|
335
|
+
*/
|
|
336
|
+
readonly host?: string | undefined
|
|
337
|
+
/**
|
|
338
|
+
* How long settled fire ledger rows are kept. Defaults to
|
|
339
|
+
* {@link defaultFireRetention}. A tick deletes older settled rows through
|
|
340
|
+
* `TriggerStore.pruneFires` at most once an hour; a failed prune is logged
|
|
341
|
+
* and the tick goes on.
|
|
342
|
+
*/
|
|
343
|
+
readonly fireRetention?: Duration.Input | undefined
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The heartbeat host name a scheduler records under when its options name
|
|
348
|
+
* none.
|
|
349
|
+
*
|
|
350
|
+
* @category constants
|
|
351
|
+
* @since 1.0.0-rc.0
|
|
352
|
+
*/
|
|
353
|
+
export const defaultHost = "local"
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* How many triggers one tick processes at the same time when its options name
|
|
357
|
+
* no `concurrency`.
|
|
358
|
+
*
|
|
359
|
+
* @category constants
|
|
360
|
+
* @since 1.0.0-rc.0
|
|
361
|
+
*/
|
|
362
|
+
export const defaultConcurrency = 4
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* How long settled fire ledger rows are kept when the options name no
|
|
366
|
+
* `fireRetention`: thirty days.
|
|
367
|
+
*
|
|
368
|
+
* @category constants
|
|
369
|
+
* @since 1.0.0-rc.1
|
|
370
|
+
*/
|
|
371
|
+
export const defaultFireRetention: Duration.Duration = Duration.days(30)
|
|
372
|
+
|
|
373
|
+
const pruneEvery = 60 * 60 * 1_000
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Histogram boundaries, in milliseconds, of `smithers.triggers.tick_duration_ms`
|
|
377
|
+
* and `smithers.triggers.launch_duration_ms`.
|
|
378
|
+
*
|
|
379
|
+
* @category constants
|
|
380
|
+
* @since 1.0.0-rc.1
|
|
381
|
+
*/
|
|
382
|
+
export const durationBoundaries: ReadonlyArray<number> = [10, 100, 1_000, 10_000, 60_000, 300_000]
|
|
383
|
+
|
|
384
|
+
const firesRecorded = Metric.counter("smithers.triggers.fires")
|
|
385
|
+
const tickDuration = Metric.histogram("smithers.triggers.tick_duration_ms", { boundaries: durationBoundaries })
|
|
386
|
+
const launchDuration = Metric.histogram("smithers.triggers.launch_duration_ms", { boundaries: durationBoundaries })
|
|
387
|
+
|
|
388
|
+
const timed = <A, E, R>(
|
|
389
|
+
histogram: typeof tickDuration,
|
|
390
|
+
effect: Effect.Effect<A, E, R>
|
|
391
|
+
): Effect.Effect<A, E, R> =>
|
|
392
|
+
Effect.gen(function*() {
|
|
393
|
+
const started = yield* Clock.currentTimeMillis
|
|
394
|
+
return yield* effect.pipe(
|
|
395
|
+
Effect.ensuring(
|
|
396
|
+
Effect.flatMap(Clock.currentTimeMillis, (ended) => Metric.update(histogram, ended - started))
|
|
397
|
+
)
|
|
398
|
+
)
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
const deadline = <A>(
|
|
402
|
+
effect: Effect.Effect<A, TriggerError>,
|
|
403
|
+
limit: Duration.Duration,
|
|
404
|
+
what: string
|
|
405
|
+
): Effect.Effect<A, TriggerError> =>
|
|
406
|
+
Effect.timeoutOrElse(effect, {
|
|
407
|
+
duration: limit,
|
|
408
|
+
orElse: () =>
|
|
409
|
+
Effect.fail(
|
|
410
|
+
new TriggerError({
|
|
411
|
+
code: "runner_timeout",
|
|
412
|
+
message: `${what} did not answer within ${Duration.toMillis(limit)} ms`
|
|
413
|
+
})
|
|
414
|
+
)
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Scheduler operations.
|
|
419
|
+
*
|
|
420
|
+
* @category models
|
|
421
|
+
* @since 0.1.0
|
|
422
|
+
*/
|
|
423
|
+
export interface Service {
|
|
424
|
+
readonly runOnce: Effect.Effect<void, TriggerError>
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* The live trigger scheduler.
|
|
429
|
+
*
|
|
430
|
+
* @category services
|
|
431
|
+
* @since 0.1.0
|
|
432
|
+
*/
|
|
433
|
+
export class Scheduler extends Context.Service<Scheduler, Service>()("flows/triggers/Scheduler") {}
|
|
434
|
+
|
|
435
|
+
/** Stable Control request identity for one manual or scheduled occurrence.
|
|
436
|
+
* @category constructors
|
|
437
|
+
* @since 0.1.0
|
|
438
|
+
*/
|
|
439
|
+
export const idempotencyKey = (triggerId: string, occurrence: number): string =>
|
|
440
|
+
`${triggerId}:${new Date(occurrence).toISOString()}`
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Runs one trigger's work and reports whether it finished.
|
|
444
|
+
*
|
|
445
|
+
* A tick walks the due triggers in id order, so an aborting trigger used to
|
|
446
|
+
* take every trigger after it alphabetically down with it, and the supervisor
|
|
447
|
+
* above discarded the error unread. The cause is logged where the trigger it
|
|
448
|
+
* belongs to is still known. Interruption is re-raised: it is the scope
|
|
449
|
+
* closing, not a trigger failing.
|
|
450
|
+
*/
|
|
451
|
+
const attempted = (
|
|
452
|
+
annotations: Record<string, string>,
|
|
453
|
+
work: string,
|
|
454
|
+
effect: Effect.Effect<void, TriggerError>
|
|
455
|
+
): Effect.Effect<boolean, TriggerError> =>
|
|
456
|
+
Effect.catchCause(Effect.as(effect, true), (cause) =>
|
|
457
|
+
Cause.hasInterrupts(cause)
|
|
458
|
+
? Effect.failCause(cause)
|
|
459
|
+
: Effect.as(
|
|
460
|
+
Effect.annotateLogs(Effect.logWarning(`A trigger ${work} failed`, cause), annotations),
|
|
461
|
+
false
|
|
462
|
+
))
|
|
463
|
+
|
|
464
|
+
const isolate = (
|
|
465
|
+
annotations: Record<string, string>,
|
|
466
|
+
work: string,
|
|
467
|
+
effect: Effect.Effect<void, TriggerError>
|
|
468
|
+
): Effect.Effect<void, TriggerError> => Effect.asVoid(attempted(annotations, work, effect))
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Constructs a scheduler service in the current Scope.
|
|
472
|
+
*
|
|
473
|
+
* @category constructors
|
|
474
|
+
* @since 0.1.0
|
|
475
|
+
*/
|
|
476
|
+
export const make = (
|
|
477
|
+
options: Options = {}
|
|
478
|
+
): Effect.Effect<Service, TriggerError, Runner | Scope.Scope | TriggerStore> =>
|
|
479
|
+
Effect.gen(function*() {
|
|
480
|
+
const parentScope = yield* Effect.scope
|
|
481
|
+
const providedStore = yield* TriggerStore
|
|
482
|
+
// Every fire the ledger records is counted by outcome, so a dropped,
|
|
483
|
+
// skipped, or failed occurrence is visible without reading the ledger.
|
|
484
|
+
const store: typeof providedStore = {
|
|
485
|
+
...providedStore,
|
|
486
|
+
recordResult: (result) =>
|
|
487
|
+
providedStore.recordResult(result).pipe(
|
|
488
|
+
Effect.tap(() => Metric.update(firesRecorded.pipe(Metric.withAttributes({ outcome: result.outcome })), 1))
|
|
489
|
+
)
|
|
490
|
+
}
|
|
491
|
+
const active = yield* ActiveRuns.make
|
|
492
|
+
const observedAt = yield* Ref.make<ReadonlyMap<string, number>>(new Map())
|
|
493
|
+
const semaphore = yield* Semaphore.make(1)
|
|
494
|
+
const runPollInterval = yield* duration(options.runPollInterval ?? "15 seconds", "runPollInterval")
|
|
495
|
+
const concurrency = options.concurrency ?? defaultConcurrency
|
|
496
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
|
497
|
+
return yield* Effect.fail(invalidOption("concurrency", "a positive integer"))
|
|
498
|
+
}
|
|
499
|
+
const startTimeout = yield* duration(options.startTimeout ?? "4 minutes", "startTimeout")
|
|
500
|
+
const inspectTimeout = yield* duration(options.inspectTimeout ?? "30 seconds", "inspectTimeout")
|
|
501
|
+
const cancelTimeout = yield* duration(options.cancelTimeout ?? "30 seconds", "cancelTimeout")
|
|
502
|
+
const fireRetention = yield* duration(options.fireRetention ?? defaultFireRetention, "fireRetention")
|
|
503
|
+
const lastPrunedAt = yield* Ref.make<number | undefined>(undefined)
|
|
504
|
+
// Every runner call carries a deadline. A runtime that never answers used
|
|
505
|
+
// to hold the launch, and the tick waiting on it, for the life of the
|
|
506
|
+
// scope with nothing written down.
|
|
507
|
+
const provided = yield* Runner
|
|
508
|
+
const runner: RunnerService = {
|
|
509
|
+
start: (input) => deadline(provided.start(input), startTimeout, `Runner.start for ${input.idempotencyKey}`),
|
|
510
|
+
inspect: (runId) => deadline(provided.inspect(runId), inspectTimeout, `Runner.inspect for run ${runId}`),
|
|
511
|
+
cancel: (runId) => deadline(provided.cancel(runId), cancelTimeout, `Runner.cancel for run ${runId}`)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// The watermark only moves forward, and only past occurrences this process
|
|
515
|
+
// finished dispatching. Advancing it before the work is what silently lost
|
|
516
|
+
// an occurrence whenever a claim or a dispatch failed.
|
|
517
|
+
const observe = (triggerId: string, occurrence: number): Effect.Effect<void> =>
|
|
518
|
+
Ref.update(observedAt, (current) => {
|
|
519
|
+
const existing = current.get(triggerId)
|
|
520
|
+
if (existing !== undefined && existing >= occurrence) return current
|
|
521
|
+
return new Map(current).set(triggerId, occurrence)
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
// A reservation is not a run: the Runner has never heard of it, and asking
|
|
525
|
+
// answers "not active" for a launch that is still in flight. Its lease is
|
|
526
|
+
// the only thing entitled to release it, in either branch.
|
|
527
|
+
const inspectStored = (runId: string): Effect.Effect<RunState, TriggerError> =>
|
|
528
|
+
isReservation(runId) ? Effect.succeed("active") : runner.inspect(runId)
|
|
529
|
+
|
|
530
|
+
const occurrenceOf = (
|
|
531
|
+
triggerId: string,
|
|
532
|
+
runId: string
|
|
533
|
+
): Effect.Effect<number, TriggerError> => {
|
|
534
|
+
const reserved = reservationOccurrence(runId)
|
|
535
|
+
if (reserved !== undefined) return Effect.succeed(reserved)
|
|
536
|
+
return store.activeOccurrence(triggerId, runId).pipe(
|
|
537
|
+
Effect.map((occurrence) => Option.isSome(occurrence) ? occurrence.value : Number.NEGATIVE_INFINITY)
|
|
538
|
+
)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Only `completed` is a completed occurrence. A failed, cancelled, or
|
|
542
|
+
// vanished run used to be recorded as `completed` because the runner
|
|
543
|
+
// answered only "not active". Recording the result also clears the
|
|
544
|
+
// matching active run atomically.
|
|
545
|
+
const recordSettled = (
|
|
546
|
+
triggerId: string,
|
|
547
|
+
occurrence: number,
|
|
548
|
+
runId: string,
|
|
549
|
+
state: Exclude<RunState, "active">
|
|
550
|
+
): Effect.Effect<void, TriggerError> =>
|
|
551
|
+
state === "completed"
|
|
552
|
+
? store.recordResult({ triggerId, occurrence, outcome: "completed", runId })
|
|
553
|
+
: store.recordResult({ triggerId, occurrence, outcome: "failed", runId, error: stoppedMessage(runId, state) })
|
|
554
|
+
|
|
555
|
+
const settleRecovered = (
|
|
556
|
+
triggerId: string,
|
|
557
|
+
occurrence: number,
|
|
558
|
+
runId: string,
|
|
559
|
+
state: Exclude<RunState, "active">
|
|
560
|
+
): Effect.Effect<void, TriggerError> =>
|
|
561
|
+
Number.isFinite(occurrence)
|
|
562
|
+
? recordSettled(triggerId, occurrence, runId, state)
|
|
563
|
+
: store.clearActive(triggerId, runId)
|
|
564
|
+
|
|
565
|
+
// The listed row already answers what the store would: with no run and no
|
|
566
|
+
// reservation on it, `activeRun` reads an idle lease, writes nothing, and
|
|
567
|
+
// answers `None`. Asking anyway cost one write transaction per trigger per
|
|
568
|
+
// tick, on the tick where every trigger is idle.
|
|
569
|
+
const storedActive = (
|
|
570
|
+
triggerId: string,
|
|
571
|
+
held: Held | undefined
|
|
572
|
+
): Effect.Effect<Option.Option<string>, TriggerError> =>
|
|
573
|
+
held !== undefined && held.activeRunId === undefined
|
|
574
|
+
? Effect.succeed(Option.none())
|
|
575
|
+
: store.activeRun(triggerId)
|
|
576
|
+
|
|
577
|
+
// Adopts the run the store holds for a trigger that has no local entry:
|
|
578
|
+
// settle it if it has already stopped, otherwise take the entry for it.
|
|
579
|
+
const recoverStored = (
|
|
580
|
+
trigger: Registered,
|
|
581
|
+
runId: string
|
|
582
|
+
): Effect.Effect<ActiveRuns.Active | undefined, TriggerError> =>
|
|
583
|
+
Effect.gen(function*() {
|
|
584
|
+
const occurrence = yield* occurrenceOf(trigger.id, runId)
|
|
585
|
+
const state = yield* inspectStored(runId)
|
|
586
|
+
if (state !== "active") {
|
|
587
|
+
yield* settleRecovered(trigger.id, occurrence, runId, state)
|
|
588
|
+
return undefined
|
|
589
|
+
}
|
|
590
|
+
const recovered: ActiveRuns.Active = { occurrence, runId }
|
|
591
|
+
yield* active.take(trigger.id, recovered)
|
|
592
|
+
return recovered
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
const resolveActive = (
|
|
596
|
+
trigger: Registered,
|
|
597
|
+
held: Held | undefined
|
|
598
|
+
): Effect.Effect<ActiveRuns.Active | undefined, TriggerError> =>
|
|
599
|
+
Effect.gen(function*() {
|
|
600
|
+
const local = yield* active.get(trigger.id)
|
|
601
|
+
if (local !== undefined) {
|
|
602
|
+
// The finalizer detaches the fiber when monitoring ends. An entry
|
|
603
|
+
// without a fiber was recovered or could no longer be inspected;
|
|
604
|
+
// only the runtime can say whether that run is still going.
|
|
605
|
+
if (local.fiber !== undefined) return local
|
|
606
|
+
if (!isReservation(local.runId)) {
|
|
607
|
+
const state = yield* inspectStored(local.runId)
|
|
608
|
+
if (state === "active") return local
|
|
609
|
+
yield* settleRecovered(trigger.id, local.occurrence, local.runId, state)
|
|
610
|
+
yield* active.remove(trigger.id, local.occurrence)
|
|
611
|
+
} else {
|
|
612
|
+
// A recovered reservation has no monitor that can remove it. Ask
|
|
613
|
+
// the store on every tick so its lease can expire and re-arm the
|
|
614
|
+
// occurrence instead of pinning this local cache forever.
|
|
615
|
+
const stored = yield* storedActive(trigger.id, held)
|
|
616
|
+
if (Option.isNone(stored)) {
|
|
617
|
+
yield* active.remove(trigger.id, local.occurrence)
|
|
618
|
+
return undefined
|
|
619
|
+
}
|
|
620
|
+
if (stored.value === local.runId) return local
|
|
621
|
+
yield* active.remove(trigger.id, local.occurrence)
|
|
622
|
+
return yield* recoverStored(trigger, stored.value)
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
const stored = yield* storedActive(trigger.id, held)
|
|
626
|
+
if (Option.isNone(stored)) return undefined
|
|
627
|
+
return yield* recoverStored(trigger, stored.value)
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
const recordFailed = (
|
|
631
|
+
trigger: Registered,
|
|
632
|
+
occurrence: number,
|
|
633
|
+
error: TriggerError,
|
|
634
|
+
runId?: string | undefined
|
|
635
|
+
): Effect.Effect<void, TriggerError> =>
|
|
636
|
+
store.recordResult({
|
|
637
|
+
triggerId: trigger.id,
|
|
638
|
+
occurrence,
|
|
639
|
+
outcome: "failed",
|
|
640
|
+
error: error.message,
|
|
641
|
+
runId
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
// An inspection error is not evidence that a run stopped. Retry three
|
|
645
|
+
// times, doubling the poll interval up to a minute, then let the next tick
|
|
646
|
+
// inspect the durable owner again. Interruption must still close the scope.
|
|
647
|
+
const inspectRun = (triggerId: string, runId: string): Effect.Effect<Option.Option<RunState>, TriggerError> =>
|
|
648
|
+
Effect.gen(function*() {
|
|
649
|
+
for (let attempt = 0;; attempt++) {
|
|
650
|
+
const inspected = yield* runner.inspect(runId).pipe(
|
|
651
|
+
Effect.map(Option.some),
|
|
652
|
+
Effect.catchCause((cause) =>
|
|
653
|
+
Cause.hasInterrupts(cause) ?
|
|
654
|
+
Effect.failCause(cause) :
|
|
655
|
+
Effect.logWarning("A trigger run inspection failed", cause).pipe(
|
|
656
|
+
Effect.annotateLogs({ triggerId, runId, attempt: String(attempt + 1) }),
|
|
657
|
+
Effect.as(Option.none<RunState>())
|
|
658
|
+
)
|
|
659
|
+
)
|
|
660
|
+
)
|
|
661
|
+
if (Option.isSome(inspected)) return inspected
|
|
662
|
+
if (attempt === 3) {
|
|
663
|
+
yield* Effect.logWarning("A trigger run monitor detached after inspection retries").pipe(
|
|
664
|
+
Effect.annotateLogs({ triggerId, runId })
|
|
665
|
+
)
|
|
666
|
+
return Option.none<RunState>()
|
|
667
|
+
}
|
|
668
|
+
yield* Effect.sleep(Math.min(Duration.toMillis(runPollInterval) * 2 ** attempt, 60_000))
|
|
669
|
+
}
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
const launch = (
|
|
673
|
+
trigger: Registered,
|
|
674
|
+
occurrence: number,
|
|
675
|
+
reservation: string,
|
|
676
|
+
preserveBuffered: boolean
|
|
677
|
+
): Effect.Effect<void, TriggerError> =>
|
|
678
|
+
Effect.gen(function*() {
|
|
679
|
+
// The occurrence takes the entry under its reservation before the
|
|
680
|
+
// runner hears of it, so every write below is fenced on this launch.
|
|
681
|
+
yield* active.take(trigger.id, { occurrence, runId: reservation })
|
|
682
|
+
let runId: string | undefined
|
|
683
|
+
let launchRecorded = false
|
|
684
|
+
let completed = false
|
|
685
|
+
const lifecycle = Effect.gen(function*() {
|
|
686
|
+
runId = yield* timed(
|
|
687
|
+
launchDuration,
|
|
688
|
+
runner.start({
|
|
689
|
+
flowId: trigger.flowId,
|
|
690
|
+
input: trigger.input,
|
|
691
|
+
idempotencyKey: idempotencyKey(trigger.id, occurrence)
|
|
692
|
+
})
|
|
693
|
+
).pipe(
|
|
694
|
+
Effect.withSpan("Scheduler.launch", {
|
|
695
|
+
attributes: { triggerId: trigger.id, occurrence: new Date(occurrence).toISOString() }
|
|
696
|
+
})
|
|
697
|
+
)
|
|
698
|
+
const startedRunId = runId
|
|
699
|
+
yield* active.update(trigger.id, occurrence, (entry) => ({ ...entry, runId: startedRunId }))
|
|
700
|
+
yield* store.recordResult({
|
|
701
|
+
triggerId: trigger.id,
|
|
702
|
+
occurrence,
|
|
703
|
+
outcome: "launched",
|
|
704
|
+
runId,
|
|
705
|
+
reservationId: reservation
|
|
706
|
+
})
|
|
707
|
+
launchRecorded = true
|
|
708
|
+
let state: RunState
|
|
709
|
+
while (true) {
|
|
710
|
+
const inspected = yield* inspectRun(trigger.id, runId)
|
|
711
|
+
if (Option.isNone(inspected)) return
|
|
712
|
+
state = inspected.value
|
|
713
|
+
if (state !== "active") break
|
|
714
|
+
yield* Effect.sleep(runPollInterval)
|
|
715
|
+
}
|
|
716
|
+
yield* recordSettled(trigger.id, occurrence, runId, state)
|
|
717
|
+
completed = true
|
|
718
|
+
}).pipe(
|
|
719
|
+
Effect.catch((error) =>
|
|
720
|
+
Effect.gen(function*() {
|
|
721
|
+
if (launchRecorded) {
|
|
722
|
+
yield* Effect.logWarning("A trigger run monitor could not persist completion", error).pipe(
|
|
723
|
+
Effect.annotateLogs({ triggerId: trigger.id, runId: runId! })
|
|
724
|
+
)
|
|
725
|
+
return
|
|
726
|
+
}
|
|
727
|
+
if (error.code === "stale_owner" && runId !== undefined) {
|
|
728
|
+
yield* Effect.logWarning("A trigger launch lost its reservation", error).pipe(
|
|
729
|
+
Effect.annotateLogs({ triggerId: trigger.id, occurrence: String(occurrence) })
|
|
730
|
+
)
|
|
731
|
+
const losingRunId = runId
|
|
732
|
+
yield* isolate(
|
|
733
|
+
{ triggerId: trigger.id, runId },
|
|
734
|
+
"losing run reconciliation",
|
|
735
|
+
Effect.gen(function*() {
|
|
736
|
+
const held = yield* store.inspect(trigger.id)
|
|
737
|
+
// An idempotent retry may have committed this same run.
|
|
738
|
+
// An unfinished retry of the same occurrence will adopt it.
|
|
739
|
+
if (
|
|
740
|
+
held.activeRunId === losingRunId ||
|
|
741
|
+
(held.activeRunId !== undefined && reservationOccurrence(held.activeRunId) === occurrence)
|
|
742
|
+
) return
|
|
743
|
+
yield* runner.cancel(losingRunId)
|
|
744
|
+
})
|
|
745
|
+
).pipe(Effect.ignore)
|
|
746
|
+
return
|
|
747
|
+
}
|
|
748
|
+
if (preserveBuffered) {
|
|
749
|
+
// A buffered occurrence was taken off the buffer by the claim
|
|
750
|
+
// that led here. Re-arm it under this reservation so the next
|
|
751
|
+
// tick retries it rather than losing it.
|
|
752
|
+
yield* isolate(
|
|
753
|
+
{ triggerId: trigger.id },
|
|
754
|
+
"buffered launch compensation",
|
|
755
|
+
store.restorePending({ triggerId: trigger.id, occurrence, reservationId: reservation })
|
|
756
|
+
).pipe(Effect.ignore)
|
|
757
|
+
} else {
|
|
758
|
+
// A start that answered too late is ambiguous: the runtime may
|
|
759
|
+
// hold the run. Keep the occurrence pending exactly as when the
|
|
760
|
+
// launched result failed to persist, so the next tick retries
|
|
761
|
+
// under the same idempotency key and the runtime's durable
|
|
762
|
+
// deduplication answers with the same run.
|
|
763
|
+
if (runId !== undefined || error.code === "runner_timeout") {
|
|
764
|
+
yield* isolate(
|
|
765
|
+
{ triggerId: trigger.id },
|
|
766
|
+
"launch compensation",
|
|
767
|
+
store.restorePending({
|
|
768
|
+
triggerId: trigger.id,
|
|
769
|
+
occurrence,
|
|
770
|
+
reservationId: reservation
|
|
771
|
+
})
|
|
772
|
+
).pipe(Effect.ignore)
|
|
773
|
+
} else {
|
|
774
|
+
yield* recordFailed(trigger, occurrence, error, reservation).pipe(Effect.ignore)
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
yield* Effect.logWarning("A trigger launch failed", error).pipe(
|
|
778
|
+
Effect.annotateLogs({ triggerId: trigger.id, occurrence: String(occurrence) })
|
|
779
|
+
)
|
|
780
|
+
})
|
|
781
|
+
),
|
|
782
|
+
// Interrupting this fiber detaches the monitor; it never cancels the
|
|
783
|
+
// run. The run is durable and outlives this process, so a deploy or
|
|
784
|
+
// any other scope closure must leave it alone: the next incarnation
|
|
785
|
+
// re-attaches through `resolveActive`. Cancellation is a deliberate
|
|
786
|
+
// act and belongs to `cancelActive` alone.
|
|
787
|
+
Effect.ensuring(Effect.suspend(() =>
|
|
788
|
+
launchRecorded && !completed
|
|
789
|
+
? active.update(trigger.id, occurrence, (entry) => ({ ...entry, fiber: undefined }))
|
|
790
|
+
: active.remove(trigger.id, occurrence)
|
|
791
|
+
))
|
|
792
|
+
)
|
|
793
|
+
// The tick does not wait for the launch. The claim already fenced
|
|
794
|
+
// the occurrence under its reservation, and every failure below is
|
|
795
|
+
// compensated in the lifecycle itself. A tick that waited held every
|
|
796
|
+
// other trigger for as long as one plan sat parked or one runner was
|
|
797
|
+
// slow, and a boundary that passed meanwhile was never claimed.
|
|
798
|
+
const fiber = yield* Effect.forkIn(
|
|
799
|
+
Effect.scoped(lifecycle).pipe(
|
|
800
|
+
Effect.onExit((exit) =>
|
|
801
|
+
exit._tag === "Failure" && !Cause.hasInterrupts(exit.cause)
|
|
802
|
+
? Effect.logWarning(
|
|
803
|
+
launchRecorded ? "A trigger run monitor failed" : "A trigger launch failed",
|
|
804
|
+
exit.cause
|
|
805
|
+
).pipe(
|
|
806
|
+
Effect.annotateLogs({
|
|
807
|
+
triggerId: trigger.id,
|
|
808
|
+
occurrence: String(occurrence),
|
|
809
|
+
runId: runId ?? reservation
|
|
810
|
+
})
|
|
811
|
+
)
|
|
812
|
+
: Effect.void
|
|
813
|
+
)
|
|
814
|
+
),
|
|
815
|
+
parentScope,
|
|
816
|
+
{ startImmediately: true }
|
|
817
|
+
)
|
|
818
|
+
// The monitor is recorded against the entry this occurrence claimed.
|
|
819
|
+
// `startImmediately` can finish or detach before the fork returns.
|
|
820
|
+
// Never attach a finished fiber to an entry awaiting tick recovery.
|
|
821
|
+
if (fiber.pollUnsafe() === undefined) {
|
|
822
|
+
yield* active.update(trigger.id, occurrence, (entry) => ({ ...entry, fiber }))
|
|
823
|
+
}
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
// Only a claim that named the run it is superseding gets here, so the run
|
|
827
|
+
// id is known. The monitor, on the other hand, may not exist: the run can
|
|
828
|
+
// belong to a scheduler this one only knows through the store. Cancelling
|
|
829
|
+
// is a deliberate act and happens here alone; a reservation has no run for
|
|
830
|
+
// the runtime to cancel, and an occurrence this process never saw has none
|
|
831
|
+
// of its own to record the supersession against.
|
|
832
|
+
const cancelActive = (
|
|
833
|
+
trigger: Registered,
|
|
834
|
+
prior: ActiveRuns.Active,
|
|
835
|
+
replacementOccurrence: number,
|
|
836
|
+
replacementReservation: string,
|
|
837
|
+
queueReplacement: boolean
|
|
838
|
+
): Effect.Effect<void, TriggerError> =>
|
|
839
|
+
Effect.gen(function*() {
|
|
840
|
+
if (!isReservation(prior.runId)) {
|
|
841
|
+
yield* runner.cancel(prior.runId).pipe(
|
|
842
|
+
Effect.catch((error) => {
|
|
843
|
+
// The claim already replaced the prior run with the new launch
|
|
844
|
+
// reservation. If cancellation fails, restore that run as active
|
|
845
|
+
// and queue the replacement so neither side of the hand-off is
|
|
846
|
+
// lost. Keep its monitor attached: it is still running.
|
|
847
|
+
const restore = queueReplacement
|
|
848
|
+
? isolate(
|
|
849
|
+
{ triggerId: trigger.id },
|
|
850
|
+
"supersede compensation",
|
|
851
|
+
store.restorePending({
|
|
852
|
+
triggerId: trigger.id,
|
|
853
|
+
occurrence: replacementOccurrence,
|
|
854
|
+
reservationId: replacementReservation
|
|
855
|
+
})
|
|
856
|
+
)
|
|
857
|
+
: Effect.void
|
|
858
|
+
return restore.pipe(
|
|
859
|
+
Effect.andThen(Effect.fail(error))
|
|
860
|
+
)
|
|
861
|
+
})
|
|
862
|
+
)
|
|
863
|
+
}
|
|
864
|
+
if (Number.isFinite(prior.occurrence) && !isReservation(prior.runId)) {
|
|
865
|
+
yield* store.recordResult({
|
|
866
|
+
triggerId: trigger.id,
|
|
867
|
+
occurrence: prior.occurrence,
|
|
868
|
+
outcome: "superseded",
|
|
869
|
+
runId: prior.runId
|
|
870
|
+
})
|
|
871
|
+
}
|
|
872
|
+
// Keep the monitor attached until the terminal write succeeds. If the
|
|
873
|
+
// store refuses it after cancellation, the monitor can still observe
|
|
874
|
+
// the stopped run and record a terminal result on its next poll.
|
|
875
|
+
if (prior.fiber !== undefined) yield* Fiber.interrupt(prior.fiber)
|
|
876
|
+
yield* active.remove(trigger.id, prior.occurrence)
|
|
877
|
+
})
|
|
878
|
+
|
|
879
|
+
const dispatchClaimed = (
|
|
880
|
+
trigger: Registered,
|
|
881
|
+
occurrence: number,
|
|
882
|
+
claim: Exclude<Claim, { readonly claimed: false }>,
|
|
883
|
+
resumeBuffered = false
|
|
884
|
+
): Effect.Effect<void, TriggerError> =>
|
|
885
|
+
Effect.gen(function*() {
|
|
886
|
+
switch (claim.action) {
|
|
887
|
+
case "fire":
|
|
888
|
+
yield* launch(trigger, occurrence, claim.reservationId, resumeBuffered)
|
|
889
|
+
return
|
|
890
|
+
case "skip":
|
|
891
|
+
yield* store.recordResult({
|
|
892
|
+
triggerId: trigger.id,
|
|
893
|
+
occurrence,
|
|
894
|
+
outcome: "skipped"
|
|
895
|
+
})
|
|
896
|
+
return
|
|
897
|
+
case "buffer":
|
|
898
|
+
yield* store.recordResult({
|
|
899
|
+
triggerId: trigger.id,
|
|
900
|
+
occurrence,
|
|
901
|
+
outcome: "buffered"
|
|
902
|
+
})
|
|
903
|
+
return
|
|
904
|
+
case "supersede": {
|
|
905
|
+
const superseded = claim.activeRunId
|
|
906
|
+
if (superseded !== undefined) {
|
|
907
|
+
const local = yield* active.get(trigger.id)
|
|
908
|
+
yield* cancelActive(
|
|
909
|
+
trigger,
|
|
910
|
+
local !== undefined && local.runId === superseded
|
|
911
|
+
? { ...local, runId: superseded }
|
|
912
|
+
: {
|
|
913
|
+
occurrence: yield* occurrenceOf(trigger.id, superseded),
|
|
914
|
+
runId: superseded
|
|
915
|
+
},
|
|
916
|
+
occurrence,
|
|
917
|
+
claim.reservationId,
|
|
918
|
+
!resumeBuffered
|
|
919
|
+
)
|
|
920
|
+
}
|
|
921
|
+
yield* launch(trigger, occurrence, claim.reservationId, resumeBuffered)
|
|
922
|
+
return
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
})
|
|
926
|
+
|
|
927
|
+
const claimOnce = (
|
|
928
|
+
trigger: Registered,
|
|
929
|
+
occurrence: number
|
|
930
|
+
): Effect.Effect<void, TriggerError> =>
|
|
931
|
+
store.claimFire({
|
|
932
|
+
triggerId: trigger.id,
|
|
933
|
+
occurrence,
|
|
934
|
+
expectedRevision: trigger.revision
|
|
935
|
+
}).pipe(
|
|
936
|
+
Effect.flatMap((claim) => claim.claimed ? dispatchClaimed(trigger, occurrence, claim) : Effect.void)
|
|
937
|
+
)
|
|
938
|
+
|
|
939
|
+
// A claim is fenced on the revision its occurrence was computed from. One
|
|
940
|
+
// refresh and one retry is enough because the next tick reads again.
|
|
941
|
+
const withRevisionRefresh = <A>(
|
|
942
|
+
trigger: Registered,
|
|
943
|
+
claim: (current: Registered) => Effect.Effect<A, TriggerError>
|
|
944
|
+
): Effect.Effect<A, TriggerError> =>
|
|
945
|
+
claim(trigger).pipe(
|
|
946
|
+
Effect.catch((error) =>
|
|
947
|
+
error.code !== "revision_mismatch"
|
|
948
|
+
? Effect.fail(error)
|
|
949
|
+
: store.get(trigger.id).pipe(
|
|
950
|
+
Effect.flatMap((refreshed) =>
|
|
951
|
+
Option.isNone(refreshed) || refreshed.value.revision === trigger.revision
|
|
952
|
+
? Effect.fail(error)
|
|
953
|
+
: claim(refreshed.value)
|
|
954
|
+
)
|
|
955
|
+
)
|
|
956
|
+
)
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
// The store reads, claims, and clears a buffer in one transaction. A
|
|
960
|
+
// dispatch failure happens after that commit, so this process can safely
|
|
961
|
+
// re-arm the occurrence before it reports the failure.
|
|
962
|
+
const resumePending = (trigger: Registered, held?: Held): Effect.Effect<void, TriggerError> =>
|
|
963
|
+
// A listed row holding neither a buffered occurrence nor a run has
|
|
964
|
+
// nothing for `claimPending` to take: only expiring a reservation
|
|
965
|
+
// re-arms a pending occurrence, and there is no reservation to expire.
|
|
966
|
+
held !== undefined && held.pendingAt === undefined && held.activeRunId === undefined
|
|
967
|
+
? Effect.void
|
|
968
|
+
: withRevisionRefresh(trigger, (current) =>
|
|
969
|
+
store.claimPending({
|
|
970
|
+
triggerId: current.id,
|
|
971
|
+
expectedRevision: current.revision
|
|
972
|
+
}).pipe(
|
|
973
|
+
Effect.flatMap((pending) => {
|
|
974
|
+
if (Option.isNone(pending)) return Effect.void
|
|
975
|
+
const occurrence = pending.value.occurrence
|
|
976
|
+
const claim = pending.value.claim
|
|
977
|
+
if (!claim.claimed) return Effect.void
|
|
978
|
+
return dispatchClaimed(
|
|
979
|
+
current,
|
|
980
|
+
occurrence,
|
|
981
|
+
claim,
|
|
982
|
+
true
|
|
983
|
+
).pipe(
|
|
984
|
+
Effect.onError(() =>
|
|
985
|
+
isolate(
|
|
986
|
+
{ triggerId: current.id },
|
|
987
|
+
"buffered launch compensation",
|
|
988
|
+
claim.action === "fire" || claim.action === "supersede"
|
|
989
|
+
? store.restorePending({ triggerId: current.id, occurrence, reservationId: claim.reservationId })
|
|
990
|
+
: store.setPending({ triggerId: current.id, occurrence })
|
|
991
|
+
).pipe(Effect.ignore)
|
|
992
|
+
)
|
|
993
|
+
)
|
|
994
|
+
})
|
|
995
|
+
))
|
|
996
|
+
|
|
997
|
+
const processTrigger = (
|
|
998
|
+
trigger: Registered,
|
|
999
|
+
held: Held | undefined,
|
|
1000
|
+
refreshed = false
|
|
1001
|
+
): Effect.Effect<void, TriggerError> =>
|
|
1002
|
+
Effect.gen(function*() {
|
|
1003
|
+
// Each trigger reads the clock itself. One instant captured before the
|
|
1004
|
+
// tick fanned out aged by every launch that finished ahead of it.
|
|
1005
|
+
const now = yield* Clock.currentTimeMillis
|
|
1006
|
+
const running = yield* resolveActive(trigger, held)
|
|
1007
|
+
// Disable prevents future claims, not recovery of an already active
|
|
1008
|
+
// occurrence (including a plan waiting for a human decision).
|
|
1009
|
+
if (!trigger.enabled) return
|
|
1010
|
+
if (running === undefined || trigger.overlap === "supersede") yield* resumePending(trigger, held)
|
|
1011
|
+
const observed = (yield* Ref.get(observedAt)).get(trigger.id)
|
|
1012
|
+
const due = yield* DueOccurrences.compute(trigger, now, observed)
|
|
1013
|
+
let dispatched: number | undefined
|
|
1014
|
+
let interrupted = false
|
|
1015
|
+
let stale = false
|
|
1016
|
+
for (const occurrence of due.occurrences) {
|
|
1017
|
+
const settledHere = yield* attempted(
|
|
1018
|
+
{ triggerId: trigger.id },
|
|
1019
|
+
`dispatch of occurrence ${occurrence}`,
|
|
1020
|
+
claimOnce(trigger, occurrence).pipe(
|
|
1021
|
+
Effect.catch((error) =>
|
|
1022
|
+
error.code === "revision_mismatch"
|
|
1023
|
+
? Effect.sync(() => {
|
|
1024
|
+
stale = true
|
|
1025
|
+
})
|
|
1026
|
+
: Effect.fail(error)
|
|
1027
|
+
)
|
|
1028
|
+
)
|
|
1029
|
+
)
|
|
1030
|
+
if (stale) break
|
|
1031
|
+
if (settledHere && !interrupted) dispatched = occurrence
|
|
1032
|
+
if (!settledHere) interrupted = true
|
|
1033
|
+
}
|
|
1034
|
+
if (stale) {
|
|
1035
|
+
// The occurrences were computed from a declaration the store has
|
|
1036
|
+
// since replaced, so the schedule decision is what went stale: a
|
|
1037
|
+
// trigger edited to fire at noon owes nothing for the hourly
|
|
1038
|
+
// boundary its old cron produced. Keep the boundary already
|
|
1039
|
+
// dispatched, re-read once, and decide again from the refreshed cron
|
|
1040
|
+
// and watermark. A second mismatch waits for the next tick, which
|
|
1041
|
+
// reads again anyway. A buffered occurrence is store state rather
|
|
1042
|
+
// than a computed decision, so `resumePending` retries it as is.
|
|
1043
|
+
if (dispatched !== undefined) yield* observe(trigger.id, dispatched)
|
|
1044
|
+
if (refreshed) return
|
|
1045
|
+
const current = yield* store.get(trigger.id)
|
|
1046
|
+
if (Option.isNone(current) || current.value.revision === trigger.revision) return
|
|
1047
|
+
// The refreshed declaration comes from the store, not the listing,
|
|
1048
|
+
// so the held snapshot no longer describes it.
|
|
1049
|
+
return yield* processTrigger(current.value, undefined, true)
|
|
1050
|
+
}
|
|
1051
|
+
if (!interrupted) return yield* observe(trigger.id, due.watermark)
|
|
1052
|
+
if (dispatched !== undefined) yield* observe(trigger.id, dispatched)
|
|
1053
|
+
}).pipe(Effect.withSpan("Scheduler.processTrigger", { attributes: { triggerId: trigger.id } }))
|
|
1054
|
+
|
|
1055
|
+
const host = options.host ?? defaultHost
|
|
1056
|
+
|
|
1057
|
+
const runOnce = semaphore.withPermits(1)(
|
|
1058
|
+
Effect.gen(function*() {
|
|
1059
|
+
// The heartbeat is observability, not dispatch: a store that cannot
|
|
1060
|
+
// record it is logged and the tick goes on, so a listing's "nothing is
|
|
1061
|
+
// listening" can never be caused by the row that reports it.
|
|
1062
|
+
yield* isolate({ host }, "heartbeat", store.heartbeat(host))
|
|
1063
|
+
// Nothing else deletes from the fire ledger, so without this it keeps
|
|
1064
|
+
// one row per occurrence forever and every fires listing reads it all.
|
|
1065
|
+
const now = yield* Clock.currentTimeMillis
|
|
1066
|
+
const last = yield* Ref.get(lastPrunedAt)
|
|
1067
|
+
if (last === undefined || now - last >= pruneEvery) {
|
|
1068
|
+
yield* Ref.set(lastPrunedAt, now)
|
|
1069
|
+
yield* isolate(
|
|
1070
|
+
{ host },
|
|
1071
|
+
"fire ledger prune",
|
|
1072
|
+
Effect.asVoid(store.pruneFires({ olderThan: now - Duration.toMillis(fireRetention) }))
|
|
1073
|
+
)
|
|
1074
|
+
}
|
|
1075
|
+
// Every trigger, not only the enabled ones: a disabled trigger can
|
|
1076
|
+
// still hold an active occurrence that has to recover, and the enabled
|
|
1077
|
+
// check below it stops the new claims.
|
|
1078
|
+
const listing = yield* store.list()
|
|
1079
|
+
// Triggers are independent once claimed: the store fences each claim
|
|
1080
|
+
// on its own row. Walking them in series let one launch waiting on a
|
|
1081
|
+
// parked plan hold every trigger after it for the whole wait. A row
|
|
1082
|
+
// the store could not decode is one more isolated failure, so one
|
|
1083
|
+
// corrupt declaration cannot stop the healthy schedules beside it.
|
|
1084
|
+
yield* Effect.forEach(
|
|
1085
|
+
listing,
|
|
1086
|
+
(row) =>
|
|
1087
|
+
isolate(
|
|
1088
|
+
{ triggerId: row.triggerId },
|
|
1089
|
+
"tick",
|
|
1090
|
+
Result.isFailure(row.trigger)
|
|
1091
|
+
? Effect.fail(row.trigger.failure)
|
|
1092
|
+
: processTrigger(row.trigger.success, row)
|
|
1093
|
+
),
|
|
1094
|
+
{ concurrency, discard: true }
|
|
1095
|
+
)
|
|
1096
|
+
}).pipe((tick) => timed(tickDuration, tick), Effect.withSpan("Scheduler.runOnce", { attributes: { host } }))
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
return Scheduler.of({ runOnce })
|
|
1100
|
+
})
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Constructs a scheduler that performs no work.
|
|
1104
|
+
*
|
|
1105
|
+
* @category constructors
|
|
1106
|
+
* @since 0.1.0
|
|
1107
|
+
*/
|
|
1108
|
+
export const makeNoop = (): Service => Scheduler.of({ runOnce: Effect.void })
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Scoped scheduler layer. Its supervisor sleeps only through the Effect Clock,
|
|
1112
|
+
* so scope closure interrupts the poll loop and detaches every run monitor.
|
|
1113
|
+
* Detaching is all it does: the runs themselves are durable and keep going,
|
|
1114
|
+
* and the next incarnation re-attaches to them from the store.
|
|
1115
|
+
*
|
|
1116
|
+
* The loop recovers from the whole cause rather than the typed error alone. A
|
|
1117
|
+
* defect raised anywhere under a tick, which `Effect.catch` by contract leaves
|
|
1118
|
+
* alone, would otherwise kill this fiber and stop every trigger in the process
|
|
1119
|
+
* with nothing written down.
|
|
1120
|
+
*
|
|
1121
|
+
* @category layers
|
|
1122
|
+
* @since 0.1.0
|
|
1123
|
+
*/
|
|
1124
|
+
export const layer = (
|
|
1125
|
+
options: Options = {}
|
|
1126
|
+
): Layer.Layer<Scheduler, TriggerError, Runner | TriggerStore> =>
|
|
1127
|
+
Layer.effect(
|
|
1128
|
+
Scheduler,
|
|
1129
|
+
Effect.gen(function*() {
|
|
1130
|
+
const scheduler = yield* make(options)
|
|
1131
|
+
const pollInterval = yield* duration(options.pollInterval ?? "1 minute", "pollInterval")
|
|
1132
|
+
yield* Effect.forkScoped(
|
|
1133
|
+
Effect.forever(
|
|
1134
|
+
scheduler.runOnce.pipe(
|
|
1135
|
+
Effect.catchCause((cause) =>
|
|
1136
|
+
Cause.hasInterrupts(cause)
|
|
1137
|
+
? Effect.failCause(cause)
|
|
1138
|
+
: Effect.logWarning("A trigger scheduler tick failed", cause)
|
|
1139
|
+
),
|
|
1140
|
+
Effect.andThen(Effect.sleep(pollInterval))
|
|
1141
|
+
)
|
|
1142
|
+
)
|
|
1143
|
+
)
|
|
1144
|
+
return scheduler
|
|
1145
|
+
})
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Provides an inert scheduler without allocating a supervisor fiber.
|
|
1150
|
+
*
|
|
1151
|
+
* @category layers
|
|
1152
|
+
* @since 0.1.0
|
|
1153
|
+
*/
|
|
1154
|
+
export const layerNoop: Layer.Layer<Scheduler> = Layer.succeed(Scheduler)(makeNoop())
|