@ayepi/work 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -18
- package/ayepi-work-deps-schedule.md +312 -0
- package/ayepi-work-ports.md +408 -0
- package/ayepi-work.md +926 -0
- package/dist/index.cjs +234 -56
- package/dist/index.d.cts +151 -50
- package/dist/index.d.ts +151 -50
- package/dist/index.js +234 -57
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ pnpm add @ayepi/work
|
|
|
15
15
|
```ts
|
|
16
16
|
import { defineWork, createWork } from '@ayepi/work'
|
|
17
17
|
|
|
18
|
-
const add = defineWork('add', (i: { a: number; b: number }) => i.a + i.b)
|
|
18
|
+
const add = defineWork('add', (i: { a: number; b: number }, ctx) => ctx.result(i.a + i.b))
|
|
19
19
|
const w = createWork({ work: [add] as const })
|
|
20
20
|
|
|
21
21
|
const sum = await w.enqueue(add({ a: 1, b: 2 })).result() // 3, typed as number
|
|
@@ -24,11 +24,20 @@ await w.stop()
|
|
|
24
24
|
|
|
25
25
|
Bare `import` has **no side effects** — the default instance does not auto-start.
|
|
26
26
|
|
|
27
|
+
## Handlers return a `WorkResult`
|
|
28
|
+
|
|
29
|
+
A handler **returns a `WorkResult`** describing what it produced:
|
|
30
|
+
|
|
31
|
+
- **`ctx.result(value, opts?)`** — contribute a value (its own `.result()` and the group). `opts`: `{ final }` locks the group result; `{ append }` accumulates.
|
|
32
|
+
- **`ctx.queue(items, opts?)`** — run sub-work (built `Work`s and/or nested results) in the same group; the work **delegates** (its `.result()` is `void`).
|
|
33
|
+
- **`ctx.void()`** — contribute nothing.
|
|
34
|
+
- **`.next(works, condition?, opts?)`** — a native dependency: queue `works` once the prior items satisfy `condition` (default `'all-success'`).
|
|
35
|
+
|
|
36
|
+
Each work then carries two inferred types — its *awaited-alone* result `S` (`.result()`) and its *group* contribution `G` (`.group()` / awaiting the handle). So `enqueue(root).group()` resolves to a **precise union from the workflow structure**, not the whole registry.
|
|
37
|
+
|
|
27
38
|
## Type safety
|
|
28
39
|
|
|
29
|
-
`defineWork(name, handler, opts?)` returns a **callable builder** typed by its input and
|
|
30
|
-
output. Pass a `const` tuple of builders to `createWork`, and both `enqueue` forms are
|
|
31
|
-
checked:
|
|
40
|
+
`defineWork(name, handler, opts?)` returns a **callable builder** typed by its input and the `WorkResult` it returns. Pass a `const` tuple to `createWork`, and both `enqueue` forms are checked:
|
|
32
41
|
|
|
33
42
|
```ts
|
|
34
43
|
w.enqueue(add({ a: 1, b: 2 })) // instance form
|
|
@@ -36,24 +45,27 @@ w.enqueue('add', { a: 1, b: 2 }) // name form (name ∈ registry, input typed
|
|
|
36
45
|
add({ a: 1 }) // ✗ type error: missing `b`
|
|
37
46
|
```
|
|
38
47
|
|
|
39
|
-
|
|
40
|
-
outputs (`GroupResult<Defs>`). `.result()` resolves this item's own output; `.group()` is
|
|
41
|
-
the explicit group form.
|
|
48
|
+
## Groups & workflows
|
|
42
49
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
group** (so awaiting the group waits for the children); `ctx.setResult(value)` records the
|
|
47
|
-
group's result.
|
|
50
|
+
`ctx.queue` composes sub-work into the same group; a returned tree is a workflow whose group
|
|
51
|
+
type is the precise union of its parts. `.next` adds native dependencies, and `ctx.result`
|
|
52
|
+
(with `final`/`append`) shapes the group's value (default: **last contributor to finish**).
|
|
48
53
|
|
|
49
54
|
```ts
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
+
const fetch = defineWork('fetch', (i: { id: string }, ctx) => ctx.result(load(i.id)))
|
|
56
|
+
const report = defineWork('report', (_i, ctx) => ctx.result(summary()))
|
|
57
|
+
const root = defineWork('root', (i: { ids: string[] }, ctx) =>
|
|
58
|
+
ctx.queue(i.ids.map((id) => fetch({ id }))).next([report({})], 'all-success'),
|
|
59
|
+
)
|
|
60
|
+
const out = await w.enqueue(root({ ids: ['a', 'b'] })) // typed: fetch's | report's output
|
|
55
61
|
```
|
|
56
62
|
|
|
63
|
+
Per-work `ctx`: `id`, `groupId`, `attempt`, `parent` (who queued it), `dependents` (ids it
|
|
64
|
+
depended on). A handler must **return** every `ctx.queue`/`result`/`void` it creates — an
|
|
65
|
+
un-returned one throws (opt out with `strictReturn: false`). Set `deadline`/`timeout` to make
|
|
66
|
+
an item that doesn't start+finish in time go terminal with an `'expired'` event. `setIdGenerator`
|
|
67
|
+
overrides how work ids are minted.
|
|
68
|
+
|
|
57
69
|
## Instance options, retries, delay
|
|
58
70
|
|
|
59
71
|
`delay`, `retry`, `priority`, and `group` are **per-instance options** — passed at queue
|
|
@@ -199,7 +211,7 @@ This package ships dense, machine-oriented reference docs written for **AI codin
|
|
|
199
211
|
- [`ayepi-work-ports.md`](./ayepi-work-ports.md)
|
|
200
212
|
- [`ayepi-work.md`](./ayepi-work.md)
|
|
201
213
|
|
|
202
|
-
They
|
|
214
|
+
They ship with this package and also live in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/work).
|
|
203
215
|
|
|
204
216
|
## License
|
|
205
217
|
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
ayepi-work-deps-schedule.md — reference for `@ayepi/work` (dependencies & scheduling), written for coding agents.
|
|
3
|
+
|
|
4
|
+
Copy this file into any project that depends on `@ayepi/work` (e.g. into your repo's
|
|
5
|
+
`docs/` or `.claude/` directory) and reference it from your agents and slash commands.
|
|
6
|
+
It documents the public API, the patterns the package expects, and how it works under the
|
|
7
|
+
hood, with copy-pasteable examples. Keep it in sync with the installed package version.
|
|
8
|
+
-->
|
|
9
|
+
|
|
10
|
+
# `@ayepi/work` — dependencies & scheduling
|
|
11
|
+
|
|
12
|
+
Part of the `@ayepi/work` doc set. See [`ayepi-work.md`](./ayepi-work.md) for the core
|
|
13
|
+
API and [`ayepi-work-ports.md`](./ayepi-work-ports.md) for ports/codec. All durations are
|
|
14
|
+
**milliseconds**.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Dependencies (fan-in gates)
|
|
19
|
+
|
|
20
|
+
A dependency is **itself a work item** — you enqueue it like anything else, often
|
|
21
|
+
alongside the works it waits `on`. It lives on the durable queue (so it survives a crash),
|
|
22
|
+
and its handler is **non-blocking**: each run reads the watched items' states and either
|
|
23
|
+
fires (queues its dependents, once) or **re-queues itself** with a small delay to check
|
|
24
|
+
again later. It never holds a worker slot waiting, so a backlog of dependencies can't
|
|
25
|
+
starve other work.
|
|
26
|
+
|
|
27
|
+
> **Prefer the native `.next` chain** ([below](#native-dependencies--next)) when you're
|
|
28
|
+
> building a workflow inside a handler — `ctx.queue([a, b]).next([c], 'all-success')` is the
|
|
29
|
+
> ergonomic form and widens the group type by `c`'s contribution. Reach for `dependency(...)`
|
|
30
|
+
> directly when you want to enqueue a standalone gate (e.g. alongside works at the top level).
|
|
31
|
+
|
|
32
|
+
### `dependency(opts)`
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
function dependency(opts: DependencyOptions): Work<DEPENDENCY_TYPE, void, void>
|
|
36
|
+
|
|
37
|
+
interface DependencyOptions {
|
|
38
|
+
readonly on: readonly (string | Work)[] // works (or their ids) to wait on
|
|
39
|
+
readonly queue: readonly Work[] // works to queue (into the same group) once satisfied
|
|
40
|
+
readonly config?: DependencyCondition // when to fire (default 'all-success')
|
|
41
|
+
readonly poll?: number // re-check interval ms (default 1000)
|
|
42
|
+
readonly timeout?: number // give up (dead-letter) after this long (ms)
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The built-in type name is exported as `DEPENDENCY_TYPE` (`'@work/dependency'`). Every work
|
|
47
|
+
system **auto-registers** the dependency handler — you never define it yourself.
|
|
48
|
+
|
|
49
|
+
### `DependencyCondition`
|
|
50
|
+
|
|
51
|
+
JSON-serializable, so the dependency evaluates identically on any instance:
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
type DependencyCondition =
|
|
55
|
+
| 'all-done' // every watched item reached a terminal state
|
|
56
|
+
| 'all-success' // every watched item succeeded
|
|
57
|
+
| { readonly count: number; readonly of?: 'done' | 'success' } // ≥ count reached the state ('done' default)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Terminal states are `success`, `failed`, `dead`. `'done'` means any terminal state;
|
|
61
|
+
`'success'` means specifically succeeded.
|
|
62
|
+
|
|
63
|
+
### `conditionMet(condition, states)`
|
|
64
|
+
|
|
65
|
+
The pure evaluator, exported for testing/inspection. A **missing** state counts as
|
|
66
|
+
"not yet done":
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
function conditionMet(condition: DependencyCondition, states: readonly (WorkState | undefined)[]): boolean
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { conditionMet } from '@ayepi/work'
|
|
74
|
+
|
|
75
|
+
conditionMet('all-done', [stSuccess, stDead]) // true
|
|
76
|
+
conditionMet('all-success', [stSuccess, stDead]) // false
|
|
77
|
+
conditionMet({ count: 2 }, [stSuccess, stFailed, undefined]) // true
|
|
78
|
+
conditionMet({ count: 2, of: 'success' }, [stSuccess, stDead]) // false
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Example: a dependency-gated finalizer
|
|
82
|
+
|
|
83
|
+
Enqueue the dependency alongside the works it waits on. Awaiting the dependency's handle
|
|
84
|
+
resolves once its queued dependents (in the same group) finish:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { defineWork, createWork, dependency } from '@ayepi/work'
|
|
88
|
+
|
|
89
|
+
const stepA = defineWork('stepA', (_i, ctx) => ctx.result('a'))
|
|
90
|
+
const stepB = defineWork('stepB', (_i, ctx) => ctx.result('b'))
|
|
91
|
+
const finalize = defineWork('finalize', (_i, ctx) => ctx.void()) // runs once both steps succeed
|
|
92
|
+
|
|
93
|
+
const w = createWork({ work: [stepA, stepB, finalize] as const })
|
|
94
|
+
|
|
95
|
+
const a = stepA({}) // ids assigned at build time
|
|
96
|
+
const b = stepB({})
|
|
97
|
+
w.enqueue(a)
|
|
98
|
+
w.enqueue(b)
|
|
99
|
+
const gate = w.enqueue(dependency({
|
|
100
|
+
on: [a, b], // accepts Work instances or string ids
|
|
101
|
+
queue: [finalize({})], // queued into the gate's group once satisfied
|
|
102
|
+
config: 'all-success',
|
|
103
|
+
poll: 10,
|
|
104
|
+
}))
|
|
105
|
+
await gate // settles after the queued finalize() completes
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
You can also queue everything together inside a handler — **return** the result so it runs
|
|
109
|
+
(and so the group type reflects it):
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
const root = defineWork('root', (_i, ctx) => {
|
|
113
|
+
const a = stepA(), b = stepB()
|
|
114
|
+
return ctx.queue([a, b, dependency({ on: [a, b], queue: [finalize()], config: 'all-success' })])
|
|
115
|
+
})
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
But the **native `.next` chain** says the same thing more directly (and types the group):
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
const root = defineWork('root', (_i, ctx) =>
|
|
122
|
+
ctx.queue([stepA(), stepB()]).next([finalize()], 'all-success'),
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Timeouts
|
|
127
|
+
|
|
128
|
+
With `timeout`, the dependency records an absolute deadline (`Date.now() + timeout`) at
|
|
129
|
+
build time. If the condition never holds, the dependency **dead-letters** past the deadline
|
|
130
|
+
(it does not retry — `DEP_RETRY_ATTEMPTS = 1`), and its dependents are never queued.
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
w.enqueue(dependency({ on: ['never-runs'], queue: [after({})], config: 'all-success', poll: 10, timeout: 30 }))
|
|
134
|
+
// after ~30ms the dependency dead-letters; `after` never runs
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### How dependencies work under the hood
|
|
138
|
+
|
|
139
|
+
- **Fire-once.** Before queueing dependents, the handler does
|
|
140
|
+
`ctx.claim('dep:<key>:fired')` (a `Store.setIfNotExists`). The `key` is stable across the
|
|
141
|
+
dependency's self-re-queues and redeliveries, so dependents queue **exactly once** across
|
|
142
|
+
a multi-pod fleet.
|
|
143
|
+
- **Non-blocking re-queue.** When not yet satisfied, the handler queues a fresh copy of
|
|
144
|
+
itself (same `key`, fresh queue id) with `{ delay: poll }`, so it never blocks a slot.
|
|
145
|
+
- **Remembered terminal statuses.** The input carries a `resolved` map of terminal statuses
|
|
146
|
+
already observed, carried forward across self-re-queues. This lets the dependency skip
|
|
147
|
+
re-reading settled works **and** avoid mistaking a since-evicted state (results expire
|
|
148
|
+
after 24 h) for a failure.
|
|
149
|
+
- It reads watched states via `ctx.states(ids)`, which reads from the shared store
|
|
150
|
+
(cross-instance).
|
|
151
|
+
|
|
152
|
+
Exported dependency symbols: `dependency`, `conditionMet`, `DEPENDENCY_TYPE`, and the
|
|
153
|
+
`DependencyOptions` type.
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Native dependencies — `.next`
|
|
158
|
+
|
|
159
|
+
A `WorkResult` (what a handler returns) has a **`.next`** method — the ergonomic, typed form
|
|
160
|
+
of a fan-in `dependency`. It queues the next works once the works the prior result queued
|
|
161
|
+
satisfy a condition, and **widens the group type** by what those next works contribute:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
next<Ns>(next: Ns, condition?: DependencyCondition, options?: WorkInstanceOptions): WorkResult<S, G | GroupOf<Ns>>
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
const fetch = defineWork('fetch', (i: { id: string }, ctx) => ctx.result(load(i.id))) // string
|
|
169
|
+
const report = defineWork('report', (_i, ctx) => ctx.result(makeReport())) // Report
|
|
170
|
+
const flow = defineWork('flow', (i: { ids: string[] }, ctx) =>
|
|
171
|
+
ctx.queue(i.ids.map((id) => fetch({ id }))) // run all fetches in the group …
|
|
172
|
+
.next([report({})], 'all-success'), // … then report once they all succeed
|
|
173
|
+
)
|
|
174
|
+
// enqueue(flow(...)).group() → string | Report (the structural union)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
- The method is **`.next`**, *not* `.then` — a `then` member would make the `WorkResult` a
|
|
178
|
+
*thenable*, and the engine's `Promise.resolve(...)` of the handler's return would try to
|
|
179
|
+
adopt/await it.
|
|
180
|
+
- `condition` defaults to `'all-success'` and accepts the full `DependencyCondition` above.
|
|
181
|
+
- `.next` **chains**: `ctx.queue([a]).next([b], cond).next([c], cond2)` runs `c` after `b`'s
|
|
182
|
+
cohort satisfies `cond2`. Each link is a real `dependency` work under the hood (so it's
|
|
183
|
+
durable and fires exactly once), with the prior link's queued works as its `on` set.
|
|
184
|
+
- Works queued by a fired `.next` (or `dependency`) see `ctx.dependents` = the ids that gate
|
|
185
|
+
was waiting `on`; works queued by `ctx.queue` see `ctx.parent` = the queuing work's id.
|
|
186
|
+
- `options` is the usual `WorkInstanceOptions` (e.g. `{ priority }`) applied to the next
|
|
187
|
+
works. A `.next` you build but **don't return** trips strict-return (see
|
|
188
|
+
[the handler contract](./ayepi-work.md#the-handler-contract--returning-a-workresult)).
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## Scheduling
|
|
193
|
+
|
|
194
|
+
Register a recurring schedule with `w.schedule(config)`. It fires on either a **5-field
|
|
195
|
+
cron expression** or a **next-time function**, and returns a cancel function. One instance
|
|
196
|
+
fires per occurrence (a `setIfNotExists` lease keyed by the fire time), so a cron never
|
|
197
|
+
double-fires across a fleet.
|
|
198
|
+
|
|
199
|
+
### `ScheduleConfig`
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
interface ScheduleConfig {
|
|
203
|
+
readonly name: string // unique; also the firing-lease key
|
|
204
|
+
readonly cron?: string // 5-field cron — mutually exclusive with `next`
|
|
205
|
+
readonly next?: (now: number) => number | Date | void // compute next fire time; void to stop
|
|
206
|
+
readonly run: () => Work | void // produce the work to enqueue (or enqueue yourself + return void)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// w.schedule(config: ScheduleConfig): () => void // returns a cancel function
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Example: cron + fn schedules
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
// cron: every day at 03:00 (local time)
|
|
216
|
+
const cancelNightly = w.schedule({ name: 'nightly', cron: '0 3 * * *', run: () => report({}) })
|
|
217
|
+
|
|
218
|
+
// fn: every 60 seconds
|
|
219
|
+
const cancelTick = w.schedule({ name: 'tick', next: (now) => now + 60_000, run: () => poll({}) })
|
|
220
|
+
|
|
221
|
+
cancelNightly() // stop the schedule
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`run` returns a `Work` to enqueue it, or does its own enqueueing and returns `void`. A
|
|
225
|
+
`next` that returns `undefined`/`null` stops the schedule.
|
|
226
|
+
|
|
227
|
+
### The cron parser — `parseCron` / `nextAfter`
|
|
228
|
+
|
|
229
|
+
The dependency-free 5-field cron parser is exported for direct use:
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
function parseCron(expr: string): CronFields // throws on malformed expressions
|
|
233
|
+
function nextAfter(expr: string, fromMs: number): number | undefined // next match strictly after fromMs
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Format is `min hour dom mon dow`:
|
|
237
|
+
|
|
238
|
+
| Field | Range | Notes |
|
|
239
|
+
|---|---|---|
|
|
240
|
+
| minute | 0–59 | |
|
|
241
|
+
| hour | 0–23 | |
|
|
242
|
+
| day of month | 1–31 | |
|
|
243
|
+
| month | 1–12 | |
|
|
244
|
+
| day of week | 0–6 | 0 = Sunday |
|
|
245
|
+
|
|
246
|
+
Each field supports `*`, a number, an `a-b` range, a `<range>/<step>` step, and
|
|
247
|
+
comma-lists (e.g. `*/15`, `1-5`, `0,30`). When **both** day-of-month and day-of-week are
|
|
248
|
+
restricted (not `*`), standard cron **OR** semantics apply (matches either).
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
import { parseCron, nextAfter } from '@ayepi/work'
|
|
252
|
+
|
|
253
|
+
parseCron('*/15 0 * * 1-5') // ok
|
|
254
|
+
parseCron('* * * *') // throws: expected 5 fields
|
|
255
|
+
nextAfter('* * * * *', Date.now()) // top of the next minute (epoch ms)
|
|
256
|
+
nextAfter('0 0 30 2 *', Date.now()) // undefined — Feb 30 never matches within ~a year
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Notes & constraints:
|
|
260
|
+
- **Minute-granular.** Cron's resolution is one minute; `nextAfter` returns the top of a
|
|
261
|
+
matching minute.
|
|
262
|
+
- **Local time.** Matching uses local `Date` getters (`getHours`, `getDay`, …), not UTC.
|
|
263
|
+
- **Bounded scan.** `nextAfter` scans at most ~1 year (`366 * 24 * 60` minutes) forward;
|
|
264
|
+
a never-matching expression returns `undefined`.
|
|
265
|
+
|
|
266
|
+
### One-off scheduling — `runAt` & handler-thrown `WorkDelayError`
|
|
267
|
+
|
|
268
|
+
`w.schedule` is for **recurring** work. For a **one-off** future run, enqueue with an absolute
|
|
269
|
+
time instead — `enqueue(work, { runAt })` (epoch ms). `runAt` is an alternative to `delay` and
|
|
270
|
+
wins over it.
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
// run once, far in the future
|
|
274
|
+
w.enqueue(report({ day }), { runAt: Date.parse('2030-01-01T03:00:00Z') })
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
A running handler can also **defer its own item** to a later time by throwing `WorkDelayError`
|
|
278
|
+
— a **reschedule, not a retry**, so the `attempt` count is unchanged and a handler can defer
|
|
279
|
+
indefinitely (poll-style "not ready yet, try me later"):
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
import { WorkDelayError } from '@ayepi/work'
|
|
283
|
+
|
|
284
|
+
const poll = defineWork('poll', async (input, ctx) => {
|
|
285
|
+
if (!(await upstreamReady())) throw new WorkDelayError({ delay: 5 * 60_000 }) // re-run in 5 min, same attempt
|
|
286
|
+
return ctx.result(await doWork(input))
|
|
287
|
+
})
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
`WorkDelayError`'s `when` takes `{ runAt }` (absolute, wins) or `{ delay }` (relative ms).
|
|
291
|
+
The deferral re-enqueues the item at the resolved time and emits a `deferred` event. See
|
|
292
|
+
[`ayepi-work.md` → Deferral & scheduling](./ayepi-work.md#deferral--scheduling) for details.
|
|
293
|
+
|
|
294
|
+
**Far-future works on any backend.** Both `runAt` and a `WorkDelayError` deferral resolve to a
|
|
295
|
+
`startAt`. A backend that can't honor a long single delay (e.g. SQS caps `DelaySeconds` at
|
|
296
|
+
15 min) hands the item back early; the engine re-checks `startAt` on pop and **re-defers**
|
|
297
|
+
until it's actually due, so an item scheduled arbitrarily far out still fires at the right time
|
|
298
|
+
(see [`ayepi-work-ports.md` → Early-arrival re-defer](./ayepi-work-ports.md#early-arrival-re-defer-far-future-scheduling)).
|
|
299
|
+
|
|
300
|
+
### How scheduling works under the hood
|
|
301
|
+
|
|
302
|
+
A ~1 s tick (`SCHED_TICK = 1000`) checks whether the next fire time has arrived. When it
|
|
303
|
+
has, the instance tries to claim a `setIfNotExists` lease at
|
|
304
|
+
`<prefix>sched:<name>:<second-bucket>` (TTL `SCHED_LEASE_TTL = 90_000`). The instance that
|
|
305
|
+
wins the lease calls `run()` and enqueues the result; the next fire time is then recomputed.
|
|
306
|
+
Because the lease is keyed by the fire's second-bucket, exactly one instance fires per
|
|
307
|
+
occurrence across the fleet.
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
See also: [`ayepi-work.md`](./ayepi-work.md) (core API, groups, retries, "how it works")
|
|
312
|
+
and [`ayepi-work-ports.md`](./ayepi-work-ports.md) (ports, custom backends, JSON codec).
|