@smthrs/triggers 0.0.0-stage → 1.0.0-rc.3

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.
Files changed (226) hide show
  1. package/CHANGELOG.md +219 -0
  2. package/LICENSE +21 -0
  3. package/README.md +109 -2
  4. package/dist/cjs/CatchUp.d.ts +25 -0
  5. package/dist/cjs/CatchUp.d.ts.map +1 -0
  6. package/dist/cjs/CatchUp.js +72 -0
  7. package/dist/cjs/CatchUp.js.map +7 -0
  8. package/dist/cjs/Channel.d.ts +108 -0
  9. package/dist/cjs/Channel.d.ts.map +1 -0
  10. package/dist/cjs/Channel.js +25 -0
  11. package/dist/cjs/Channel.js.map +7 -0
  12. package/dist/cjs/ClaimDecision.d.ts +183 -0
  13. package/dist/cjs/ClaimDecision.d.ts.map +1 -0
  14. package/dist/cjs/ClaimDecision.js +144 -0
  15. package/dist/cjs/ClaimDecision.js.map +7 -0
  16. package/dist/cjs/Cron.d.ts +91 -0
  17. package/dist/cjs/Cron.d.ts.map +1 -0
  18. package/dist/cjs/Cron.js +108 -0
  19. package/dist/cjs/Cron.js.map +7 -0
  20. package/dist/cjs/DispatchReader.d.ts +77 -0
  21. package/dist/cjs/DispatchReader.d.ts.map +1 -0
  22. package/dist/cjs/DispatchReader.js +121 -0
  23. package/dist/cjs/DispatchReader.js.map +7 -0
  24. package/dist/cjs/Overlap.d.ts +40 -0
  25. package/dist/cjs/Overlap.d.ts.map +1 -0
  26. package/dist/cjs/Overlap.js +37 -0
  27. package/dist/cjs/Overlap.js.map +7 -0
  28. package/dist/cjs/Schedule.d.ts +96 -0
  29. package/dist/cjs/Schedule.d.ts.map +1 -0
  30. package/dist/cjs/Schedule.js +61 -0
  31. package/dist/cjs/Schedule.js.map +7 -0
  32. package/dist/cjs/Scheduler.d.ts +248 -0
  33. package/dist/cjs/Scheduler.d.ts.map +1 -0
  34. package/dist/cjs/Scheduler.js +647 -0
  35. package/dist/cjs/Scheduler.js.map +7 -0
  36. package/dist/cjs/SqlTriggerStore.d.ts +29 -0
  37. package/dist/cjs/SqlTriggerStore.d.ts.map +1 -0
  38. package/dist/cjs/SqlTriggerStore.js +483 -0
  39. package/dist/cjs/SqlTriggerStore.js.map +7 -0
  40. package/dist/cjs/Trigger.d.ts +81 -0
  41. package/dist/cjs/Trigger.d.ts.map +1 -0
  42. package/dist/cjs/Trigger.js +54 -0
  43. package/dist/cjs/Trigger.js.map +7 -0
  44. package/dist/cjs/TriggerError.d.ts +58 -0
  45. package/dist/cjs/TriggerError.d.ts.map +1 -0
  46. package/dist/cjs/TriggerError.js +83 -0
  47. package/dist/cjs/TriggerError.js.map +7 -0
  48. package/dist/cjs/TriggerStore.d.ts +411 -0
  49. package/dist/cjs/TriggerStore.d.ts.map +1 -0
  50. package/dist/cjs/TriggerStore.js +127 -0
  51. package/dist/cjs/TriggerStore.js.map +7 -0
  52. package/dist/cjs/Webhook.d.ts +118 -0
  53. package/dist/cjs/Webhook.d.ts.map +1 -0
  54. package/dist/cjs/Webhook.js +153 -0
  55. package/dist/cjs/Webhook.js.map +7 -0
  56. package/dist/cjs/index.d.ts +30 -0
  57. package/dist/cjs/index.d.ts.map +1 -0
  58. package/dist/cjs/index.js +57 -0
  59. package/dist/cjs/index.js.map +7 -0
  60. package/dist/cjs/internal/ActiveRuns.d.ts +51 -0
  61. package/dist/cjs/internal/ActiveRuns.d.ts.map +1 -0
  62. package/dist/cjs/internal/ActiveRuns.js +56 -0
  63. package/dist/cjs/internal/ActiveRuns.js.map +7 -0
  64. package/dist/cjs/internal/DueOccurrences.d.ts +32 -0
  65. package/dist/cjs/internal/DueOccurrences.d.ts.map +1 -0
  66. package/dist/cjs/internal/DueOccurrences.js +80 -0
  67. package/dist/cjs/internal/DueOccurrences.js.map +7 -0
  68. package/dist/cjs/internal/WallClock.d.ts +59 -0
  69. package/dist/cjs/internal/WallClock.d.ts.map +1 -0
  70. package/dist/cjs/internal/WallClock.js +132 -0
  71. package/dist/cjs/internal/WallClock.js.map +7 -0
  72. package/dist/cjs/migrations/0001_triggers.d.ts +15 -0
  73. package/dist/cjs/migrations/0001_triggers.d.ts.map +1 -0
  74. package/dist/cjs/migrations/0001_triggers.js +64 -0
  75. package/dist/cjs/migrations/0001_triggers.js.map +7 -0
  76. package/dist/cjs/migrations/0002_reservation_lease.d.ts +15 -0
  77. package/dist/cjs/migrations/0002_reservation_lease.d.ts.map +1 -0
  78. package/dist/cjs/migrations/0002_reservation_lease.js +41 -0
  79. package/dist/cjs/migrations/0002_reservation_lease.js.map +7 -0
  80. package/dist/cjs/migrations/0003_heartbeat.d.ts +16 -0
  81. package/dist/cjs/migrations/0003_heartbeat.d.ts.map +1 -0
  82. package/dist/cjs/migrations/0003_heartbeat.js +44 -0
  83. package/dist/cjs/migrations/0003_heartbeat.js.map +7 -0
  84. package/dist/cjs/migrations/0004_fire_run_index.d.ts +17 -0
  85. package/dist/cjs/migrations/0004_fire_run_index.d.ts.map +1 -0
  86. package/dist/cjs/migrations/0004_fire_run_index.js +40 -0
  87. package/dist/cjs/migrations/0004_fire_run_index.js.map +7 -0
  88. package/dist/cjs/migrations/index.d.ts +33 -0
  89. package/dist/cjs/migrations/index.d.ts.map +1 -0
  90. package/dist/cjs/migrations/index.js +50 -0
  91. package/dist/cjs/migrations/index.js.map +7 -0
  92. package/dist/cjs/package.json +1 -0
  93. package/dist/cjs/test/TestTriggers.d.ts +12 -0
  94. package/dist/cjs/test/TestTriggers.d.ts.map +1 -0
  95. package/dist/cjs/test/TestTriggers.js +421 -0
  96. package/dist/cjs/test/TestTriggers.js.map +7 -0
  97. package/dist/esm/CatchUp.d.ts +25 -0
  98. package/dist/esm/CatchUp.d.ts.map +1 -0
  99. package/dist/esm/CatchUp.js +52 -0
  100. package/dist/esm/CatchUp.js.map +1 -0
  101. package/dist/esm/Channel.d.ts +108 -0
  102. package/dist/esm/Channel.d.ts.map +1 -0
  103. package/dist/esm/Channel.js +19 -0
  104. package/dist/esm/Channel.js.map +1 -0
  105. package/dist/esm/ClaimDecision.d.ts +183 -0
  106. package/dist/esm/ClaimDecision.d.ts.map +1 -0
  107. package/dist/esm/ClaimDecision.js +172 -0
  108. package/dist/esm/ClaimDecision.js.map +1 -0
  109. package/dist/esm/Cron.d.ts +91 -0
  110. package/dist/esm/Cron.d.ts.map +1 -0
  111. package/dist/esm/Cron.js +132 -0
  112. package/dist/esm/Cron.js.map +1 -0
  113. package/dist/esm/DispatchReader.d.ts +77 -0
  114. package/dist/esm/DispatchReader.d.ts.map +1 -0
  115. package/dist/esm/DispatchReader.js +152 -0
  116. package/dist/esm/DispatchReader.js.map +1 -0
  117. package/dist/esm/Overlap.d.ts +40 -0
  118. package/dist/esm/Overlap.d.ts.map +1 -0
  119. package/dist/esm/Overlap.js +31 -0
  120. package/dist/esm/Overlap.js.map +1 -0
  121. package/dist/esm/Schedule.d.ts +96 -0
  122. package/dist/esm/Schedule.d.ts.map +1 -0
  123. package/dist/esm/Schedule.js +73 -0
  124. package/dist/esm/Schedule.js.map +1 -0
  125. package/dist/esm/Scheduler.d.ts +248 -0
  126. package/dist/esm/Scheduler.d.ts.map +1 -0
  127. package/dist/esm/Scheduler.js +734 -0
  128. package/dist/esm/Scheduler.js.map +1 -0
  129. package/dist/esm/SqlTriggerStore.d.ts +29 -0
  130. package/dist/esm/SqlTriggerStore.d.ts.map +1 -0
  131. package/dist/esm/SqlTriggerStore.js +483 -0
  132. package/dist/esm/SqlTriggerStore.js.map +1 -0
  133. package/dist/esm/Trigger.d.ts +81 -0
  134. package/dist/esm/Trigger.d.ts.map +1 -0
  135. package/dist/esm/Trigger.js +56 -0
  136. package/dist/esm/Trigger.js.map +1 -0
  137. package/dist/esm/TriggerError.d.ts +58 -0
  138. package/dist/esm/TriggerError.d.ts.map +1 -0
  139. package/dist/esm/TriggerError.js +110 -0
  140. package/dist/esm/TriggerError.js.map +1 -0
  141. package/dist/esm/TriggerStore.d.ts +411 -0
  142. package/dist/esm/TriggerStore.d.ts.map +1 -0
  143. package/dist/esm/TriggerStore.js +209 -0
  144. package/dist/esm/TriggerStore.js.map +1 -0
  145. package/dist/esm/Webhook.d.ts +118 -0
  146. package/dist/esm/Webhook.d.ts.map +1 -0
  147. package/dist/esm/Webhook.js +186 -0
  148. package/dist/esm/Webhook.js.map +1 -0
  149. package/dist/esm/index.d.ts +30 -0
  150. package/dist/esm/index.d.ts.map +1 -0
  151. package/dist/esm/index.js +30 -0
  152. package/dist/esm/index.js.map +1 -0
  153. package/dist/esm/internal/ActiveRuns.d.ts +51 -0
  154. package/dist/esm/internal/ActiveRuns.d.ts.map +1 -0
  155. package/dist/esm/internal/ActiveRuns.js +34 -0
  156. package/dist/esm/internal/ActiveRuns.js.map +1 -0
  157. package/dist/esm/internal/DueOccurrences.d.ts +32 -0
  158. package/dist/esm/internal/DueOccurrences.d.ts.map +1 -0
  159. package/dist/esm/internal/DueOccurrences.js +55 -0
  160. package/dist/esm/internal/DueOccurrences.js.map +1 -0
  161. package/dist/esm/internal/WallClock.d.ts +59 -0
  162. package/dist/esm/internal/WallClock.d.ts.map +1 -0
  163. package/dist/esm/internal/WallClock.js +155 -0
  164. package/dist/esm/internal/WallClock.js.map +1 -0
  165. package/dist/esm/migrations/0001_triggers.d.ts +15 -0
  166. package/dist/esm/migrations/0001_triggers.d.ts.map +1 -0
  167. package/dist/esm/migrations/0001_triggers.js +42 -0
  168. package/dist/esm/migrations/0001_triggers.js.map +1 -0
  169. package/dist/esm/migrations/0002_reservation_lease.d.ts +15 -0
  170. package/dist/esm/migrations/0002_reservation_lease.d.ts.map +1 -0
  171. package/dist/esm/migrations/0002_reservation_lease.js +19 -0
  172. package/dist/esm/migrations/0002_reservation_lease.js.map +1 -0
  173. package/dist/esm/migrations/0003_heartbeat.d.ts +16 -0
  174. package/dist/esm/migrations/0003_heartbeat.d.ts.map +1 -0
  175. package/dist/esm/migrations/0003_heartbeat.js +23 -0
  176. package/dist/esm/migrations/0003_heartbeat.js.map +1 -0
  177. package/dist/esm/migrations/0004_fire_run_index.d.ts +17 -0
  178. package/dist/esm/migrations/0004_fire_run_index.d.ts.map +1 -0
  179. package/dist/esm/migrations/0004_fire_run_index.js +20 -0
  180. package/dist/esm/migrations/0004_fire_run_index.js.map +1 -0
  181. package/dist/esm/migrations/index.d.ts +33 -0
  182. package/dist/esm/migrations/index.d.ts.map +1 -0
  183. package/dist/esm/migrations/index.js +37 -0
  184. package/dist/esm/migrations/index.js.map +1 -0
  185. package/dist/esm/test/TestTriggers.d.ts +12 -0
  186. package/dist/esm/test/TestTriggers.d.ts.map +1 -0
  187. package/dist/esm/test/TestTriggers.js +439 -0
  188. package/dist/esm/test/TestTriggers.js.map +1 -0
  189. package/docs/README.md +140 -0
  190. package/docs/api.md +1186 -0
  191. package/docs/concepts/channels.md +103 -0
  192. package/docs/concepts/claim-protocol.md +229 -0
  193. package/docs/concepts/policies.md +124 -0
  194. package/docs/concepts/schedules.md +134 -0
  195. package/docs/guides/choose-a-policy.md +113 -0
  196. package/docs/guides/declare-a-trigger.md +136 -0
  197. package/docs/guides/ingest-a-webhook.md +164 -0
  198. package/docs/guides/run-the-scheduler.md +173 -0
  199. package/docs/guides/testing.md +157 -0
  200. package/docs/installation.md +81 -0
  201. package/docs/quickstart.md +163 -0
  202. package/docs/troubleshooting.md +281 -0
  203. package/package.json +241 -3
  204. package/src/CatchUp.ts +73 -0
  205. package/src/Channel.ts +121 -0
  206. package/src/ClaimDecision.ts +303 -0
  207. package/src/Cron.ts +173 -0
  208. package/src/DispatchReader.ts +179 -0
  209. package/src/Overlap.ts +53 -0
  210. package/src/Schedule.ts +112 -0
  211. package/src/Scheduler.ts +1154 -0
  212. package/src/SqlTriggerStore.ts +638 -0
  213. package/src/Trigger.ts +84 -0
  214. package/src/TriggerError.ts +129 -0
  215. package/src/TriggerStore.ts +514 -0
  216. package/src/Webhook.ts +270 -0
  217. package/src/index.ts +30 -0
  218. package/src/internal/ActiveRuns.ts +83 -0
  219. package/src/internal/DueOccurrences.ts +101 -0
  220. package/src/internal/WallClock.ts +172 -0
  221. package/src/migrations/0001_triggers.ts +43 -0
  222. package/src/migrations/0002_reservation_lease.ts +20 -0
  223. package/src/migrations/0003_heartbeat.ts +24 -0
  224. package/src/migrations/0004_fire_run_index.ts +21 -0
  225. package/src/migrations/index.ts +40 -0
  226. package/src/test/TestTriggers.ts +563 -0
@@ -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())