@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,734 @@
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
+ import * as Control from "@smthrs/control/Control";
9
+ import * as Cause from "effect/Cause";
10
+ import * as Clock from "effect/Clock";
11
+ import * as Context from "effect/Context";
12
+ import * as Duration from "effect/Duration";
13
+ import * as Effect from "effect/Effect";
14
+ import * as Fiber from "effect/Fiber";
15
+ import * as Layer from "effect/Layer";
16
+ import * as Metric from "effect/Metric";
17
+ import * as Option from "effect/Option";
18
+ import * as Ref from "effect/Ref";
19
+ import * as Result from "effect/Result";
20
+ import * as Semaphore from "effect/Semaphore";
21
+ import * as ActiveRuns from "./internal/ActiveRuns.js";
22
+ import * as DueOccurrences from "./internal/DueOccurrences.js";
23
+ import { TriggerError } from "./TriggerError.js";
24
+ import { isReservation, reservationOccurrence, TriggerStore } from "./TriggerStore.js";
25
+ /**
26
+ * Injectable scheduled-run launcher.
27
+ *
28
+ * Constructed with {@link makeRunner}, {@link makeNoopRunner}, or
29
+ * {@link layerNoopRunner}; the tag itself carries no constructors, so there is
30
+ * one spelling of each and the module's own `make`/`makeNoop` can only mean
31
+ * the scheduler.
32
+ *
33
+ * @category services
34
+ * @since 0.1.0
35
+ */
36
+ export class Runner extends Context.Service()("flows/triggers/Scheduler/Runner") {
37
+ }
38
+ /**
39
+ * Constructs a scheduled-run launcher.
40
+ *
41
+ * @category constructors
42
+ * @since 0.1.0
43
+ */
44
+ export const makeRunner = (implementation) => Runner.of(implementation);
45
+ /**
46
+ * Constructs a launcher that returns the idempotency key as a terminal run.
47
+ *
48
+ * @category constructors
49
+ * @since 0.1.0
50
+ */
51
+ export const makeNoopRunner = (overrides = {}) => makeRunner({
52
+ start: (input) => Effect.succeed(input.idempotencyKey),
53
+ inspect: () => Effect.succeed("completed"),
54
+ cancel: () => Effect.void,
55
+ ...overrides
56
+ });
57
+ /**
58
+ * Provides the terminal no-op launcher.
59
+ *
60
+ * @category layers
61
+ * @since 0.1.0
62
+ */
63
+ export const layerNoopRunner = (overrides = {}) => Layer.succeed(Runner)(makeNoopRunner(overrides));
64
+ const runnerError = (message, cause) => new TriggerError({
65
+ code: "runner",
66
+ message,
67
+ ...(cause === undefined ? {} : { cause })
68
+ });
69
+ const translateRunnerFailure = (effect, message) => effect.pipe(Effect.mapError((error) => error instanceof TriggerError ? error : runnerError(message, error)), Effect.catchDefect((defect) => Effect.fail(runnerError(message, defect))));
70
+ const invalidOption = (field, requirement) => new TriggerError({
71
+ code: "invalid_options",
72
+ message: `${field} must be ${requirement}`,
73
+ path: field
74
+ });
75
+ const duration = (input, field) => Option.match(Duration.fromInput(input), {
76
+ onNone: () => Effect.fail(invalidOption(field, "a valid Effect duration")),
77
+ onSome: (value) =>
78
+ // Zero polls a CPU-tight loop and an infinite interval never completes,
79
+ // and `Duration.fromInput` accepts both.
80
+ Duration.isFinite(value) && Duration.toMillis(value) > 0
81
+ ? Effect.succeed(value)
82
+ : Effect.fail(invalidOption(field, "a finite positive duration"))
83
+ });
84
+ const stoppedMessage = (runId, state) => {
85
+ switch (state) {
86
+ case "failed":
87
+ return `Run ${runId} failed`;
88
+ case "cancelled":
89
+ return `Run ${runId} was cancelled`;
90
+ case "missing":
91
+ return `Run ${runId} is unknown to the runner`;
92
+ }
93
+ };
94
+ const runIdFromReceipt = (receipt) => {
95
+ switch (receipt._tag) {
96
+ case "Accepted":
97
+ case "AlreadyApplied":
98
+ return receipt.runId === undefined
99
+ ? Effect.fail(runnerError(`Control ${receipt._tag} receipt did not include a run id`))
100
+ : Effect.succeed(receipt.runId);
101
+ case "Terminal":
102
+ return Effect.succeed(receipt.runId);
103
+ case "Conflict":
104
+ return Effect.fail(runnerError(`Control rejected the scheduled run: ${receipt.message}`));
105
+ }
106
+ };
107
+ /**
108
+ * How many times a parked plan is re-offered before the launch is abandoned.
109
+ *
110
+ * The delay doubles from one second, so the eighth attempt lands a little over
111
+ * two minutes in. A plan nobody approves used to be re-offered once a second
112
+ * for the life of the scope while the launch reservation behind it expired.
113
+ *
114
+ * @category constants
115
+ * @since 0.1.0
116
+ */
117
+ export const parkedAttempts = 8;
118
+ const runApprovedPlan = (control, plan, key, attempt) => control.run({
119
+ _tag: "Plan",
120
+ planId: plan.planId,
121
+ digest: plan.digest,
122
+ envelope: plan.envelope,
123
+ idempotencyKey: key
124
+ }).pipe(Effect.mapError((error) => runnerError("Control could not launch the scheduled run", error)), Effect.flatMap((receipt) => {
125
+ if (receipt._tag !== "Parked")
126
+ return runIdFromReceipt(receipt);
127
+ if (attempt >= parkedAttempts) {
128
+ return Effect.fail(runnerError(`Control plan ${plan.planId} is still parked awaiting approval after ${attempt} attempts`));
129
+ }
130
+ return Effect.logInfo(`A scheduled plan is parked awaiting approval, attempt ${attempt}`).pipe(Effect.andThen(Effect.sleep(Duration.millis(1000 * 2 ** (attempt - 1)))), Effect.andThen(Effect.suspend(() => runApprovedPlan(control, plan, key, attempt + 1))));
131
+ }));
132
+ /**
133
+ * The statuses that mean a run has stopped for good.
134
+ *
135
+ * Liveness is stated as the complement of this set rather than as a list of
136
+ * live statuses, so a status Control adds later is treated as live until this
137
+ * package says otherwise. Reading it the other way round is what dropped
138
+ * `accepted`, the status every run holds between its claim and its first
139
+ * executed step (`packages/smithers/control/src/ControlLive.ts`).
140
+ */
141
+ const settledState = (status) => status === "cancelled" || status === "completed" || status === "failed" ? status : undefined;
142
+ /**
143
+ * Runner layer backed by the authoritative Control plan/run/list/cancel API.
144
+ *
145
+ * A parked plan waits for approval and retries the same idempotent run request
146
+ * a bounded number of times; this adapter never approves it or reconstructs an
147
+ * execution envelope.
148
+ *
149
+ * @category layers
150
+ * @since 0.1.0
151
+ */
152
+ export const layerControlRunner = Layer.effect(Runner, Effect.gen(function* () {
153
+ const control = yield* Control.Control;
154
+ return makeRunner({
155
+ start: (input) => translateRunnerFailure(control.plan(input).pipe(Effect.flatMap((plan) => runApprovedPlan(control, plan, input.idempotencyKey, 1))), "Control could not launch the scheduled run"),
156
+ inspect: (runId) => translateRunnerFailure(control.list({ _tag: "runs", filters: { runId }, limit: 1 }).pipe(Effect.map((response) => {
157
+ const run = response._tag === "runs"
158
+ ? response.items.find((candidate) => candidate.runId === runId)
159
+ : undefined;
160
+ if (run === undefined)
161
+ return "missing";
162
+ return settledState(run.status) ?? "active";
163
+ })), `Control could not inspect run ${runId}`),
164
+ cancel: (runId) => translateRunnerFailure(control.cancel({
165
+ runId,
166
+ idempotencyKey: `trigger-cancel:${runId}`
167
+ }).pipe(Effect.flatMap((receipt) => {
168
+ switch (receipt._tag) {
169
+ case "Accepted":
170
+ case "AlreadyApplied":
171
+ case "Terminal":
172
+ return Effect.void;
173
+ case "Conflict":
174
+ return Effect.fail(runnerError(`Control refused scheduled cancellation: ${receipt.message}`));
175
+ case "Parked":
176
+ return Effect.fail(runnerError(`Control returned a Parked receipt for cancellation of run ${runId}`));
177
+ }
178
+ })), `Control could not cancel run ${runId}`)
179
+ });
180
+ }));
181
+ /**
182
+ * The heartbeat host name a scheduler records under when its options name
183
+ * none.
184
+ *
185
+ * @category constants
186
+ * @since 1.0.0-rc.0
187
+ */
188
+ export const defaultHost = "local";
189
+ /**
190
+ * How many triggers one tick processes at the same time when its options name
191
+ * no `concurrency`.
192
+ *
193
+ * @category constants
194
+ * @since 1.0.0-rc.0
195
+ */
196
+ export const defaultConcurrency = 4;
197
+ /**
198
+ * How long settled fire ledger rows are kept when the options name no
199
+ * `fireRetention`: thirty days.
200
+ *
201
+ * @category constants
202
+ * @since 1.0.0-rc.1
203
+ */
204
+ export const defaultFireRetention = Duration.days(30);
205
+ const pruneEvery = 60 * 60 * 1_000;
206
+ /**
207
+ * Histogram boundaries, in milliseconds, of `smithers.triggers.tick_duration_ms`
208
+ * and `smithers.triggers.launch_duration_ms`.
209
+ *
210
+ * @category constants
211
+ * @since 1.0.0-rc.1
212
+ */
213
+ export const durationBoundaries = [10, 100, 1_000, 10_000, 60_000, 300_000];
214
+ const firesRecorded = Metric.counter("smithers.triggers.fires");
215
+ const tickDuration = Metric.histogram("smithers.triggers.tick_duration_ms", { boundaries: durationBoundaries });
216
+ const launchDuration = Metric.histogram("smithers.triggers.launch_duration_ms", { boundaries: durationBoundaries });
217
+ const timed = (histogram, effect) => Effect.gen(function* () {
218
+ const started = yield* Clock.currentTimeMillis;
219
+ return yield* effect.pipe(Effect.ensuring(Effect.flatMap(Clock.currentTimeMillis, (ended) => Metric.update(histogram, ended - started))));
220
+ });
221
+ const deadline = (effect, limit, what) => Effect.timeoutOrElse(effect, {
222
+ duration: limit,
223
+ orElse: () => Effect.fail(new TriggerError({
224
+ code: "runner_timeout",
225
+ message: `${what} did not answer within ${Duration.toMillis(limit)} ms`
226
+ }))
227
+ });
228
+ /**
229
+ * The live trigger scheduler.
230
+ *
231
+ * @category services
232
+ * @since 0.1.0
233
+ */
234
+ export class Scheduler extends Context.Service()("flows/triggers/Scheduler") {
235
+ }
236
+ /** Stable Control request identity for one manual or scheduled occurrence.
237
+ * @category constructors
238
+ * @since 0.1.0
239
+ */
240
+ export const idempotencyKey = (triggerId, occurrence) => `${triggerId}:${new Date(occurrence).toISOString()}`;
241
+ /**
242
+ * Runs one trigger's work and reports whether it finished.
243
+ *
244
+ * A tick walks the due triggers in id order, so an aborting trigger used to
245
+ * take every trigger after it alphabetically down with it, and the supervisor
246
+ * above discarded the error unread. The cause is logged where the trigger it
247
+ * belongs to is still known. Interruption is re-raised: it is the scope
248
+ * closing, not a trigger failing.
249
+ */
250
+ const attempted = (annotations, work, effect) => Effect.catchCause(Effect.as(effect, true), (cause) => Cause.hasInterrupts(cause)
251
+ ? Effect.failCause(cause)
252
+ : Effect.as(Effect.annotateLogs(Effect.logWarning(`A trigger ${work} failed`, cause), annotations), false));
253
+ const isolate = (annotations, work, effect) => Effect.asVoid(attempted(annotations, work, effect));
254
+ /**
255
+ * Constructs a scheduler service in the current Scope.
256
+ *
257
+ * @category constructors
258
+ * @since 0.1.0
259
+ */
260
+ export const make = (options = {}) => Effect.gen(function* () {
261
+ const parentScope = yield* Effect.scope;
262
+ const providedStore = yield* TriggerStore;
263
+ // Every fire the ledger records is counted by outcome, so a dropped,
264
+ // skipped, or failed occurrence is visible without reading the ledger.
265
+ const store = {
266
+ ...providedStore,
267
+ recordResult: (result) => providedStore.recordResult(result).pipe(Effect.tap(() => Metric.update(firesRecorded.pipe(Metric.withAttributes({ outcome: result.outcome })), 1)))
268
+ };
269
+ const active = yield* ActiveRuns.make;
270
+ const observedAt = yield* Ref.make(new Map());
271
+ const semaphore = yield* Semaphore.make(1);
272
+ const runPollInterval = yield* duration(options.runPollInterval ?? "15 seconds", "runPollInterval");
273
+ const concurrency = options.concurrency ?? defaultConcurrency;
274
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
275
+ return yield* Effect.fail(invalidOption("concurrency", "a positive integer"));
276
+ }
277
+ const startTimeout = yield* duration(options.startTimeout ?? "4 minutes", "startTimeout");
278
+ const inspectTimeout = yield* duration(options.inspectTimeout ?? "30 seconds", "inspectTimeout");
279
+ const cancelTimeout = yield* duration(options.cancelTimeout ?? "30 seconds", "cancelTimeout");
280
+ const fireRetention = yield* duration(options.fireRetention ?? defaultFireRetention, "fireRetention");
281
+ const lastPrunedAt = yield* Ref.make(undefined);
282
+ // Every runner call carries a deadline. A runtime that never answers used
283
+ // to hold the launch, and the tick waiting on it, for the life of the
284
+ // scope with nothing written down.
285
+ const provided = yield* Runner;
286
+ const runner = {
287
+ start: (input) => deadline(provided.start(input), startTimeout, `Runner.start for ${input.idempotencyKey}`),
288
+ inspect: (runId) => deadline(provided.inspect(runId), inspectTimeout, `Runner.inspect for run ${runId}`),
289
+ cancel: (runId) => deadline(provided.cancel(runId), cancelTimeout, `Runner.cancel for run ${runId}`)
290
+ };
291
+ // The watermark only moves forward, and only past occurrences this process
292
+ // finished dispatching. Advancing it before the work is what silently lost
293
+ // an occurrence whenever a claim or a dispatch failed.
294
+ const observe = (triggerId, occurrence) => Ref.update(observedAt, (current) => {
295
+ const existing = current.get(triggerId);
296
+ if (existing !== undefined && existing >= occurrence)
297
+ return current;
298
+ return new Map(current).set(triggerId, occurrence);
299
+ });
300
+ // A reservation is not a run: the Runner has never heard of it, and asking
301
+ // answers "not active" for a launch that is still in flight. Its lease is
302
+ // the only thing entitled to release it, in either branch.
303
+ const inspectStored = (runId) => isReservation(runId) ? Effect.succeed("active") : runner.inspect(runId);
304
+ const occurrenceOf = (triggerId, runId) => {
305
+ const reserved = reservationOccurrence(runId);
306
+ if (reserved !== undefined)
307
+ return Effect.succeed(reserved);
308
+ return store.activeOccurrence(triggerId, runId).pipe(Effect.map((occurrence) => Option.isSome(occurrence) ? occurrence.value : Number.NEGATIVE_INFINITY));
309
+ };
310
+ // Only `completed` is a completed occurrence. A failed, cancelled, or
311
+ // vanished run used to be recorded as `completed` because the runner
312
+ // answered only "not active". Recording the result also clears the
313
+ // matching active run atomically.
314
+ const recordSettled = (triggerId, occurrence, runId, state) => state === "completed"
315
+ ? store.recordResult({ triggerId, occurrence, outcome: "completed", runId })
316
+ : store.recordResult({ triggerId, occurrence, outcome: "failed", runId, error: stoppedMessage(runId, state) });
317
+ const settleRecovered = (triggerId, occurrence, runId, state) => Number.isFinite(occurrence)
318
+ ? recordSettled(triggerId, occurrence, runId, state)
319
+ : store.clearActive(triggerId, runId);
320
+ // The listed row already answers what the store would: with no run and no
321
+ // reservation on it, `activeRun` reads an idle lease, writes nothing, and
322
+ // answers `None`. Asking anyway cost one write transaction per trigger per
323
+ // tick, on the tick where every trigger is idle.
324
+ const storedActive = (triggerId, held) => held !== undefined && held.activeRunId === undefined
325
+ ? Effect.succeed(Option.none())
326
+ : store.activeRun(triggerId);
327
+ // Adopts the run the store holds for a trigger that has no local entry:
328
+ // settle it if it has already stopped, otherwise take the entry for it.
329
+ const recoverStored = (trigger, runId) => Effect.gen(function* () {
330
+ const occurrence = yield* occurrenceOf(trigger.id, runId);
331
+ const state = yield* inspectStored(runId);
332
+ if (state !== "active") {
333
+ yield* settleRecovered(trigger.id, occurrence, runId, state);
334
+ return undefined;
335
+ }
336
+ const recovered = { occurrence, runId };
337
+ yield* active.take(trigger.id, recovered);
338
+ return recovered;
339
+ });
340
+ const resolveActive = (trigger, held) => Effect.gen(function* () {
341
+ const local = yield* active.get(trigger.id);
342
+ if (local !== undefined) {
343
+ // The finalizer detaches the fiber when monitoring ends. An entry
344
+ // without a fiber was recovered or could no longer be inspected;
345
+ // only the runtime can say whether that run is still going.
346
+ if (local.fiber !== undefined)
347
+ return local;
348
+ if (!isReservation(local.runId)) {
349
+ const state = yield* inspectStored(local.runId);
350
+ if (state === "active")
351
+ return local;
352
+ yield* settleRecovered(trigger.id, local.occurrence, local.runId, state);
353
+ yield* active.remove(trigger.id, local.occurrence);
354
+ }
355
+ else {
356
+ // A recovered reservation has no monitor that can remove it. Ask
357
+ // the store on every tick so its lease can expire and re-arm the
358
+ // occurrence instead of pinning this local cache forever.
359
+ const stored = yield* storedActive(trigger.id, held);
360
+ if (Option.isNone(stored)) {
361
+ yield* active.remove(trigger.id, local.occurrence);
362
+ return undefined;
363
+ }
364
+ if (stored.value === local.runId)
365
+ return local;
366
+ yield* active.remove(trigger.id, local.occurrence);
367
+ return yield* recoverStored(trigger, stored.value);
368
+ }
369
+ }
370
+ const stored = yield* storedActive(trigger.id, held);
371
+ if (Option.isNone(stored))
372
+ return undefined;
373
+ return yield* recoverStored(trigger, stored.value);
374
+ });
375
+ const recordFailed = (trigger, occurrence, error, runId) => store.recordResult({
376
+ triggerId: trigger.id,
377
+ occurrence,
378
+ outcome: "failed",
379
+ error: error.message,
380
+ runId
381
+ });
382
+ // An inspection error is not evidence that a run stopped. Retry three
383
+ // times, doubling the poll interval up to a minute, then let the next tick
384
+ // inspect the durable owner again. Interruption must still close the scope.
385
+ const inspectRun = (triggerId, runId) => Effect.gen(function* () {
386
+ for (let attempt = 0;; attempt++) {
387
+ const inspected = yield* runner.inspect(runId).pipe(Effect.map(Option.some), Effect.catchCause((cause) => Cause.hasInterrupts(cause) ?
388
+ Effect.failCause(cause) :
389
+ Effect.logWarning("A trigger run inspection failed", cause).pipe(Effect.annotateLogs({ triggerId, runId, attempt: String(attempt + 1) }), Effect.as(Option.none()))));
390
+ if (Option.isSome(inspected))
391
+ return inspected;
392
+ if (attempt === 3) {
393
+ yield* Effect.logWarning("A trigger run monitor detached after inspection retries").pipe(Effect.annotateLogs({ triggerId, runId }));
394
+ return Option.none();
395
+ }
396
+ yield* Effect.sleep(Math.min(Duration.toMillis(runPollInterval) * 2 ** attempt, 60_000));
397
+ }
398
+ });
399
+ const launch = (trigger, occurrence, reservation, preserveBuffered) => Effect.gen(function* () {
400
+ // The occurrence takes the entry under its reservation before the
401
+ // runner hears of it, so every write below is fenced on this launch.
402
+ yield* active.take(trigger.id, { occurrence, runId: reservation });
403
+ let runId;
404
+ let launchRecorded = false;
405
+ let completed = false;
406
+ const lifecycle = Effect.gen(function* () {
407
+ runId = yield* timed(launchDuration, runner.start({
408
+ flowId: trigger.flowId,
409
+ input: trigger.input,
410
+ idempotencyKey: idempotencyKey(trigger.id, occurrence)
411
+ })).pipe(Effect.withSpan("Scheduler.launch", {
412
+ attributes: { triggerId: trigger.id, occurrence: new Date(occurrence).toISOString() }
413
+ }));
414
+ const startedRunId = runId;
415
+ yield* active.update(trigger.id, occurrence, (entry) => ({ ...entry, runId: startedRunId }));
416
+ yield* store.recordResult({
417
+ triggerId: trigger.id,
418
+ occurrence,
419
+ outcome: "launched",
420
+ runId,
421
+ reservationId: reservation
422
+ });
423
+ launchRecorded = true;
424
+ let state;
425
+ while (true) {
426
+ const inspected = yield* inspectRun(trigger.id, runId);
427
+ if (Option.isNone(inspected))
428
+ return;
429
+ state = inspected.value;
430
+ if (state !== "active")
431
+ break;
432
+ yield* Effect.sleep(runPollInterval);
433
+ }
434
+ yield* recordSettled(trigger.id, occurrence, runId, state);
435
+ completed = true;
436
+ }).pipe(Effect.catch((error) => Effect.gen(function* () {
437
+ if (launchRecorded) {
438
+ yield* Effect.logWarning("A trigger run monitor could not persist completion", error).pipe(Effect.annotateLogs({ triggerId: trigger.id, runId: runId }));
439
+ return;
440
+ }
441
+ if (error.code === "stale_owner" && runId !== undefined) {
442
+ yield* Effect.logWarning("A trigger launch lost its reservation", error).pipe(Effect.annotateLogs({ triggerId: trigger.id, occurrence: String(occurrence) }));
443
+ const losingRunId = runId;
444
+ yield* isolate({ triggerId: trigger.id, runId }, "losing run reconciliation", Effect.gen(function* () {
445
+ const held = yield* store.inspect(trigger.id);
446
+ // An idempotent retry may have committed this same run.
447
+ // An unfinished retry of the same occurrence will adopt it.
448
+ if (held.activeRunId === losingRunId ||
449
+ (held.activeRunId !== undefined && reservationOccurrence(held.activeRunId) === occurrence))
450
+ return;
451
+ yield* runner.cancel(losingRunId);
452
+ })).pipe(Effect.ignore);
453
+ return;
454
+ }
455
+ if (preserveBuffered) {
456
+ // A buffered occurrence was taken off the buffer by the claim
457
+ // that led here. Re-arm it under this reservation so the next
458
+ // tick retries it rather than losing it.
459
+ yield* isolate({ triggerId: trigger.id }, "buffered launch compensation", store.restorePending({ triggerId: trigger.id, occurrence, reservationId: reservation })).pipe(Effect.ignore);
460
+ }
461
+ else {
462
+ // A start that answered too late is ambiguous: the runtime may
463
+ // hold the run. Keep the occurrence pending exactly as when the
464
+ // launched result failed to persist, so the next tick retries
465
+ // under the same idempotency key and the runtime's durable
466
+ // deduplication answers with the same run.
467
+ if (runId !== undefined || error.code === "runner_timeout") {
468
+ yield* isolate({ triggerId: trigger.id }, "launch compensation", store.restorePending({
469
+ triggerId: trigger.id,
470
+ occurrence,
471
+ reservationId: reservation
472
+ })).pipe(Effect.ignore);
473
+ }
474
+ else {
475
+ yield* recordFailed(trigger, occurrence, error, reservation).pipe(Effect.ignore);
476
+ }
477
+ }
478
+ yield* Effect.logWarning("A trigger launch failed", error).pipe(Effect.annotateLogs({ triggerId: trigger.id, occurrence: String(occurrence) }));
479
+ })),
480
+ // Interrupting this fiber detaches the monitor; it never cancels the
481
+ // run. The run is durable and outlives this process, so a deploy or
482
+ // any other scope closure must leave it alone: the next incarnation
483
+ // re-attaches through `resolveActive`. Cancellation is a deliberate
484
+ // act and belongs to `cancelActive` alone.
485
+ Effect.ensuring(Effect.suspend(() => launchRecorded && !completed
486
+ ? active.update(trigger.id, occurrence, (entry) => ({ ...entry, fiber: undefined }))
487
+ : active.remove(trigger.id, occurrence))));
488
+ // The tick does not wait for the launch. The claim already fenced
489
+ // the occurrence under its reservation, and every failure below is
490
+ // compensated in the lifecycle itself. A tick that waited held every
491
+ // other trigger for as long as one plan sat parked or one runner was
492
+ // slow, and a boundary that passed meanwhile was never claimed.
493
+ const fiber = yield* Effect.forkIn(Effect.scoped(lifecycle).pipe(Effect.onExit((exit) => exit._tag === "Failure" && !Cause.hasInterrupts(exit.cause)
494
+ ? Effect.logWarning(launchRecorded ? "A trigger run monitor failed" : "A trigger launch failed", exit.cause).pipe(Effect.annotateLogs({
495
+ triggerId: trigger.id,
496
+ occurrence: String(occurrence),
497
+ runId: runId ?? reservation
498
+ }))
499
+ : Effect.void)), parentScope, { startImmediately: true });
500
+ // The monitor is recorded against the entry this occurrence claimed.
501
+ // `startImmediately` can finish or detach before the fork returns.
502
+ // Never attach a finished fiber to an entry awaiting tick recovery.
503
+ if (fiber.pollUnsafe() === undefined) {
504
+ yield* active.update(trigger.id, occurrence, (entry) => ({ ...entry, fiber }));
505
+ }
506
+ });
507
+ // Only a claim that named the run it is superseding gets here, so the run
508
+ // id is known. The monitor, on the other hand, may not exist: the run can
509
+ // belong to a scheduler this one only knows through the store. Cancelling
510
+ // is a deliberate act and happens here alone; a reservation has no run for
511
+ // the runtime to cancel, and an occurrence this process never saw has none
512
+ // of its own to record the supersession against.
513
+ const cancelActive = (trigger, prior, replacementOccurrence, replacementReservation, queueReplacement) => Effect.gen(function* () {
514
+ if (!isReservation(prior.runId)) {
515
+ yield* runner.cancel(prior.runId).pipe(Effect.catch((error) => {
516
+ // The claim already replaced the prior run with the new launch
517
+ // reservation. If cancellation fails, restore that run as active
518
+ // and queue the replacement so neither side of the hand-off is
519
+ // lost. Keep its monitor attached: it is still running.
520
+ const restore = queueReplacement
521
+ ? isolate({ triggerId: trigger.id }, "supersede compensation", store.restorePending({
522
+ triggerId: trigger.id,
523
+ occurrence: replacementOccurrence,
524
+ reservationId: replacementReservation
525
+ }))
526
+ : Effect.void;
527
+ return restore.pipe(Effect.andThen(Effect.fail(error)));
528
+ }));
529
+ }
530
+ if (Number.isFinite(prior.occurrence) && !isReservation(prior.runId)) {
531
+ yield* store.recordResult({
532
+ triggerId: trigger.id,
533
+ occurrence: prior.occurrence,
534
+ outcome: "superseded",
535
+ runId: prior.runId
536
+ });
537
+ }
538
+ // Keep the monitor attached until the terminal write succeeds. If the
539
+ // store refuses it after cancellation, the monitor can still observe
540
+ // the stopped run and record a terminal result on its next poll.
541
+ if (prior.fiber !== undefined)
542
+ yield* Fiber.interrupt(prior.fiber);
543
+ yield* active.remove(trigger.id, prior.occurrence);
544
+ });
545
+ const dispatchClaimed = (trigger, occurrence, claim, resumeBuffered = false) => Effect.gen(function* () {
546
+ switch (claim.action) {
547
+ case "fire":
548
+ yield* launch(trigger, occurrence, claim.reservationId, resumeBuffered);
549
+ return;
550
+ case "skip":
551
+ yield* store.recordResult({
552
+ triggerId: trigger.id,
553
+ occurrence,
554
+ outcome: "skipped"
555
+ });
556
+ return;
557
+ case "buffer":
558
+ yield* store.recordResult({
559
+ triggerId: trigger.id,
560
+ occurrence,
561
+ outcome: "buffered"
562
+ });
563
+ return;
564
+ case "supersede": {
565
+ const superseded = claim.activeRunId;
566
+ if (superseded !== undefined) {
567
+ const local = yield* active.get(trigger.id);
568
+ yield* cancelActive(trigger, local !== undefined && local.runId === superseded
569
+ ? { ...local, runId: superseded }
570
+ : {
571
+ occurrence: yield* occurrenceOf(trigger.id, superseded),
572
+ runId: superseded
573
+ }, occurrence, claim.reservationId, !resumeBuffered);
574
+ }
575
+ yield* launch(trigger, occurrence, claim.reservationId, resumeBuffered);
576
+ return;
577
+ }
578
+ }
579
+ });
580
+ const claimOnce = (trigger, occurrence) => store.claimFire({
581
+ triggerId: trigger.id,
582
+ occurrence,
583
+ expectedRevision: trigger.revision
584
+ }).pipe(Effect.flatMap((claim) => claim.claimed ? dispatchClaimed(trigger, occurrence, claim) : Effect.void));
585
+ // A claim is fenced on the revision its occurrence was computed from. One
586
+ // refresh and one retry is enough because the next tick reads again.
587
+ const withRevisionRefresh = (trigger, claim) => claim(trigger).pipe(Effect.catch((error) => error.code !== "revision_mismatch"
588
+ ? Effect.fail(error)
589
+ : store.get(trigger.id).pipe(Effect.flatMap((refreshed) => Option.isNone(refreshed) || refreshed.value.revision === trigger.revision
590
+ ? Effect.fail(error)
591
+ : claim(refreshed.value)))));
592
+ // The store reads, claims, and clears a buffer in one transaction. A
593
+ // dispatch failure happens after that commit, so this process can safely
594
+ // re-arm the occurrence before it reports the failure.
595
+ const resumePending = (trigger, held) =>
596
+ // A listed row holding neither a buffered occurrence nor a run has
597
+ // nothing for `claimPending` to take: only expiring a reservation
598
+ // re-arms a pending occurrence, and there is no reservation to expire.
599
+ held !== undefined && held.pendingAt === undefined && held.activeRunId === undefined
600
+ ? Effect.void
601
+ : withRevisionRefresh(trigger, (current) => store.claimPending({
602
+ triggerId: current.id,
603
+ expectedRevision: current.revision
604
+ }).pipe(Effect.flatMap((pending) => {
605
+ if (Option.isNone(pending))
606
+ return Effect.void;
607
+ const occurrence = pending.value.occurrence;
608
+ const claim = pending.value.claim;
609
+ if (!claim.claimed)
610
+ return Effect.void;
611
+ return dispatchClaimed(current, occurrence, claim, true).pipe(Effect.onError(() => isolate({ triggerId: current.id }, "buffered launch compensation", claim.action === "fire" || claim.action === "supersede"
612
+ ? store.restorePending({ triggerId: current.id, occurrence, reservationId: claim.reservationId })
613
+ : store.setPending({ triggerId: current.id, occurrence })).pipe(Effect.ignore)));
614
+ })));
615
+ const processTrigger = (trigger, held, refreshed = false) => Effect.gen(function* () {
616
+ // Each trigger reads the clock itself. One instant captured before the
617
+ // tick fanned out aged by every launch that finished ahead of it.
618
+ const now = yield* Clock.currentTimeMillis;
619
+ const running = yield* resolveActive(trigger, held);
620
+ // Disable prevents future claims, not recovery of an already active
621
+ // occurrence (including a plan waiting for a human decision).
622
+ if (!trigger.enabled)
623
+ return;
624
+ if (running === undefined || trigger.overlap === "supersede")
625
+ yield* resumePending(trigger, held);
626
+ const observed = (yield* Ref.get(observedAt)).get(trigger.id);
627
+ const due = yield* DueOccurrences.compute(trigger, now, observed);
628
+ let dispatched;
629
+ let interrupted = false;
630
+ let stale = false;
631
+ for (const occurrence of due.occurrences) {
632
+ const settledHere = yield* attempted({ triggerId: trigger.id }, `dispatch of occurrence ${occurrence}`, claimOnce(trigger, occurrence).pipe(Effect.catch((error) => error.code === "revision_mismatch"
633
+ ? Effect.sync(() => {
634
+ stale = true;
635
+ })
636
+ : Effect.fail(error))));
637
+ if (stale)
638
+ break;
639
+ if (settledHere && !interrupted)
640
+ dispatched = occurrence;
641
+ if (!settledHere)
642
+ interrupted = true;
643
+ }
644
+ if (stale) {
645
+ // The occurrences were computed from a declaration the store has
646
+ // since replaced, so the schedule decision is what went stale: a
647
+ // trigger edited to fire at noon owes nothing for the hourly
648
+ // boundary its old cron produced. Keep the boundary already
649
+ // dispatched, re-read once, and decide again from the refreshed cron
650
+ // and watermark. A second mismatch waits for the next tick, which
651
+ // reads again anyway. A buffered occurrence is store state rather
652
+ // than a computed decision, so `resumePending` retries it as is.
653
+ if (dispatched !== undefined)
654
+ yield* observe(trigger.id, dispatched);
655
+ if (refreshed)
656
+ return;
657
+ const current = yield* store.get(trigger.id);
658
+ if (Option.isNone(current) || current.value.revision === trigger.revision)
659
+ return;
660
+ // The refreshed declaration comes from the store, not the listing,
661
+ // so the held snapshot no longer describes it.
662
+ return yield* processTrigger(current.value, undefined, true);
663
+ }
664
+ if (!interrupted)
665
+ return yield* observe(trigger.id, due.watermark);
666
+ if (dispatched !== undefined)
667
+ yield* observe(trigger.id, dispatched);
668
+ }).pipe(Effect.withSpan("Scheduler.processTrigger", { attributes: { triggerId: trigger.id } }));
669
+ const host = options.host ?? defaultHost;
670
+ const runOnce = semaphore.withPermits(1)(Effect.gen(function* () {
671
+ // The heartbeat is observability, not dispatch: a store that cannot
672
+ // record it is logged and the tick goes on, so a listing's "nothing is
673
+ // listening" can never be caused by the row that reports it.
674
+ yield* isolate({ host }, "heartbeat", store.heartbeat(host));
675
+ // Nothing else deletes from the fire ledger, so without this it keeps
676
+ // one row per occurrence forever and every fires listing reads it all.
677
+ const now = yield* Clock.currentTimeMillis;
678
+ const last = yield* Ref.get(lastPrunedAt);
679
+ if (last === undefined || now - last >= pruneEvery) {
680
+ yield* Ref.set(lastPrunedAt, now);
681
+ yield* isolate({ host }, "fire ledger prune", Effect.asVoid(store.pruneFires({ olderThan: now - Duration.toMillis(fireRetention) })));
682
+ }
683
+ // Every trigger, not only the enabled ones: a disabled trigger can
684
+ // still hold an active occurrence that has to recover, and the enabled
685
+ // check below it stops the new claims.
686
+ const listing = yield* store.list();
687
+ // Triggers are independent once claimed: the store fences each claim
688
+ // on its own row. Walking them in series let one launch waiting on a
689
+ // parked plan hold every trigger after it for the whole wait. A row
690
+ // the store could not decode is one more isolated failure, so one
691
+ // corrupt declaration cannot stop the healthy schedules beside it.
692
+ yield* Effect.forEach(listing, (row) => isolate({ triggerId: row.triggerId }, "tick", Result.isFailure(row.trigger)
693
+ ? Effect.fail(row.trigger.failure)
694
+ : processTrigger(row.trigger.success, row)), { concurrency, discard: true });
695
+ }).pipe((tick) => timed(tickDuration, tick), Effect.withSpan("Scheduler.runOnce", { attributes: { host } })));
696
+ return Scheduler.of({ runOnce });
697
+ });
698
+ /**
699
+ * Constructs a scheduler that performs no work.
700
+ *
701
+ * @category constructors
702
+ * @since 0.1.0
703
+ */
704
+ export const makeNoop = () => Scheduler.of({ runOnce: Effect.void });
705
+ /**
706
+ * Scoped scheduler layer. Its supervisor sleeps only through the Effect Clock,
707
+ * so scope closure interrupts the poll loop and detaches every run monitor.
708
+ * Detaching is all it does: the runs themselves are durable and keep going,
709
+ * and the next incarnation re-attaches to them from the store.
710
+ *
711
+ * The loop recovers from the whole cause rather than the typed error alone. A
712
+ * defect raised anywhere under a tick, which `Effect.catch` by contract leaves
713
+ * alone, would otherwise kill this fiber and stop every trigger in the process
714
+ * with nothing written down.
715
+ *
716
+ * @category layers
717
+ * @since 0.1.0
718
+ */
719
+ export const layer = (options = {}) => Layer.effect(Scheduler, Effect.gen(function* () {
720
+ const scheduler = yield* make(options);
721
+ const pollInterval = yield* duration(options.pollInterval ?? "1 minute", "pollInterval");
722
+ yield* Effect.forkScoped(Effect.forever(scheduler.runOnce.pipe(Effect.catchCause((cause) => Cause.hasInterrupts(cause)
723
+ ? Effect.failCause(cause)
724
+ : Effect.logWarning("A trigger scheduler tick failed", cause)), Effect.andThen(Effect.sleep(pollInterval)))));
725
+ return scheduler;
726
+ }));
727
+ /**
728
+ * Provides an inert scheduler without allocating a supervisor fiber.
729
+ *
730
+ * @category layers
731
+ * @since 0.1.0
732
+ */
733
+ export const layerNoop = Layer.succeed(Scheduler)(makeNoop());
734
+ //# sourceMappingURL=Scheduler.js.map