@smthrs/triggers 0.0.0-stage → 1.0.0-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +219 -0
- package/LICENSE +21 -0
- package/README.md +109 -2
- package/dist/cjs/CatchUp.d.ts +25 -0
- package/dist/cjs/CatchUp.d.ts.map +1 -0
- package/dist/cjs/CatchUp.js +72 -0
- package/dist/cjs/CatchUp.js.map +7 -0
- package/dist/cjs/Channel.d.ts +108 -0
- package/dist/cjs/Channel.d.ts.map +1 -0
- package/dist/cjs/Channel.js +25 -0
- package/dist/cjs/Channel.js.map +7 -0
- package/dist/cjs/ClaimDecision.d.ts +183 -0
- package/dist/cjs/ClaimDecision.d.ts.map +1 -0
- package/dist/cjs/ClaimDecision.js +144 -0
- package/dist/cjs/ClaimDecision.js.map +7 -0
- package/dist/cjs/Cron.d.ts +91 -0
- package/dist/cjs/Cron.d.ts.map +1 -0
- package/dist/cjs/Cron.js +108 -0
- package/dist/cjs/Cron.js.map +7 -0
- package/dist/cjs/DispatchReader.d.ts +77 -0
- package/dist/cjs/DispatchReader.d.ts.map +1 -0
- package/dist/cjs/DispatchReader.js +121 -0
- package/dist/cjs/DispatchReader.js.map +7 -0
- package/dist/cjs/Overlap.d.ts +40 -0
- package/dist/cjs/Overlap.d.ts.map +1 -0
- package/dist/cjs/Overlap.js +37 -0
- package/dist/cjs/Overlap.js.map +7 -0
- package/dist/cjs/Schedule.d.ts +96 -0
- package/dist/cjs/Schedule.d.ts.map +1 -0
- package/dist/cjs/Schedule.js +61 -0
- package/dist/cjs/Schedule.js.map +7 -0
- package/dist/cjs/Scheduler.d.ts +248 -0
- package/dist/cjs/Scheduler.d.ts.map +1 -0
- package/dist/cjs/Scheduler.js +647 -0
- package/dist/cjs/Scheduler.js.map +7 -0
- package/dist/cjs/SqlTriggerStore.d.ts +29 -0
- package/dist/cjs/SqlTriggerStore.d.ts.map +1 -0
- package/dist/cjs/SqlTriggerStore.js +483 -0
- package/dist/cjs/SqlTriggerStore.js.map +7 -0
- package/dist/cjs/Trigger.d.ts +81 -0
- package/dist/cjs/Trigger.d.ts.map +1 -0
- package/dist/cjs/Trigger.js +54 -0
- package/dist/cjs/Trigger.js.map +7 -0
- package/dist/cjs/TriggerError.d.ts +58 -0
- package/dist/cjs/TriggerError.d.ts.map +1 -0
- package/dist/cjs/TriggerError.js +83 -0
- package/dist/cjs/TriggerError.js.map +7 -0
- package/dist/cjs/TriggerStore.d.ts +411 -0
- package/dist/cjs/TriggerStore.d.ts.map +1 -0
- package/dist/cjs/TriggerStore.js +127 -0
- package/dist/cjs/TriggerStore.js.map +7 -0
- package/dist/cjs/Webhook.d.ts +118 -0
- package/dist/cjs/Webhook.d.ts.map +1 -0
- package/dist/cjs/Webhook.js +153 -0
- package/dist/cjs/Webhook.js.map +7 -0
- package/dist/cjs/index.d.ts +30 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +57 -0
- package/dist/cjs/index.js.map +7 -0
- package/dist/cjs/internal/ActiveRuns.d.ts +51 -0
- package/dist/cjs/internal/ActiveRuns.d.ts.map +1 -0
- package/dist/cjs/internal/ActiveRuns.js +56 -0
- package/dist/cjs/internal/ActiveRuns.js.map +7 -0
- package/dist/cjs/internal/DueOccurrences.d.ts +32 -0
- package/dist/cjs/internal/DueOccurrences.d.ts.map +1 -0
- package/dist/cjs/internal/DueOccurrences.js +80 -0
- package/dist/cjs/internal/DueOccurrences.js.map +7 -0
- package/dist/cjs/internal/WallClock.d.ts +59 -0
- package/dist/cjs/internal/WallClock.d.ts.map +1 -0
- package/dist/cjs/internal/WallClock.js +132 -0
- package/dist/cjs/internal/WallClock.js.map +7 -0
- package/dist/cjs/migrations/0001_triggers.d.ts +15 -0
- package/dist/cjs/migrations/0001_triggers.d.ts.map +1 -0
- package/dist/cjs/migrations/0001_triggers.js +64 -0
- package/dist/cjs/migrations/0001_triggers.js.map +7 -0
- package/dist/cjs/migrations/0002_reservation_lease.d.ts +15 -0
- package/dist/cjs/migrations/0002_reservation_lease.d.ts.map +1 -0
- package/dist/cjs/migrations/0002_reservation_lease.js +41 -0
- package/dist/cjs/migrations/0002_reservation_lease.js.map +7 -0
- package/dist/cjs/migrations/0003_heartbeat.d.ts +16 -0
- package/dist/cjs/migrations/0003_heartbeat.d.ts.map +1 -0
- package/dist/cjs/migrations/0003_heartbeat.js +44 -0
- package/dist/cjs/migrations/0003_heartbeat.js.map +7 -0
- package/dist/cjs/migrations/0004_fire_run_index.d.ts +17 -0
- package/dist/cjs/migrations/0004_fire_run_index.d.ts.map +1 -0
- package/dist/cjs/migrations/0004_fire_run_index.js +40 -0
- package/dist/cjs/migrations/0004_fire_run_index.js.map +7 -0
- package/dist/cjs/migrations/index.d.ts +33 -0
- package/dist/cjs/migrations/index.d.ts.map +1 -0
- package/dist/cjs/migrations/index.js +50 -0
- package/dist/cjs/migrations/index.js.map +7 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/test/TestTriggers.d.ts +12 -0
- package/dist/cjs/test/TestTriggers.d.ts.map +1 -0
- package/dist/cjs/test/TestTriggers.js +421 -0
- package/dist/cjs/test/TestTriggers.js.map +7 -0
- package/dist/esm/CatchUp.d.ts +25 -0
- package/dist/esm/CatchUp.d.ts.map +1 -0
- package/dist/esm/CatchUp.js +52 -0
- package/dist/esm/CatchUp.js.map +1 -0
- package/dist/esm/Channel.d.ts +108 -0
- package/dist/esm/Channel.d.ts.map +1 -0
- package/dist/esm/Channel.js +19 -0
- package/dist/esm/Channel.js.map +1 -0
- package/dist/esm/ClaimDecision.d.ts +183 -0
- package/dist/esm/ClaimDecision.d.ts.map +1 -0
- package/dist/esm/ClaimDecision.js +172 -0
- package/dist/esm/ClaimDecision.js.map +1 -0
- package/dist/esm/Cron.d.ts +91 -0
- package/dist/esm/Cron.d.ts.map +1 -0
- package/dist/esm/Cron.js +132 -0
- package/dist/esm/Cron.js.map +1 -0
- package/dist/esm/DispatchReader.d.ts +77 -0
- package/dist/esm/DispatchReader.d.ts.map +1 -0
- package/dist/esm/DispatchReader.js +152 -0
- package/dist/esm/DispatchReader.js.map +1 -0
- package/dist/esm/Overlap.d.ts +40 -0
- package/dist/esm/Overlap.d.ts.map +1 -0
- package/dist/esm/Overlap.js +31 -0
- package/dist/esm/Overlap.js.map +1 -0
- package/dist/esm/Schedule.d.ts +96 -0
- package/dist/esm/Schedule.d.ts.map +1 -0
- package/dist/esm/Schedule.js +73 -0
- package/dist/esm/Schedule.js.map +1 -0
- package/dist/esm/Scheduler.d.ts +248 -0
- package/dist/esm/Scheduler.d.ts.map +1 -0
- package/dist/esm/Scheduler.js +734 -0
- package/dist/esm/Scheduler.js.map +1 -0
- package/dist/esm/SqlTriggerStore.d.ts +29 -0
- package/dist/esm/SqlTriggerStore.d.ts.map +1 -0
- package/dist/esm/SqlTriggerStore.js +483 -0
- package/dist/esm/SqlTriggerStore.js.map +1 -0
- package/dist/esm/Trigger.d.ts +81 -0
- package/dist/esm/Trigger.d.ts.map +1 -0
- package/dist/esm/Trigger.js +56 -0
- package/dist/esm/Trigger.js.map +1 -0
- package/dist/esm/TriggerError.d.ts +58 -0
- package/dist/esm/TriggerError.d.ts.map +1 -0
- package/dist/esm/TriggerError.js +110 -0
- package/dist/esm/TriggerError.js.map +1 -0
- package/dist/esm/TriggerStore.d.ts +411 -0
- package/dist/esm/TriggerStore.d.ts.map +1 -0
- package/dist/esm/TriggerStore.js +209 -0
- package/dist/esm/TriggerStore.js.map +1 -0
- package/dist/esm/Webhook.d.ts +118 -0
- package/dist/esm/Webhook.d.ts.map +1 -0
- package/dist/esm/Webhook.js +186 -0
- package/dist/esm/Webhook.js.map +1 -0
- package/dist/esm/index.d.ts +30 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +30 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/ActiveRuns.d.ts +51 -0
- package/dist/esm/internal/ActiveRuns.d.ts.map +1 -0
- package/dist/esm/internal/ActiveRuns.js +34 -0
- package/dist/esm/internal/ActiveRuns.js.map +1 -0
- package/dist/esm/internal/DueOccurrences.d.ts +32 -0
- package/dist/esm/internal/DueOccurrences.d.ts.map +1 -0
- package/dist/esm/internal/DueOccurrences.js +55 -0
- package/dist/esm/internal/DueOccurrences.js.map +1 -0
- package/dist/esm/internal/WallClock.d.ts +59 -0
- package/dist/esm/internal/WallClock.d.ts.map +1 -0
- package/dist/esm/internal/WallClock.js +155 -0
- package/dist/esm/internal/WallClock.js.map +1 -0
- package/dist/esm/migrations/0001_triggers.d.ts +15 -0
- package/dist/esm/migrations/0001_triggers.d.ts.map +1 -0
- package/dist/esm/migrations/0001_triggers.js +42 -0
- package/dist/esm/migrations/0001_triggers.js.map +1 -0
- package/dist/esm/migrations/0002_reservation_lease.d.ts +15 -0
- package/dist/esm/migrations/0002_reservation_lease.d.ts.map +1 -0
- package/dist/esm/migrations/0002_reservation_lease.js +19 -0
- package/dist/esm/migrations/0002_reservation_lease.js.map +1 -0
- package/dist/esm/migrations/0003_heartbeat.d.ts +16 -0
- package/dist/esm/migrations/0003_heartbeat.d.ts.map +1 -0
- package/dist/esm/migrations/0003_heartbeat.js +23 -0
- package/dist/esm/migrations/0003_heartbeat.js.map +1 -0
- package/dist/esm/migrations/0004_fire_run_index.d.ts +17 -0
- package/dist/esm/migrations/0004_fire_run_index.d.ts.map +1 -0
- package/dist/esm/migrations/0004_fire_run_index.js +20 -0
- package/dist/esm/migrations/0004_fire_run_index.js.map +1 -0
- package/dist/esm/migrations/index.d.ts +33 -0
- package/dist/esm/migrations/index.d.ts.map +1 -0
- package/dist/esm/migrations/index.js +37 -0
- package/dist/esm/migrations/index.js.map +1 -0
- package/dist/esm/test/TestTriggers.d.ts +12 -0
- package/dist/esm/test/TestTriggers.d.ts.map +1 -0
- package/dist/esm/test/TestTriggers.js +439 -0
- package/dist/esm/test/TestTriggers.js.map +1 -0
- package/docs/README.md +140 -0
- package/docs/api.md +1186 -0
- package/docs/concepts/channels.md +103 -0
- package/docs/concepts/claim-protocol.md +229 -0
- package/docs/concepts/policies.md +124 -0
- package/docs/concepts/schedules.md +134 -0
- package/docs/guides/choose-a-policy.md +113 -0
- package/docs/guides/declare-a-trigger.md +136 -0
- package/docs/guides/ingest-a-webhook.md +164 -0
- package/docs/guides/run-the-scheduler.md +173 -0
- package/docs/guides/testing.md +157 -0
- package/docs/installation.md +81 -0
- package/docs/quickstart.md +163 -0
- package/docs/troubleshooting.md +281 -0
- package/package.json +241 -3
- package/src/CatchUp.ts +73 -0
- package/src/Channel.ts +121 -0
- package/src/ClaimDecision.ts +303 -0
- package/src/Cron.ts +173 -0
- package/src/DispatchReader.ts +179 -0
- package/src/Overlap.ts +53 -0
- package/src/Schedule.ts +112 -0
- package/src/Scheduler.ts +1154 -0
- package/src/SqlTriggerStore.ts +638 -0
- package/src/Trigger.ts +84 -0
- package/src/TriggerError.ts +129 -0
- package/src/TriggerStore.ts +514 -0
- package/src/Webhook.ts +270 -0
- package/src/index.ts +30 -0
- package/src/internal/ActiveRuns.ts +83 -0
- package/src/internal/DueOccurrences.ts +101 -0
- package/src/internal/WallClock.ts +172 -0
- package/src/migrations/0001_triggers.ts +43 -0
- package/src/migrations/0002_reservation_lease.ts +20 -0
- package/src/migrations/0003_heartbeat.ts +24 -0
- package/src/migrations/0004_fire_run_index.ts +21 -0
- package/src/migrations/index.ts +40 -0
- package/src/test/TestTriggers.ts +563 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Test trigger code"
|
|
3
|
+
description: "Test schedules and triggers deterministically: the in-memory store, the recording runner, a test clock instead of wall time, and the noop store that proves a composition reaches nothing."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 5
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Nothing in this package reads wall time or reaches a network directly. Time is
|
|
9
|
+
the Effect `Clock`, launching is the `Runner` port, and persistence is the
|
|
10
|
+
`TriggerStore` contract. A test replaces those three and runs the production
|
|
11
|
+
logic.
|
|
12
|
+
|
|
13
|
+
## Swap the store for the in-memory one
|
|
14
|
+
|
|
15
|
+
`TestTriggers.layer` is a `TriggerStore` with real claim and overlap semantics
|
|
16
|
+
and no database:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import * as TestTriggers from "@smthrs/triggers/test/TestTriggers"
|
|
20
|
+
import * as Layer from "effect/Layer"
|
|
21
|
+
|
|
22
|
+
const store = TestTriggers.layer
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
It is not a kinder set of rules. Both stores apply one shared claim decision, so
|
|
26
|
+
they return the same refusal codes in the same order, hold the same 5-minute
|
|
27
|
+
reservation lease from `TriggerStore.reservationLeaseMs`, reclaim an expired
|
|
28
|
+
reservation the same way, and follow the same watermark rules. Registration
|
|
29
|
+
validates the declaration and serializes its input at the call boundary in both,
|
|
30
|
+
`list` and `listEnabled` order by id in both, and every reading is an
|
|
31
|
+
independent value that cannot be mutated back into stored state.
|
|
32
|
+
|
|
33
|
+
Two things it cannot stand in for. It holds one process's state behind a `Ref`,
|
|
34
|
+
so it never reports a `store` failure from a write and never shows contention
|
|
35
|
+
between connections. It applies no migrations and keeps no rows, so a test about
|
|
36
|
+
a schema or a column shape has nothing to read.
|
|
37
|
+
|
|
38
|
+
Use `SqlTriggerStore.layer` over an in-memory SQLite database when the thing
|
|
39
|
+
under test is a SQL behavior, such as migration application or a row shape.
|
|
40
|
+
|
|
41
|
+
## Record launches instead of performing them
|
|
42
|
+
|
|
43
|
+
`Scheduler.layerNoopRunner` answers `inspect` with `"completed"` and `cancel`
|
|
44
|
+
with nothing, so launched work settles immediately. Override only what the test
|
|
45
|
+
needs to observe:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import * as Scheduler from "@smthrs/triggers/Scheduler"
|
|
49
|
+
import * as Effect from "effect/Effect"
|
|
50
|
+
|
|
51
|
+
const launched: Array<Scheduler.StartInput> = []
|
|
52
|
+
|
|
53
|
+
const runner = Scheduler.layerNoopRunner({
|
|
54
|
+
start: (input) =>
|
|
55
|
+
Effect.sync(() => {
|
|
56
|
+
launched.push(input)
|
|
57
|
+
return input.idempotencyKey
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Assert on `idempotencyKey`. It is `<triggerId>:<occurrence ISO instant>`, so it
|
|
63
|
+
pins which boundary fired, not merely that something fired.
|
|
64
|
+
|
|
65
|
+
To exercise a run that stays alive, keep a set of active run ids and answer
|
|
66
|
+
`inspect` from it: `"active"` while the id is in the set, and the state you
|
|
67
|
+
want recorded once it leaves. To exercise supersede, record what `cancel` was
|
|
68
|
+
called with.
|
|
69
|
+
|
|
70
|
+
## Move the clock instead of waiting
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import * as Scheduler from "@smthrs/triggers/Scheduler"
|
|
74
|
+
import * as Effect from "effect/Effect"
|
|
75
|
+
import { TestClock } from "effect/testing"
|
|
76
|
+
|
|
77
|
+
const program = Effect.scoped(Effect.gen(function*() {
|
|
78
|
+
const scheduler = yield* Scheduler.make()
|
|
79
|
+
yield* TestClock.setTime(Date.parse("2026-03-01T03:00:00.000Z"))
|
|
80
|
+
yield* scheduler.runOnce
|
|
81
|
+
yield* TestClock.setTime(Date.parse("2026-03-02T03:00:00.000Z"))
|
|
82
|
+
yield* scheduler.runOnce
|
|
83
|
+
yield* Effect.yieldNow
|
|
84
|
+
}))
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Build the scheduler with `Scheduler.make` rather than `Scheduler.layer`, so no
|
|
88
|
+
supervisor fiber competes with the test for ticks, and the test decides when
|
|
89
|
+
each tick happens.
|
|
90
|
+
|
|
91
|
+
Two habits keep such a test honest:
|
|
92
|
+
|
|
93
|
+
- Yield after a tick that launches. The launch monitor runs in a forked fiber,
|
|
94
|
+
so the terminal result is recorded after `runOnce` returns.
|
|
95
|
+
- Remember that the first tick a process performs on a trigger establishes a
|
|
96
|
+
watermark and fires nothing. A test that expects a launch needs a second tick,
|
|
97
|
+
or a trigger seeded with a `lastFiredAt` through a recorded result.
|
|
98
|
+
|
|
99
|
+
## Observe what the store recorded
|
|
100
|
+
|
|
101
|
+
Wrap the store layer to capture `recordResult` calls, which is where every
|
|
102
|
+
scheduler decision lands:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import * as TriggerStore from "@smthrs/triggers/TriggerStore"
|
|
106
|
+
import * as Effect from "effect/Effect"
|
|
107
|
+
import * as Layer from "effect/Layer"
|
|
108
|
+
|
|
109
|
+
const recording = (results: Array<TriggerStore.Result>) =>
|
|
110
|
+
Layer.effect(
|
|
111
|
+
TriggerStore.TriggerStore,
|
|
112
|
+
Effect.gen(function*() {
|
|
113
|
+
const store = yield* TriggerStore.TriggerStore
|
|
114
|
+
return TriggerStore.TriggerStore.of({
|
|
115
|
+
...store,
|
|
116
|
+
recordResult: (result) =>
|
|
117
|
+
Effect.sync(() => {
|
|
118
|
+
results.push(result)
|
|
119
|
+
}).pipe(Effect.andThen(store.recordResult(result)))
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
).pipe(Layer.provide(TestTriggers.layer))
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The outcomes tell the policies apart: `skipped`, `buffered`, and `superseded`
|
|
126
|
+
are the three overlap decisions, and `launched` followed by `completed` is a run
|
|
127
|
+
that went all the way.
|
|
128
|
+
|
|
129
|
+
## Prove a composition reaches nothing
|
|
130
|
+
|
|
131
|
+
`TriggerStore.layerNoop` fails every method with `store` and a message naming
|
|
132
|
+
the method. Use it when a composition needs the service in its type but must
|
|
133
|
+
never touch it, and override the one method a test expects to be reached. A
|
|
134
|
+
scheduler tick polls `list`, so that is the override a tick needs:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
import * as TriggerStore from "@smthrs/triggers/TriggerStore"
|
|
138
|
+
import * as Effect from "effect/Effect"
|
|
139
|
+
|
|
140
|
+
const absent = TriggerStore.layerNoop({ list: () => Effect.succeed([]) })
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`Scheduler.layerNoop` is the matching inert scheduler, and
|
|
144
|
+
`Scheduler.makeNoopRunner` the matching inert launcher.
|
|
145
|
+
|
|
146
|
+
## Test the pure parts directly
|
|
147
|
+
|
|
148
|
+
`Overlap.decide`, `Overlap.pendingAfter`, and `CatchUp.occurrences` are pure
|
|
149
|
+
functions over explicit state. A policy question is answerable without a store,
|
|
150
|
+
a clock, or a scheduler at all, and that is the cheapest place to pin a policy
|
|
151
|
+
expectation:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
import * as Overlap from "@smthrs/triggers/Overlap"
|
|
155
|
+
|
|
156
|
+
Overlap.decide("supersede", { running: true, due: 7_200_000 }) // "supersede"
|
|
157
|
+
```
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Installation"
|
|
3
|
+
description: "How to get @smthrs/triggers, what it requires at runtime, the import forms it publishes, and the packages a running host adds on top."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 1
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Get the package
|
|
9
|
+
|
|
10
|
+
Not on npm yet; see [Installation](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/flow/docs/installation.md#use-the-libraries).
|
|
11
|
+
|
|
12
|
+
The installed manifest resolves to the synchronized RC:
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@smthrs/triggers": "1.0.0-rc.1"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Requirements
|
|
23
|
+
|
|
24
|
+
- Node.js 26.4.0 or later.
|
|
25
|
+
- [`effect`](https://effect.website) 4.0.0-rc.115, which supplies the `Effect`,
|
|
26
|
+
`Schema`, `Clock`, and SQL client types this package's signatures use.
|
|
27
|
+
- [`@smthrs/control`](/api/control), the authoritative launch boundary. The
|
|
28
|
+
scheduler's Control-backed runner and every channel dispatch go through it.
|
|
29
|
+
- [`@smthrs/database`](/api/database), for the SQL client and the durable
|
|
30
|
+
writer that `SqlTriggerStore` writes through.
|
|
31
|
+
|
|
32
|
+
All three are ordinary dependencies of the package and install with it.
|
|
33
|
+
|
|
34
|
+
## Import forms
|
|
35
|
+
|
|
36
|
+
The root entry point re-exports every module as a namespace:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { Cron, DispatchReader, Scheduler, SqlTriggerStore, Trigger, TriggerStore, Webhook } from "@smthrs/triggers"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Each module is also importable from its own subpath, which is the form these
|
|
43
|
+
docs use:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import * as Scheduler from "@smthrs/triggers/Scheduler"
|
|
47
|
+
import * as TriggerStore from "@smthrs/triggers/TriggerStore"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The in-memory store for tests lives at its own subpath:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import * as TestTriggers from "@smthrs/triggers/test/TestTriggers"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Three subpath forms are not public and are blocked in the package's export map:
|
|
57
|
+
`@smthrs/triggers/migrations/*`, `@smthrs/triggers/internal/*`, and
|
|
58
|
+
`@smthrs/triggers/*/index`. `@smthrs/triggers/package.json` is exported.
|
|
59
|
+
|
|
60
|
+
Migrations are internal on purpose. `SqlTriggerStore.layer` applies
|
|
61
|
+
`0001_triggers` and then `0002_reservation_lease` when it builds, so a host
|
|
62
|
+
never runs them itself.
|
|
63
|
+
|
|
64
|
+
## What a running host adds
|
|
65
|
+
|
|
66
|
+
The package declares triggers and channels; it does not open a database or
|
|
67
|
+
reach a control plane. A host that actually fires triggers composes three more
|
|
68
|
+
layers:
|
|
69
|
+
|
|
70
|
+
- A SQL client and a durable writer, from
|
|
71
|
+
[`@smthrs/database`](/api/database). `NodeDatabase.layer({ filename })` plus
|
|
72
|
+
`DurableWriter.layer()` is the Node pairing.
|
|
73
|
+
- `SqlTriggerStore.layer`, which turns those two into a `TriggerStore`.
|
|
74
|
+
- A `Scheduler.Runner`. `Scheduler.layerControlRunner` is the production one
|
|
75
|
+
and requires `Control.Control` from [`@smthrs/control`](/api/control).
|
|
76
|
+
|
|
77
|
+
The composition is in [Run the scheduler in a host](./guides/run-the-scheduler.md).
|
|
78
|
+
|
|
79
|
+
## Next step
|
|
80
|
+
|
|
81
|
+
Register a trigger and watch it launch in the [Quickstart](./quickstart.md).
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Quickstart"
|
|
3
|
+
description: "Register a durable trigger and watch the scheduler launch it: a SQLite store, a recording runner, and a test clock that crosses two schedule boundaries in milliseconds."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 2
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
This quickstart takes one trigger from a declaration to a launched run. The
|
|
9
|
+
store is the production SQLite one and the scheduler is the production one;
|
|
10
|
+
only two things are substituted, and both are seams the package already has:
|
|
11
|
+
the `Runner` records launches instead of reaching a control plane, and the
|
|
12
|
+
Effect `Clock` is a test clock, so a nightly schedule crosses two boundaries in
|
|
13
|
+
milliseconds instead of two days.
|
|
14
|
+
|
|
15
|
+
By the end you will have a trigger row that remembers when it last fired, and
|
|
16
|
+
one launch carrying an idempotency key derived from the occurrence.
|
|
17
|
+
|
|
18
|
+
## Prerequisites
|
|
19
|
+
|
|
20
|
+
- Node.js 26.4.0 or later.
|
|
21
|
+
- A TypeScript project that depends on `@smthrs/triggers` and
|
|
22
|
+
`@smthrs/database`. See [Installation](./installation.md).
|
|
23
|
+
|
|
24
|
+
## Compose the store, the runner, and the clock
|
|
25
|
+
|
|
26
|
+
Create `quickstart.ts`. `SqlTriggerStore.layer` needs a SQL client and a
|
|
27
|
+
durable writer, and it runs its own migrations when it builds:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import * as DurableWriter from "@smthrs/database/DurableWriter"
|
|
31
|
+
import * as NodeDatabase from "@smthrs/database/node/NodeDatabase"
|
|
32
|
+
import * as Scheduler from "@smthrs/triggers/Scheduler"
|
|
33
|
+
import * as SqlTriggerStore from "@smthrs/triggers/SqlTriggerStore"
|
|
34
|
+
import * as TriggerStore from "@smthrs/triggers/TriggerStore"
|
|
35
|
+
import * as Effect from "effect/Effect"
|
|
36
|
+
import * as Layer from "effect/Layer"
|
|
37
|
+
import { TestClock } from "effect/testing"
|
|
38
|
+
|
|
39
|
+
const launched: Array<Scheduler.StartInput> = []
|
|
40
|
+
|
|
41
|
+
const database = Layer.provideMerge(
|
|
42
|
+
DurableWriter.layer(),
|
|
43
|
+
NodeDatabase.layer({ filename: ":memory:" })
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
/** The launcher seam: record the launch and answer with a run id. */
|
|
47
|
+
const runner = Scheduler.layerNoopRunner({
|
|
48
|
+
start: (input) =>
|
|
49
|
+
Effect.sync(() => {
|
|
50
|
+
launched.push(input)
|
|
51
|
+
return input.idempotencyKey
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const layer = Layer.mergeAll(
|
|
56
|
+
SqlTriggerStore.layer.pipe(Layer.provide(database)),
|
|
57
|
+
runner,
|
|
58
|
+
TestClock.layer()
|
|
59
|
+
)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`Scheduler.layerNoopRunner` answers `inspect` with `"completed"` and `cancel`
|
|
63
|
+
with nothing, so a launch settles the moment it starts. The override replaces only
|
|
64
|
+
`start`.
|
|
65
|
+
|
|
66
|
+
## Register the trigger and run two ticks
|
|
67
|
+
|
|
68
|
+
`Scheduler.make` builds a scheduler with no supervisor fiber, so the program
|
|
69
|
+
decides when a tick happens. That is what makes the walkthrough deterministic:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
const program = Effect.scoped(Effect.gen(function*() {
|
|
73
|
+
const triggers = yield* TriggerStore.TriggerStore
|
|
74
|
+
yield* triggers.register({
|
|
75
|
+
id: "nightly-report",
|
|
76
|
+
flowId: "reports/nightly",
|
|
77
|
+
input: { channel: "#ops" },
|
|
78
|
+
cron: "0 3 * * *",
|
|
79
|
+
timezone: "UTC",
|
|
80
|
+
overlap: "skip",
|
|
81
|
+
catchUp: "one",
|
|
82
|
+
maxCatchUp: 1,
|
|
83
|
+
enabled: true
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const scheduler = yield* Scheduler.make()
|
|
87
|
+
|
|
88
|
+
// First sight of this trigger: the tick records where the clock is and
|
|
89
|
+
// fires nothing, because a trigger that has never fired owes no history.
|
|
90
|
+
yield* TestClock.setTime(Date.parse("2026-03-01T03:00:00.000Z"))
|
|
91
|
+
yield* scheduler.runOnce
|
|
92
|
+
|
|
93
|
+
// A boundary has passed since that watermark, so this tick launches it.
|
|
94
|
+
yield* TestClock.setTime(Date.parse("2026-03-02T03:00:00.000Z"))
|
|
95
|
+
yield* scheduler.runOnce
|
|
96
|
+
yield* Effect.yieldNow
|
|
97
|
+
|
|
98
|
+
return yield* triggers.get("nightly-report")
|
|
99
|
+
}))
|
|
100
|
+
|
|
101
|
+
const registered = await Effect.runPromise(program.pipe(Effect.provide(layer)))
|
|
102
|
+
console.log(launched)
|
|
103
|
+
console.log(registered)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Run the file with your TypeScript runner. The recorded launch and the stored
|
|
107
|
+
row look like this:
|
|
108
|
+
|
|
109
|
+
```text
|
|
110
|
+
[
|
|
111
|
+
{
|
|
112
|
+
flowId: 'reports/nightly',
|
|
113
|
+
input: { channel: '#ops' },
|
|
114
|
+
idempotencyKey: 'nightly-report:2026-03-02T03:00:00.000Z'
|
|
115
|
+
}
|
|
116
|
+
]
|
|
117
|
+
{
|
|
118
|
+
_id: 'Option',
|
|
119
|
+
_tag: 'Some',
|
|
120
|
+
value: {
|
|
121
|
+
id: 'nightly-report',
|
|
122
|
+
flowId: 'reports/nightly',
|
|
123
|
+
input: { channel: '#ops' },
|
|
124
|
+
cron: '0 3 * * *',
|
|
125
|
+
timezone: 'UTC',
|
|
126
|
+
overlap: 'skip',
|
|
127
|
+
catchUp: 'one',
|
|
128
|
+
maxCatchUp: 1,
|
|
129
|
+
enabled: true,
|
|
130
|
+
revision: 1,
|
|
131
|
+
lastFiredAt: 1772420400000
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## What just happened
|
|
137
|
+
|
|
138
|
+
Four facts in that output are the package working.
|
|
139
|
+
|
|
140
|
+
- **The first tick fired nothing.** A trigger registered on Sunday evening does
|
|
141
|
+
not owe Monday morning six days ago. With no `lastFiredAt`, the first tick a
|
|
142
|
+
process sees establishes a watermark at the latest boundary and starts from
|
|
143
|
+
the next one.
|
|
144
|
+
- **The idempotency key is the occurrence, not the moment.** It is
|
|
145
|
+
`<triggerId>:<occurrence ISO instant>`, computed from the schedule boundary
|
|
146
|
+
with milliseconds zeroed. Two hosts that both notice the same boundary derive
|
|
147
|
+
the same key, so a duplicate launch is the same launch.
|
|
148
|
+
- **`revision` is 1.** Every registration of the same id bumps the revision, and
|
|
149
|
+
a claim carries the revision it was computed from. That is the fence that
|
|
150
|
+
stops a stale snapshot from firing a trigger somebody just edited.
|
|
151
|
+
- **`lastFiredAt` is the occurrence that fired**, not the wall time the host
|
|
152
|
+
noticed it. It is the cursor catch-up resumes from, and it only ever moves
|
|
153
|
+
forward, so an older run settling after a newer occurrence was skipped cannot
|
|
154
|
+
drag it backwards and replay settled work.
|
|
155
|
+
|
|
156
|
+
## Next steps
|
|
157
|
+
|
|
158
|
+
- [Run the scheduler in a host](./guides/run-the-scheduler.md): the same
|
|
159
|
+
composition with a supervisor fiber and the real Control-backed runner.
|
|
160
|
+
- [Choose an overlap and catch-up policy](./guides/choose-a-policy.md): what
|
|
161
|
+
`skip` and `one` decided here, and when to pick something else.
|
|
162
|
+
- [The claim protocol](./concepts/claim-protocol.md): how hosts coordinate
|
|
163
|
+
claims and runners deduplicate repeated launch attempts.
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Troubleshooting"
|
|
3
|
+
description: "Every failure code @smthrs/triggers reports, plus the behaviors most often mistaken for bugs: a first tick that fires nothing, a buffer that seems stuck, and a catch-up backlog that was abandoned."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Every failure this package reports is a `TriggerError` carrying a stable `code`.
|
|
7
|
+
Branch on the code; never parse the message. `TriggerError.path` names the
|
|
8
|
+
offending field when the failure could locate one.
|
|
9
|
+
|
|
10
|
+
Find your code below, or read the last section if nothing failed and the trigger
|
|
11
|
+
still did not do what you expected.
|
|
12
|
+
|
|
13
|
+
## invalid_cron
|
|
14
|
+
|
|
15
|
+
**What happened.** Effect's cron parser rejected the expression or the timezone.
|
|
16
|
+
|
|
17
|
+
**What to change.** Fix the expression. The message is the parser's own, which
|
|
18
|
+
names the field it choked on. A timezone must be an IANA name such as
|
|
19
|
+
`America/New_York`.
|
|
20
|
+
|
|
21
|
+
## unsatisfiable_cron
|
|
22
|
+
|
|
23
|
+
**What happened.** The expression parses and the calendar never satisfies it.
|
|
24
|
+
`0 0 30 2 *` is February 30; `0 0 31 4 *` is April 31. The occurrence search
|
|
25
|
+
exhausted its bound looking for a date that does not exist.
|
|
26
|
+
|
|
27
|
+
**What to change.** Fix the expression. The refusal arrives at declaration time,
|
|
28
|
+
from `Trigger.make`, `Schedule.make`, or `SqlTriggerStore.register`, rather than
|
|
29
|
+
at the tick that would have fired the trigger. That is deliberate: the
|
|
30
|
+
satisfiability probe is the same search every tick performs, so an expression
|
|
31
|
+
that survives declaration is one the scheduler can keep answering.
|
|
32
|
+
|
|
33
|
+
## invalid_trigger
|
|
34
|
+
|
|
35
|
+
**What happened.** The declaration did not decode. Either a field is missing or
|
|
36
|
+
the wrong type, or the `input` has no JSON representation.
|
|
37
|
+
|
|
38
|
+
**What to change.** Read `TriggerError.path`, which names the offending field.
|
|
39
|
+
`id`, `flowId`, and `cron` must be non-empty strings; `maxCatchUp` must be an
|
|
40
|
+
integer between 0 and 1000; `input` must be JSON, which excludes `undefined`,
|
|
41
|
+
`NaN`, a `Date`, and a function.
|
|
42
|
+
|
|
43
|
+
Enumerable getters are accepted when their values are JSON. They are evaluated
|
|
44
|
+
during decoding and again during SQL serialization. SQL registration takes an
|
|
45
|
+
eager serialized snapshot before returning its Effect; later changes to the
|
|
46
|
+
getter's source do not change that snapshot. Use plain JSON values for stable
|
|
47
|
+
input, since a getter can produce different values or throw on a later read.
|
|
48
|
+
|
|
49
|
+
## invalid_schedule
|
|
50
|
+
|
|
51
|
+
**What happened.** The same thing, for a declaration decoded through
|
|
52
|
+
`Schedule.make` rather than `Trigger.make`.
|
|
53
|
+
|
|
54
|
+
**What to change.** Read `TriggerError.path` and fix the field.
|
|
55
|
+
|
|
56
|
+
## invalid_options
|
|
57
|
+
|
|
58
|
+
**What happened.** One of two contracts was violated.
|
|
59
|
+
|
|
60
|
+
- A cron occurrence limit was not a non-negative safe integer. `path` is
|
|
61
|
+
`"limit"`, and the message repeats the value it received, including `NaN` and
|
|
62
|
+
`Infinity`.
|
|
63
|
+
- A scheduler interval or deadline was not a finite, positive Effect duration.
|
|
64
|
+
`path` is `"pollInterval"`, `"runPollInterval"`, `"startTimeout"`,
|
|
65
|
+
`"inspectTimeout"`, or `"cancelTimeout"`.
|
|
66
|
+
- A scheduler `concurrency` was not a positive integer. `path` is
|
|
67
|
+
`"concurrency"`.
|
|
68
|
+
- A `TriggerStore.history` limit was not a positive safe integer. `path` is
|
|
69
|
+
`"limit"`. Zero is refused here because a zero-row page can never carry a
|
|
70
|
+
cursor.
|
|
71
|
+
|
|
72
|
+
**What to change.** Pass a real count, or a real duration. Zero and infinity are
|
|
73
|
+
both refused, because zero polls a CPU-tight loop and infinity never completes,
|
|
74
|
+
and `Duration.fromInput` accepts both.
|
|
75
|
+
|
|
76
|
+
## catch_up_bound_exceeded
|
|
77
|
+
|
|
78
|
+
**What happened.** One of three things, and the message tells them apart.
|
|
79
|
+
|
|
80
|
+
- `maxCatchUp must be a non-negative safe integer, received ...`: the bound
|
|
81
|
+
itself is unusable. This is checked before any policy branch, so it fires even
|
|
82
|
+
under `catchUp: "none"`.
|
|
83
|
+
- `missed N occurrences; maxCatchUp is M`: the trigger owes more than it is
|
|
84
|
+
allowed to replay.
|
|
85
|
+
- `interval contains more than 1000 occurrences`: an unbounded
|
|
86
|
+
`Cron.occurrencesBetween` was asked for a window it will not materialize.
|
|
87
|
+
|
|
88
|
+
**What to change.** For an unusable bound, fix the declaration. For a breached
|
|
89
|
+
bound, raise `maxCatchUp`, or accept the abandonment. For an unbounded search,
|
|
90
|
+
pass a `limit`.
|
|
91
|
+
|
|
92
|
+
The scheduler logs a warning annotated with the trigger id and abandons the
|
|
93
|
+
backlog when catch-up exceeds its bound. It still dispatches the current
|
|
94
|
+
occurrence subject to overlap, on the first poll after a restart and on every
|
|
95
|
+
later poll.
|
|
96
|
+
|
|
97
|
+
The trap worth naming: `maxCatchUp` defaults to 0, and a declaration that sets
|
|
98
|
+
`catchUp: "one"` without raising it owes one occurrence it is not allowed to
|
|
99
|
+
replay. Whenever `catchUp` is not `none`, set `maxCatchUp` to at least 1. See
|
|
100
|
+
[Choose an overlap and catch-up policy](./guides/choose-a-policy.md).
|
|
101
|
+
|
|
102
|
+
## unknown_trigger
|
|
103
|
+
|
|
104
|
+
**What happened.** An operation named a trigger id with no row behind it. Every
|
|
105
|
+
method addressing one trigger reports this, except `clearActive`.
|
|
106
|
+
|
|
107
|
+
**What to change.** Register the trigger first. If the id is right, check that
|
|
108
|
+
you are pointed at the database you think you are: `SqlTriggerStore.layer`
|
|
109
|
+
creates its tables on the SQL client it is given, so a second in-memory database
|
|
110
|
+
looks exactly like a missing trigger.
|
|
111
|
+
|
|
112
|
+
## trigger_disabled
|
|
113
|
+
|
|
114
|
+
**What happened.** A claim read the trigger row inside its transaction and found
|
|
115
|
+
`enabled` false.
|
|
116
|
+
|
|
117
|
+
**What to change.** Nothing, usually. This is the correct refusal for a
|
|
118
|
+
scheduler that computed an occurrence before somebody disabled the trigger. Note
|
|
119
|
+
that the check is on the stored row, not on the caller's snapshot, so a claim
|
|
120
|
+
built from a stale copy is refused rather than obeyed.
|
|
121
|
+
|
|
122
|
+
## revision_mismatch
|
|
123
|
+
|
|
124
|
+
**What happened.** The claim's `expectedRevision` differs from the revision on
|
|
125
|
+
the row. Somebody re-registered the trigger between the read and the claim.
|
|
126
|
+
|
|
127
|
+
**What to change.** Re-read the trigger and decide again. The scheduler already
|
|
128
|
+
does this: it refreshes once and recomputes what is due from the refreshed
|
|
129
|
+
declaration rather than retrying the occurrence the old one produced, which is
|
|
130
|
+
enough because the next tick reads again anyway.
|
|
131
|
+
|
|
132
|
+
## verification_failed
|
|
133
|
+
|
|
134
|
+
**What happened.** A webhook request did not authenticate. Either the signature
|
|
135
|
+
did not match, the header was absent or empty, `SignatureConfig.expected`
|
|
136
|
+
returned zero bytes, or the credential could not be resolved. Nothing was
|
|
137
|
+
decoded and no Control operation ran. The message does not say which: a
|
|
138
|
+
refusal from `Webhook.ingest` always reads `webhook <name> did not verify the
|
|
139
|
+
request`, and one from the signature verifier itself always reads
|
|
140
|
+
`webhook signature in <header> did not verify`. A failure raised by `expected`
|
|
141
|
+
is the verifier refusal's `cause`, so read it there, on the host side, rather
|
|
142
|
+
than expecting it in the message a sender receives.
|
|
143
|
+
|
|
144
|
+
**What to change.** Check three things in order.
|
|
145
|
+
|
|
146
|
+
1. The header name in `SignatureConfig.header`. The verifier looks it up first
|
|
147
|
+
in lowercase and then exactly as written, so a casing mismatch is not the
|
|
148
|
+
cause, but a wrong name is.
|
|
149
|
+
2. The bytes `SignatureConfig.expected` returns. They are compared against the
|
|
150
|
+
UTF-8 encoding of the header value, so an implementation that returns raw
|
|
151
|
+
HMAC bytes where the provider sends hex will never match. Return the encoded
|
|
152
|
+
form the provider actually sends. A zero-length result is refused outright,
|
|
153
|
+
because it means the secret resolved to the empty string rather than that
|
|
154
|
+
every request is valid.
|
|
155
|
+
3. The credential. `Webhook.Config.credential` is required, and a failure inside
|
|
156
|
+
`expected` while resolving it surfaces here as a typed failure rather than as
|
|
157
|
+
a defect.
|
|
158
|
+
|
|
159
|
+
## runner
|
|
160
|
+
|
|
161
|
+
**What happened.** The scheduler could not plan, launch, inspect, or cancel a
|
|
162
|
+
run, or a plan never got approved.
|
|
163
|
+
|
|
164
|
+
**What to change.** Read the message.
|
|
165
|
+
|
|
166
|
+
- `Control plan <id> is still parked awaiting approval after 8 attempts`: the
|
|
167
|
+
flow needs an approval nobody gave. The runner re-offers the same idempotent
|
|
168
|
+
request with a delay that doubles from one second, so it gives up a little
|
|
169
|
+
over two minutes in. Approve the plan, or stop scheduling a flow that requires
|
|
170
|
+
an interactive approval.
|
|
171
|
+
- `Control rejected the scheduled run: ...`: Control answered `Conflict`. The
|
|
172
|
+
message is Control's.
|
|
173
|
+
- `Control <tag> receipt did not include a run id`: an accepted launch came back
|
|
174
|
+
without the id the scheduler needs to monitor it.
|
|
175
|
+
- `Control could not ...`: the underlying Control call failed. The cause carries
|
|
176
|
+
the original error.
|
|
177
|
+
|
|
178
|
+
A run inspection error retains the active owner and overlap protection. The
|
|
179
|
+
monitor logs the cause and retries three times with doubling delays starting
|
|
180
|
+
at `runPollInterval`, capped at one minute. Exhaustion detaches the monitor;
|
|
181
|
+
subsequent ticks inspect the retained owner again. Restore Control access to
|
|
182
|
+
resume completion detection. The default run poll interval is fifteen seconds.
|
|
183
|
+
An inspection outage does not record a failed run or cancel it.
|
|
184
|
+
|
|
185
|
+
## runner_timeout
|
|
186
|
+
|
|
187
|
+
**What happened.** One `Runner.start`, `inspect`, or `cancel` call exceeded
|
|
188
|
+
`startTimeout`, `inspectTimeout`, or `cancelTimeout`. The defaults are four
|
|
189
|
+
minutes, thirty seconds, and thirty seconds. The call was interrupted.
|
|
190
|
+
|
|
191
|
+
**What to change.** Nothing is lost. A start that timed out leaves the
|
|
192
|
+
occurrence pending, so the next tick retries it under the same `idempotencyKey`
|
|
193
|
+
and a runtime that did start the run answers with the same run id. An
|
|
194
|
+
inspection that timed out is retried like any inspection failure. A
|
|
195
|
+
cancellation that timed out restores the prior run as active and queues the
|
|
196
|
+
replacement. If the runtime is healthy and merely slow, raise the deadline in
|
|
197
|
+
`Scheduler.Options`; a parked plan needs a `startTimeout` above the two minutes
|
|
198
|
+
`parkedAttempts` allows.
|
|
199
|
+
|
|
200
|
+
## store
|
|
201
|
+
|
|
202
|
+
**What happened.** A persistence operation failed, or a `TriggerStore.makeNoop`
|
|
203
|
+
method was called.
|
|
204
|
+
|
|
205
|
+
**What to change.** Read the message.
|
|
206
|
+
|
|
207
|
+
- `<method> is unavailable`: the composition provided `TriggerStore.layerNoop`
|
|
208
|
+
and something reached a method it did not override. Provide a real store, or
|
|
209
|
+
override that method.
|
|
210
|
+
- `could not run trigger migrations`: the database refused the schema. Check
|
|
211
|
+
that the SQL client points at a writable database.
|
|
212
|
+
- `could not decode trigger row`: a row's `input_json` did not parse, or the
|
|
213
|
+
row fails the declaration schema `register` enforces (an empty id, flow, or
|
|
214
|
+
cron, or a `maxCatchUp` above the limit). Something outside this package
|
|
215
|
+
wrote the row; the cause names the field.
|
|
216
|
+
- `trigger input is not JSON-serializable`: the input contained a cycle.
|
|
217
|
+
- `trigger store read failed` or `trigger store write failed`: the underlying
|
|
218
|
+
SQL call failed, and its error is the cause.
|
|
219
|
+
|
|
220
|
+
## The trigger did not fire, and nothing failed
|
|
221
|
+
|
|
222
|
+
First ask whether anything is polling. `store.lastHeartbeat()` answers `None`
|
|
223
|
+
when no scheduler has ever ticked against this store, and a `TriggerSummary`
|
|
224
|
+
from `Control.list` carries no `schedulerLastTickMs` in the same case. A
|
|
225
|
+
heartbeat older than `pollInterval` by a wide margin means the scheduler's scope
|
|
226
|
+
closed. Then, four behaviors are correct and surprising.
|
|
227
|
+
|
|
228
|
+
**A trigger with no durable cursor fires nothing on its first tick.** A trigger with no
|
|
229
|
+
`lastFiredAt` establishes a watermark at the latest boundary on first sight and
|
|
230
|
+
starts from the next one. Registering a weekly trigger on a Sunday evening does
|
|
231
|
+
not fire it for the Monday six days gone. Run a second tick after a boundary has
|
|
232
|
+
passed, or seed a `lastFiredAt` by recording a result.
|
|
233
|
+
|
|
234
|
+
**A boundary was skipped while a run was in flight.** That is `overlap: "skip"`,
|
|
235
|
+
the default. The occurrence is recorded as `skipped` and the cursor advances.
|
|
236
|
+
Choose `buffer-one` if one coalesced follow-up is enough.
|
|
237
|
+
|
|
238
|
+
**A buffered occurrence has not run yet.** The buffer drains on the next tick
|
|
239
|
+
after the active run settles, and it holds exactly one occurrence: a run that
|
|
240
|
+
overran four boundaries leaves one pending occurrence, the newest.
|
|
241
|
+
`store.activeRun(triggerId)` tells you whether something is still holding the
|
|
242
|
+
trigger.
|
|
243
|
+
|
|
244
|
+
**A backlog disappeared after downtime.** Either `catchUp` is `none`, which
|
|
245
|
+
replays no missed boundary, or the backlog exceeded `maxCatchUp` and was
|
|
246
|
+
abandoned with a warning annotated with the trigger id. Check the logs for
|
|
247
|
+
`A trigger abandoned catch-up work beyond its bound`. The current occurrence
|
|
248
|
+
still fires in both cases.
|
|
249
|
+
|
|
250
|
+
## The trigger fired twice
|
|
251
|
+
|
|
252
|
+
Check that both launches carry the same `idempotencyKey`. The key is
|
|
253
|
+
`<triggerId>:<occurrence ISO instant>`, so two hosts noticing the same boundary
|
|
254
|
+
produce the same key. Two different keys identify different occurrences or
|
|
255
|
+
trigger ids.
|
|
256
|
+
|
|
257
|
+
Scheduled dispatch makes at-least-once launch attempts. `RunnerService.start`
|
|
258
|
+
must durably deduplicate by `idempotencyKey` and return the same run identity on
|
|
259
|
+
replay, including across host restarts.
|
|
260
|
+
The Control-backed adapter forwards this key to Control. Custom runners must
|
|
261
|
+
provide the same durable deduplication contract.
|
|
262
|
+
|
|
263
|
+
The same database can issue another launch-capable claim after lease expiry or
|
|
264
|
+
launch compensation. A launch may have been accepted before its `launched`
|
|
265
|
+
result was persisted; a crash or result-write failure in that window causes a
|
|
266
|
+
retry. An expired lease can also overlap an earlier in-flight attempt. Repeated
|
|
267
|
+
`start` calls with one key are expected during recovery. If they produce
|
|
268
|
+
different run identities, check the runner's durable idempotency records and
|
|
269
|
+
whether the hosts share that deduplication state.
|
|
270
|
+
|
|
271
|
+
## A run is stuck holding the trigger
|
|
272
|
+
|
|
273
|
+
A trigger whose `activeRun` never clears is holding either a run the runner
|
|
274
|
+
still calls active, or a launch reservation from a process that died. A
|
|
275
|
+
reservation looks like `trigger-reservation:<triggerId>:<occurrence>`, and
|
|
276
|
+
`TriggerStore.isReservation` says so.
|
|
277
|
+
|
|
278
|
+
A reservation releases itself when its 5-minute lease expires, at which point
|
|
279
|
+
the store restores the unfinished occurrence to pending work. Wait out the
|
|
280
|
+
lease. A real run id that never clears is a run the control plane still reports
|
|
281
|
+
as live, which is a question for the run, not for the trigger.
|