@nestjs/workflows 0.0.1
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/LICENSE +23 -0
- package/README.md +50 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +8 -0
- package/dist/core/codecs/aes-gcm-payload.codec.d.ts +42 -0
- package/dist/core/codecs/aes-gcm-payload.codec.js +119 -0
- package/dist/core/codecs/index.d.ts +2 -0
- package/dist/core/codecs/index.js +2 -0
- package/dist/core/codecs/payload-codecs.d.ts +59 -0
- package/dist/core/codecs/payload-codecs.js +130 -0
- package/dist/core/index.d.ts +16 -0
- package/dist/core/index.js +21 -0
- package/dist/core/interfaces/clock.interface.d.ts +18 -0
- package/dist/core/interfaces/clock.interface.js +1 -0
- package/dist/core/interfaces/index.d.ts +9 -0
- package/dist/core/interfaces/index.js +9 -0
- package/dist/core/interfaces/leased-worker-options.interface.d.ts +94 -0
- package/dist/core/interfaces/leased-worker-options.interface.js +1 -0
- package/dist/core/interfaces/limits.interface.d.ts +92 -0
- package/dist/core/interfaces/limits.interface.js +1 -0
- package/dist/core/interfaces/payload-codec.interface.d.ts +64 -0
- package/dist/core/interfaces/payload-codec.interface.js +1 -0
- package/dist/core/interfaces/result-waiter-options.interface.d.ts +52 -0
- package/dist/core/interfaces/result-waiter-options.interface.js +1 -0
- package/dist/core/interfaces/retry-settings.interface.d.ts +71 -0
- package/dist/core/interfaces/retry-settings.interface.js +1 -0
- package/dist/core/interfaces/schedule-store.interface.d.ts +143 -0
- package/dist/core/interfaces/schedule-store.interface.js +1 -0
- package/dist/core/interfaces/schedule.interface.d.ts +106 -0
- package/dist/core/interfaces/schedule.interface.js +1 -0
- package/dist/core/interfaces/scheduler-options.interface.d.ts +152 -0
- package/dist/core/interfaces/scheduler-options.interface.js +1 -0
- package/dist/core/limits/index.d.ts +1 -0
- package/dist/core/limits/index.js +1 -0
- package/dist/core/limits/limits.d.ts +61 -0
- package/dist/core/limits/limits.js +130 -0
- package/dist/core/results/index.d.ts +1 -0
- package/dist/core/results/index.js +1 -0
- package/dist/core/results/result-waiter.d.ts +54 -0
- package/dist/core/results/result-waiter.js +140 -0
- package/dist/core/retries/index.d.ts +1 -0
- package/dist/core/retries/index.js +1 -0
- package/dist/core/retries/retry.d.ts +60 -0
- package/dist/core/retries/retry.js +107 -0
- package/dist/core/scheduling/cron.util.d.ts +52 -0
- package/dist/core/scheduling/cron.util.js +262 -0
- package/dist/core/scheduling/encoded-schedule.store.d.ts +26 -0
- package/dist/core/scheduling/encoded-schedule.store.js +54 -0
- package/dist/core/scheduling/in-memory-schedule.store.d.ts +20 -0
- package/dist/core/scheduling/in-memory-schedule.store.js +90 -0
- package/dist/core/scheduling/index.d.ts +3 -0
- package/dist/core/scheduling/index.js +3 -0
- package/dist/core/scheduling/rrule.util.d.ts +55 -0
- package/dist/core/scheduling/rrule.util.js +371 -0
- package/dist/core/scheduling/schedule-spec.d.ts +57 -0
- package/dist/core/scheduling/schedule-spec.js +233 -0
- package/dist/core/scheduling/scheduler.d.ts +134 -0
- package/dist/core/scheduling/scheduler.js +450 -0
- package/dist/core/scheduling/time-zone.util.d.ts +18 -0
- package/dist/core/scheduling/time-zone.util.js +88 -0
- package/dist/core/time/clock.d.ts +28 -0
- package/dist/core/time/clock.js +35 -0
- package/dist/core/time/duration.d.ts +21 -0
- package/dist/core/time/duration.js +30 -0
- package/dist/core/time/index.d.ts +2 -0
- package/dist/core/time/index.js +2 -0
- package/dist/core/utils/canonical.util.d.ts +2 -0
- package/dist/core/utils/canonical.util.js +4 -0
- package/dist/core/utils/maybe.util.d.ts +8 -0
- package/dist/core/utils/maybe.util.js +14 -0
- package/dist/core/utils/payload-place.util.d.ts +10 -0
- package/dist/core/utils/payload-place.util.js +32 -0
- package/dist/core/workers/index.d.ts +1 -0
- package/dist/core/workers/index.js +1 -0
- package/dist/core/workers/leased-worker.d.ts +86 -0
- package/dist/core/workers/leased-worker.js +280 -0
- package/dist/cqrs/decorators/index.d.ts +2 -0
- package/dist/cqrs/decorators/index.js +2 -0
- package/dist/cqrs/decorators/signal-on.decorator.d.ts +18 -0
- package/dist/cqrs/decorators/signal-on.decorator.js +34 -0
- package/dist/cqrs/decorators/start-on.decorator.d.ts +18 -0
- package/dist/cqrs/decorators/start-on.decorator.js +49 -0
- package/dist/cqrs/index.d.ts +3 -0
- package/dist/cqrs/index.js +5 -0
- package/dist/cqrs/interfaces/index.d.ts +4 -0
- package/dist/cqrs/interfaces/index.js +4 -0
- package/dist/cqrs/interfaces/signal-on-options.interface.d.ts +22 -0
- package/dist/cqrs/interfaces/signal-on-options.interface.js +1 -0
- package/dist/cqrs/interfaces/start-on-options.interface.d.ts +23 -0
- package/dist/cqrs/interfaces/start-on-options.interface.js +1 -0
- package/dist/cqrs/interfaces/workflow-dispatcher-context.interface.d.ts +10 -0
- package/dist/cqrs/interfaces/workflow-dispatcher-context.interface.js +1 -0
- package/dist/cqrs/interfaces/workflow-event-route.interface.d.ts +20 -0
- package/dist/cqrs/interfaces/workflow-event-route.interface.js +1 -0
- package/dist/cqrs/utils/event-routes.util.d.ts +7 -0
- package/dist/cqrs/utils/event-routes.util.js +22 -0
- package/dist/cqrs/workflow-event.publisher.d.ts +52 -0
- package/dist/cqrs/workflow-event.publisher.js +257 -0
- package/dist/cqrs/workflow-events.explorer.d.ts +27 -0
- package/dist/cqrs/workflow-events.explorer.js +92 -0
- package/dist/cqrs/workflows-cqrs.module.d.ts +10 -0
- package/dist/cqrs/workflows-cqrs.module.js +27 -0
- package/dist/decorators/index.d.ts +1 -0
- package/dist/decorators/index.js +1 -0
- package/dist/decorators/workflow.decorator.d.ts +7 -0
- package/dist/decorators/workflow.decorator.js +52 -0
- package/dist/errors/child-workflow-failed.error.d.ts +13 -0
- package/dist/errors/child-workflow-failed.error.js +14 -0
- package/dist/errors/index.d.ts +13 -0
- package/dist/errors/index.js +13 -0
- package/dist/errors/non-retryable-step.error.d.ts +4 -0
- package/dist/errors/non-retryable-step.error.js +7 -0
- package/dist/errors/step-failed.error.d.ts +15 -0
- package/dist/errors/step-failed.error.js +16 -0
- package/dist/errors/step-timeout.error.d.ts +5 -0
- package/dist/errors/step-timeout.error.js +8 -0
- package/dist/errors/workflow-definition.error.d.ts +10 -0
- package/dist/errors/workflow-definition.error.js +13 -0
- package/dist/errors/workflow-failed.error.d.ts +20 -0
- package/dist/errors/workflow-failed.error.js +17 -0
- package/dist/errors/workflow-id-conflict.error.d.ts +6 -0
- package/dist/errors/workflow-id-conflict.error.js +9 -0
- package/dist/errors/workflow-interrupt.error.d.ts +32 -0
- package/dist/errors/workflow-interrupt.error.js +19 -0
- package/dist/errors/workflow-non-determinism.error.d.ts +4 -0
- package/dist/errors/workflow-non-determinism.error.js +7 -0
- package/dist/errors/workflow-not-found.error.d.ts +10 -0
- package/dist/errors/workflow-not-found.error.js +13 -0
- package/dist/errors/workflow-result-timeout.error.d.ts +11 -0
- package/dist/errors/workflow-result-timeout.error.js +13 -0
- package/dist/errors/workflow-state.error.d.ts +11 -0
- package/dist/errors/workflow-state.error.js +14 -0
- package/dist/errors/workflow.error.d.ts +7 -0
- package/dist/errors/workflow.error.js +7 -0
- package/dist/events/index.d.ts +3 -0
- package/dist/events/index.js +3 -0
- package/dist/events/workflow-events.interface.d.ts +145 -0
- package/dist/events/workflow-events.interface.js +1 -0
- package/dist/events/workflow-events.service.d.ts +17 -0
- package/dist/events/workflow-events.service.js +38 -0
- package/dist/events/workflows.channels.d.ts +3 -0
- package/dist/events/workflows.channels.js +9 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +23 -0
- package/dist/interfaces/index.d.ts +13 -0
- package/dist/interfaces/index.js +13 -0
- package/dist/interfaces/serialized-workflow-error.interface.d.ts +6 -0
- package/dist/interfaces/serialized-workflow-error.interface.js +1 -0
- package/dist/interfaces/workflow-client.interface.d.ts +156 -0
- package/dist/interfaces/workflow-client.interface.js +1 -0
- package/dist/interfaces/workflow-clock.interface.d.ts +7 -0
- package/dist/interfaces/workflow-clock.interface.js +1 -0
- package/dist/interfaces/workflow-context.interface.d.ts +241 -0
- package/dist/interfaces/workflow-context.interface.js +1 -0
- package/dist/interfaces/workflow-decorator-options.interface.d.ts +101 -0
- package/dist/interfaces/workflow-decorator-options.interface.js +1 -0
- package/dist/interfaces/workflow-instance.interface.d.ts +83 -0
- package/dist/interfaces/workflow-instance.interface.js +1 -0
- package/dist/interfaces/workflow-payload-codec.interface.d.ts +32 -0
- package/dist/interfaces/workflow-payload-codec.interface.js +1 -0
- package/dist/interfaces/workflow-retry-options.interface.d.ts +9 -0
- package/dist/interfaces/workflow-retry-options.interface.js +1 -0
- package/dist/interfaces/workflow-runner.interface.d.ts +11 -0
- package/dist/interfaces/workflow-runner.interface.js +1 -0
- package/dist/interfaces/workflow-schedule.interface.d.ts +104 -0
- package/dist/interfaces/workflow-schedule.interface.js +1 -0
- package/dist/interfaces/workflow-storage.interface.d.ts +4 -0
- package/dist/interfaces/workflow-storage.interface.js +1 -0
- package/dist/interfaces/workflow-store.interface.d.ts +488 -0
- package/dist/interfaces/workflow-store.interface.js +1 -0
- package/dist/interfaces/workflows-module-options.interface.d.ts +80 -0
- package/dist/interfaces/workflows-module-options.interface.js +1 -0
- package/dist/mysql/index.d.ts +5 -0
- package/dist/mysql/index.js +8 -0
- package/dist/mysql/interfaces/index.d.ts +1 -0
- package/dist/mysql/interfaces/index.js +1 -0
- package/dist/mysql/interfaces/mysql-workflow-store-options.interface.d.ts +25 -0
- package/dist/mysql/interfaces/mysql-workflow-store-options.interface.js +1 -0
- package/dist/mysql/key-limits.d.ts +30 -0
- package/dist/mysql/key-limits.js +41 -0
- package/dist/mysql/migrations/index.d.ts +7 -0
- package/dist/mysql/migrations/index.js +16 -0
- package/dist/mysql/migrations/initial.migration.d.ts +9 -0
- package/dist/mysql/migrations/initial.migration.js +104 -0
- package/dist/mysql/mysql-workflow.store.d.ts +197 -0
- package/dist/mysql/mysql-workflow.store.js +828 -0
- package/dist/postgres/errors/index.d.ts +1 -0
- package/dist/postgres/errors/index.js +2 -0
- package/dist/postgres/index.d.ts +5 -0
- package/dist/postgres/index.js +8 -0
- package/dist/postgres/interfaces/index.d.ts +1 -0
- package/dist/postgres/interfaces/index.js +1 -0
- package/dist/postgres/interfaces/postgres-workflow-store-options.interface.d.ts +22 -0
- package/dist/postgres/interfaces/postgres-workflow-store-options.interface.js +1 -0
- package/dist/postgres/migrations/index.d.ts +6 -0
- package/dist/postgres/migrations/index.js +15 -0
- package/dist/postgres/migrations/initial.migration.d.ts +6 -0
- package/dist/postgres/migrations/initial.migration.js +106 -0
- package/dist/postgres/postgres-workflow.store.d.ts +132 -0
- package/dist/postgres/postgres-workflow.store.js +586 -0
- package/dist/services/index.d.ts +6 -0
- package/dist/services/index.js +6 -0
- package/dist/services/workflow-client.service.d.ts +146 -0
- package/dist/services/workflow-client.service.js +436 -0
- package/dist/services/workflow-execution.service.d.ts +267 -0
- package/dist/services/workflow-execution.service.js +1076 -0
- package/dist/services/workflow-registry.service.d.ts +56 -0
- package/dist/services/workflow-registry.service.js +172 -0
- package/dist/services/workflow-scheduler.service.d.ts +54 -0
- package/dist/services/workflow-scheduler.service.js +173 -0
- package/dist/services/workflow-schedules.service.d.ts +60 -0
- package/dist/services/workflow-schedules.service.js +193 -0
- package/dist/services/workflow-worker.service.d.ts +92 -0
- package/dist/services/workflow-worker.service.js +468 -0
- package/dist/signals/index.d.ts +1 -0
- package/dist/signals/index.js +1 -0
- package/dist/signals/workflow.signal.d.ts +17 -0
- package/dist/signals/workflow.signal.js +28 -0
- package/dist/sql/rate-windows.d.ts +41 -0
- package/dist/sql/rate-windows.js +53 -0
- package/dist/sql/workflow-rows.d.ts +16 -0
- package/dist/sql/workflow-rows.js +102 -0
- package/dist/sql/workflow-schema.error.d.ts +21 -0
- package/dist/sql/workflow-schema.error.js +15 -0
- package/dist/storage/encoded-workflow.store.d.ts +71 -0
- package/dist/storage/encoded-workflow.store.js +201 -0
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.js +1 -0
- package/dist/storage/workflow.storage.d.ts +55 -0
- package/dist/storage/workflow.storage.js +159 -0
- package/dist/stores/in-memory-workflow.store.d.ts +75 -0
- package/dist/stores/in-memory-workflow.store.js +380 -0
- package/dist/stores/index.d.ts +1 -0
- package/dist/stores/index.js +1 -0
- package/dist/testing/assertions.d.ts +10 -0
- package/dist/testing/assertions.js +72 -0
- package/dist/testing/index.d.ts +38 -0
- package/dist/testing/index.js +1089 -0
- package/dist/testing/schedule-store.contract.d.ts +33 -0
- package/dist/testing/schedule-store.contract.js +161 -0
- package/dist/utils/index.d.ts +7 -0
- package/dist/utils/index.js +7 -0
- package/dist/utils/journal-limits.util.d.ts +6 -0
- package/dist/utils/journal-limits.util.js +25 -0
- package/dist/utils/new-instance.util.d.ts +26 -0
- package/dist/utils/new-instance.util.js +87 -0
- package/dist/utils/normalize.util.d.ts +5 -0
- package/dist/utils/normalize.util.js +11 -0
- package/dist/utils/run-timeout.util.d.ts +3 -0
- package/dist/utils/run-timeout.util.js +15 -0
- package/dist/utils/schedule-spec.util.d.ts +16 -0
- package/dist/utils/schedule-spec.util.js +9 -0
- package/dist/utils/schedule-store.util.d.ts +15 -0
- package/dist/utils/schedule-store.util.js +44 -0
- package/dist/utils/serialize-error.util.d.ts +7 -0
- package/dist/utils/serialize-error.util.js +22 -0
- package/dist/utils/step-scope.util.d.ts +16 -0
- package/dist/utils/step-scope.util.js +38 -0
- package/dist/workflows.constants.d.ts +10 -0
- package/dist/workflows.constants.js +10 -0
- package/dist/workflows.module-definition.d.ts +4 -0
- package/dist/workflows.module-definition.js +6 -0
- package/dist/workflows.module.d.ts +24 -0
- package/dist/workflows.module.js +68 -0
- package/package.json +123 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { Logger } from '@nestjs/common';
|
|
3
|
+
import { PayloadCodecs } from '../codecs/payload-codecs.js';
|
|
4
|
+
import { systemClock } from '../time/clock.js';
|
|
5
|
+
import { canonical } from '../utils/canonical.util.js';
|
|
6
|
+
import { EncodedScheduleStore } from './encoded-schedule.store.js';
|
|
7
|
+
import { latestOccurrences, nextOccurrence, occurrenceId, sameTiming } from './schedule-spec.js';
|
|
8
|
+
/** `missed: 'skip'` still starts an occurrence found this soon after its time. */
|
|
9
|
+
const ON_TIME_MS = 60_000;
|
|
10
|
+
/** `missed: 'all'` starts at most this many of the occurrences it missed, the latest. */
|
|
11
|
+
const MAX_CATCH_UP = 100;
|
|
12
|
+
/** Schedules one production leases at most; the rest wait for the next. */
|
|
13
|
+
const BATCH = 100;
|
|
14
|
+
/** How many times a production tries to start an occurrence the store refuses (about 5 minutes, backing off). */
|
|
15
|
+
const MAX_START_ATTEMPTS = 10;
|
|
16
|
+
/** How often a process reconciles its code's declared schedules with the store, after doing so at its startup. */
|
|
17
|
+
const RECONCILE_MS = 60_000;
|
|
18
|
+
/** A process whose code declares a schedule confirms it when it reconciles, if its last confirmation is this old. */
|
|
19
|
+
const CONFIRM_MS = 60_000;
|
|
20
|
+
/** A declared schedule that no process confirmed for this long has no running code that declares it. */
|
|
21
|
+
const STALE_MS = 5 * 60_000;
|
|
22
|
+
/** The stored payload of a schedule that no codec can read any more: never equal to the code's. */
|
|
23
|
+
const UNREADABLE = Symbol('unreadable');
|
|
24
|
+
/**
|
|
25
|
+
* The engine of schedules, over a `ScheduleStore`: it starts each occurrence's run exactly once however many workers
|
|
26
|
+
* produce them, and keeps the schedules the code declares in step with it, also through rolling deploys. A package
|
|
27
|
+
* plugs in its runs (`fire`, `running`, `cancel`) and calls `produce()` from its worker's loop.
|
|
28
|
+
*
|
|
29
|
+
* - **One run per occurrence.** A production leases the due schedules (`claimSchedules`), records its decisions in
|
|
30
|
+
* each (the starts it will make, the runs to cancel) before making them, then clears them and hands the schedule
|
|
31
|
+
* back. A worker that dies or loses the lease in between leaves them to the next, which makes them again: a run's
|
|
32
|
+
* id, `occurrenceId(schedule, at)`, makes the second start a no-op. `runs`, what `limit` counts, is exact.
|
|
33
|
+
* - **missed** and **overlap** as `ScheduleOptions` say; a start the store refuses is retried, backing off from a
|
|
34
|
+
* second to a minute, ten times in all, while the schedule's other starts go ahead.
|
|
35
|
+
* - **Declared schedules follow the code.** At startup (`synced()`) and every minute (from `produce()`), a process
|
|
36
|
+
* saves the schedules its code declares, confirms them, and takes over one declared otherwise, unless a newer version
|
|
37
|
+
* of the code declared it and a process of that code still confirms it. It deletes a declared schedule its code
|
|
38
|
+
* doesn't declare once no process confirmed it for five minutes: the code that declares it is gone.
|
|
39
|
+
*
|
|
40
|
+
* ```ts
|
|
41
|
+
* const scheduler = new Scheduler({ store, targets, declared, fire, running, cancel, clock });
|
|
42
|
+
* await scheduler.synced(); // at startup
|
|
43
|
+
* const worker = new LeasedWorker({ ..., produce: async () => (await scheduler.produce(owner, leaseMs)).started });
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export class Scheduler {
|
|
47
|
+
constructor(options) {
|
|
48
|
+
this.options = options;
|
|
49
|
+
/** When `produce()` next reconciles the declared schedules with the code (the clock's time). */
|
|
50
|
+
this.reconcileAt = 0;
|
|
51
|
+
this.warned = new Set();
|
|
52
|
+
this.store = options.store;
|
|
53
|
+
this.clock = options.clock ?? systemClock;
|
|
54
|
+
this.logger = options.logger ?? new Logger('Scheduler');
|
|
55
|
+
this.labels = {
|
|
56
|
+
target: options.labels?.target ?? ((name) => `target "${name}"`),
|
|
57
|
+
targets: options.labels?.targets ?? 'targets',
|
|
58
|
+
payload: options.labels?.payload ?? 'payload',
|
|
59
|
+
upsert: options.labels?.upsert ?? 'upsert()',
|
|
60
|
+
};
|
|
61
|
+
this.encoded = new EncodedScheduleStore(options.store, options.codecs ?? new PayloadCodecs([]), options.payloadContext ?? ((schedule) => ({ field: 'payload', schedule })), this.logger);
|
|
62
|
+
}
|
|
63
|
+
/** The declared schedules, reconciled with the code once per process (again after a failure). Call it at startup. */
|
|
64
|
+
synced() {
|
|
65
|
+
this.syncing ??= this.reconcile(true).then(() => {
|
|
66
|
+
this.reconcileAt = this.clock.now() + RECONCILE_MS;
|
|
67
|
+
}, (error) => {
|
|
68
|
+
this.syncing = undefined;
|
|
69
|
+
throw error;
|
|
70
|
+
});
|
|
71
|
+
return this.syncing;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Leases the due schedules of this process's targets and starts their occurrences' runs; every minute it reconciles
|
|
75
|
+
* the declared schedules first. Call it from the worker's loop (`LeasedWorker`'s `produce`), with its owner and
|
|
76
|
+
* lease duration.
|
|
77
|
+
*/
|
|
78
|
+
async produce(owner, leaseMs) {
|
|
79
|
+
const targets = this.options.targets();
|
|
80
|
+
if (targets.length === 0) {
|
|
81
|
+
return { started: 0, cancelled: [] };
|
|
82
|
+
}
|
|
83
|
+
await this.synced();
|
|
84
|
+
const now = this.clock.now();
|
|
85
|
+
if (now >= this.reconcileAt) {
|
|
86
|
+
this.reconcileAt = now + RECONCILE_MS;
|
|
87
|
+
try {
|
|
88
|
+
await this.reconcile(false);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
this.logger.error(`Reconciling the schedules the ${this.labels.targets} declare failed; it is tried again in a minute.`, error);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const token = randomUUID();
|
|
95
|
+
const claimed = await this.encoded.claimSchedules({ owner, token, now, leaseUntil: now + leaseMs, limit: BATCH, targets });
|
|
96
|
+
const production = { started: 0, cancelled: [] };
|
|
97
|
+
for (const record of claimed) {
|
|
98
|
+
try {
|
|
99
|
+
const one = await this.produceOne(record, token, now);
|
|
100
|
+
production.started += one.started;
|
|
101
|
+
production.cancelled.push(...one.cancelled);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
this.logger.error(`Starting the occurrences of schedule "${record.id}" failed; it is retried when its lease expires.`, error);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return production;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Applies `change` to the stored schedule until it lands: a write conditional on the revision it read. `change`
|
|
111
|
+
* returns the new fields, or `null` to leave it. Resolves to the schedule as stored (`null`: none). With `replacing`,
|
|
112
|
+
* a schedule whose payload no codec can read any more is read as it is stored, so the new payload replaces it; with
|
|
113
|
+
* `asStored`, it is read and saved as the store holds it, its payload untouched (a pause needs no codec).
|
|
114
|
+
*/
|
|
115
|
+
async save(id, change, options = {}) {
|
|
116
|
+
const store = options.asStored ? this.store : this.encoded;
|
|
117
|
+
const read = options.replacing ? (id) => this.readReplacing(id) : (id) => store.getSchedule(id);
|
|
118
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
119
|
+
const current = await read(id);
|
|
120
|
+
const fields = change(current);
|
|
121
|
+
if (!fields) {
|
|
122
|
+
return current;
|
|
123
|
+
}
|
|
124
|
+
const saved = await store.saveSchedule({ ...fields, id, expectRevision: current?.revision ?? null, now: this.clock.now() });
|
|
125
|
+
if (saved) {
|
|
126
|
+
return saved;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const message = `Schedule "${id}" kept changing while it was being saved. Try again.`;
|
|
130
|
+
throw this.options.conflictError?.(message) ?? new Error(message);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* A schedule's fields after a change of what it is: a new one starts with its first occurrence after now; one whose
|
|
134
|
+
* timing is unchanged keeps its progress; one whose timing changed starts over from now (keeping its runs and the
|
|
135
|
+
* starts in flight, dropping an occurrence buffered with the old timing) and takes the lease from a worker that
|
|
136
|
+
* computed with the old timing. Its pause carries over.
|
|
137
|
+
*/
|
|
138
|
+
changed(current, next) {
|
|
139
|
+
const now = this.clock.now();
|
|
140
|
+
if (!current) {
|
|
141
|
+
const state = { next: nextOccurrence(next.spec, now), runs: 0, buffered: null, pending: [] };
|
|
142
|
+
return { ...next, paused: false, state, wakeAt: wakeAt(state, now), releaseLease: false };
|
|
143
|
+
}
|
|
144
|
+
const previous = current.state;
|
|
145
|
+
const retimed = !sameTiming(current.spec, next.spec);
|
|
146
|
+
// A retimed schedule drops its buffered occurrence, which then never starts: it no longer counts.
|
|
147
|
+
const runs = retimed && previous.buffered !== null ? previous.runs - 1 : previous.runs;
|
|
148
|
+
const ended = limitReached(next.spec, runs);
|
|
149
|
+
const state = {
|
|
150
|
+
...previous,
|
|
151
|
+
runs,
|
|
152
|
+
next: ended ? null : retimed ? nextOccurrence(next.spec, now) : (previous.next ?? nextOccurrence(next.spec, now)),
|
|
153
|
+
buffered: retimed ? null : previous.buffered,
|
|
154
|
+
};
|
|
155
|
+
return { ...next, paused: current.paused, state, wakeAt: wakeAt(state, now), releaseLease: retimed };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Pauses the schedule at once: it takes the lease, so a worker deciding its occurrences at that moment starts only
|
|
159
|
+
* what it had already decided, and nothing else starts until `resume()`. Resolves to it as stored, or `null` for an
|
|
160
|
+
* unknown id.
|
|
161
|
+
*/
|
|
162
|
+
pause(id) {
|
|
163
|
+
return this.save(id, (current) => (!current || current.paused ? null : { ...fieldsOf(current), paused: true, releaseLease: true }), { asStored: true });
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Resumes the schedule from its first occurrence after now: those due while it was paused are passed over, whatever
|
|
167
|
+
* `missed` says. Resolves to it as stored, or `null` for an unknown id.
|
|
168
|
+
*/
|
|
169
|
+
resume(id) {
|
|
170
|
+
return this.save(id, (current) => {
|
|
171
|
+
if (!current?.paused) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
const now = this.clock.now();
|
|
175
|
+
const spec = current.spec;
|
|
176
|
+
const previous = current.state;
|
|
177
|
+
const state = { ...previous, next: limitReached(spec, previous.runs) ? null : nextOccurrence(spec, now) };
|
|
178
|
+
return { ...fieldsOf(current), paused: false, state, wakeAt: wakeAt(state, now), releaseLease: true };
|
|
179
|
+
}, { asStored: true });
|
|
180
|
+
}
|
|
181
|
+
/** The schedule with its payload decoded, or `null` for an unknown id. Throws when no codec can read its payload. */
|
|
182
|
+
read(id) {
|
|
183
|
+
return this.encoded.getSchedule(id);
|
|
184
|
+
}
|
|
185
|
+
/** A schedule as stored, with its payload decoded; `null` (logged once) when no codec can read it any more. */
|
|
186
|
+
async readable(record) {
|
|
187
|
+
try {
|
|
188
|
+
return await this.encoded.decode(record);
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
if (!this.warned.has(`unreadable:${record.id}`)) {
|
|
192
|
+
this.warned.add(`unreadable:${record.id}`);
|
|
193
|
+
this.logger.warn(`Schedule "${record.id}" is shown without its ${this.labels.payload}, which can't be read: ${error.message}`);
|
|
194
|
+
}
|
|
195
|
+
return { ...record, payload: null };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Where a stored schedule is: when its next occurrence is due (`null` while paused, or once it ended), how many it
|
|
200
|
+
* started (what `limit` counts), and the occurrence buffered for `overlap: 'buffer-one'`.
|
|
201
|
+
*/
|
|
202
|
+
progress(record) {
|
|
203
|
+
const state = record.state;
|
|
204
|
+
return { nextAt: record.paused ? null : state.next, runs: state.runs, bufferedAt: state.buffered };
|
|
205
|
+
}
|
|
206
|
+
// ---------------------------------------------------------------- reconciling the declared schedules
|
|
207
|
+
/**
|
|
208
|
+
* Reconciles the declared schedules with this process's code: at startup, then about once a minute (from the
|
|
209
|
+
* productions). Safe in a rolling deploy, where processes of other code run beside it:
|
|
210
|
+
*
|
|
211
|
+
* - A schedule its code declares is saved when it is missing, and confirmed (`confirmedAt`) when its last
|
|
212
|
+
* confirmation is a minute old. One declared differently is replaced at startup, unless a newer version of the
|
|
213
|
+
* code declared it and a process of that code still confirms it; later, only once no process confirms it (the code
|
|
214
|
+
* that declared it is gone), or when an older version declared it.
|
|
215
|
+
* - A declared schedule its code doesn't declare is left while a process confirms it, since the code of a running
|
|
216
|
+
* process declares it, and deleted once none has for five minutes, whatever its target: otherwise the schedules of
|
|
217
|
+
* a target that no code has any more would stay forever. The processes whose code declares one save it again as
|
|
218
|
+
* they start, so after its processes were gone for longer, it starts over: from its next occurrence, unpaused, its
|
|
219
|
+
* runs counted from zero.
|
|
220
|
+
*/
|
|
221
|
+
async reconcile(startup) {
|
|
222
|
+
if (this.options.targets().length === 0) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const now = this.clock.now();
|
|
226
|
+
const declared = this.declared();
|
|
227
|
+
for (const [id, schedule] of declared) {
|
|
228
|
+
await this.save(id, (current) => {
|
|
229
|
+
if (current && !current.declared && !this.warned.has(id)) {
|
|
230
|
+
this.warned.add(id);
|
|
231
|
+
this.logger.warn(`Schedule "${id}" was saved with ${this.labels.upsert}; ${this.labels.target(schedule.target)} declares it now, and takes it over.`);
|
|
232
|
+
}
|
|
233
|
+
return this.declaredChange(current, schedule, { now, startup });
|
|
234
|
+
}, { replacing: true });
|
|
235
|
+
}
|
|
236
|
+
// Read as stored: removing a schedule needs nothing a codec encoded.
|
|
237
|
+
for (const record of await this.allDeclared()) {
|
|
238
|
+
if (!declared.has(record.id)) {
|
|
239
|
+
await this.remove(record, now);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
declaredChange(current, schedule, at) {
|
|
244
|
+
const payload = typeof schedule.payload === 'function' ? null : (schedule.payload ?? null);
|
|
245
|
+
const spec = { ...schedule.spec, declaredBy: schedule.version, confirmedAt: at.now };
|
|
246
|
+
if (!current?.declared) {
|
|
247
|
+
return this.changed(current, { target: schedule.target, declared: true, spec: spec, payload });
|
|
248
|
+
}
|
|
249
|
+
const stored = current.spec;
|
|
250
|
+
const sameTarget = current.target === schedule.target;
|
|
251
|
+
const samePayload = canonical(current.payload ?? null) === canonical(payload);
|
|
252
|
+
if (sameTarget && canonical(declaration(stored)) === canonical(schedule.spec) && (samePayload || current.payload === UNREADABLE)) {
|
|
253
|
+
// With a codec, the payload is saved again at each startup: encoded with the current key, a rotated one can go.
|
|
254
|
+
const rewrite = !samePayload || (at.startup && this.encoded.encodes && payload !== null);
|
|
255
|
+
const confirm = stored.confirmedAt === undefined || at.now - stored.confirmedAt >= CONFIRM_MS;
|
|
256
|
+
if (!rewrite && !confirm) {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
const { target, paused, wakeAt, state } = current;
|
|
260
|
+
const by = Math.max(stored.declaredBy ?? 0, schedule.version);
|
|
261
|
+
return { target, declared: true, spec: { ...stored, declaredBy: by, confirmedAt: at.now }, payload, paused, wakeAt, state, releaseLease: false };
|
|
262
|
+
}
|
|
263
|
+
// Declared otherwise by other code. A newer version's declaration stays while a process of it confirms it; the
|
|
264
|
+
// code that starts takes the others over, and a running process one that no process confirms any more (the code
|
|
265
|
+
// that declared it is gone) or an older version's.
|
|
266
|
+
const by = stored.declaredBy ?? 0;
|
|
267
|
+
const takeOver = !confirmed(current, at.now) || (sameTarget ? (at.startup ? by <= schedule.version : by < schedule.version) : at.startup);
|
|
268
|
+
return takeOver ? this.changed(current, { target: schedule.target, declared: true, spec: spec, payload }) : null;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* A schedule to save with a new payload (the code's, or an upsert's): one with a payload no codec can read any more
|
|
272
|
+
* (a dropped key) is read as it is stored, its payload marked unreadable, so the new payload can replace it.
|
|
273
|
+
*/
|
|
274
|
+
async readReplacing(id) {
|
|
275
|
+
try {
|
|
276
|
+
return await this.encoded.getSchedule(id);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
const record = await this.store.getSchedule(id);
|
|
280
|
+
return record && { ...record, payload: UNREADABLE };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Deletes a declared schedule this process's code doesn't declare, once no process whose code declares it has
|
|
285
|
+
* confirmed it for five minutes; again if it was written meanwhile, unless that write confirmed it.
|
|
286
|
+
*/
|
|
287
|
+
async remove(record, now) {
|
|
288
|
+
let current = record;
|
|
289
|
+
for (let attempt = 0; attempt < 10 && current?.declared && !confirmed(current, now); attempt++) {
|
|
290
|
+
if (await this.store.deleteSchedule(current.id, current.revision)) {
|
|
291
|
+
this.logger.log(`Deleted schedule "${current.id}" of ${this.labels.target(current.target)}: no process whose code declares it confirmed it for five minutes.`);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
current = await this.store.getSchedule(current.id);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/** Every declared schedule as stored, a page at a time. */
|
|
298
|
+
async allDeclared() {
|
|
299
|
+
const records = [];
|
|
300
|
+
for (let offset = 0;; offset += BATCH) {
|
|
301
|
+
const page = await this.store.listSchedules({ declared: true, limit: BATCH, offset });
|
|
302
|
+
records.push(...page);
|
|
303
|
+
if (page.length < BATCH) {
|
|
304
|
+
return records;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
declared() {
|
|
309
|
+
return this.options.declared?.() ?? new Map();
|
|
310
|
+
}
|
|
311
|
+
// ---------------------------------------------------------------- producing occurrences
|
|
312
|
+
async produceOne(record, token, now) {
|
|
313
|
+
const spec = record.spec;
|
|
314
|
+
const state = record.state;
|
|
315
|
+
const declared = record.declared ? this.declared().get(record.id) : undefined;
|
|
316
|
+
if (record.declared && !declared) {
|
|
317
|
+
// A declared schedule belongs to the code that declares it: in a rolling deploy, a worker of code that doesn't
|
|
318
|
+
// (yet, or any more) leaves it to one that does, and it goes once no process confirms it (see reconcile()).
|
|
319
|
+
await this.encoded.writeSchedule(record.id, token, { now, state, wakeAt: record.wakeAt, release: true });
|
|
320
|
+
if (!this.warned.has(`undeclared:${record.id}`)) {
|
|
321
|
+
this.warned.add(`undeclared:${record.id}`);
|
|
322
|
+
this.logger.warn(`Schedule "${record.id}" is declared by code this worker doesn't run; it leaves it to a worker whose code declares it.`);
|
|
323
|
+
}
|
|
324
|
+
return { started: 0, cancelled: [] };
|
|
325
|
+
}
|
|
326
|
+
const st = { ...state, pending: [...state.pending] };
|
|
327
|
+
const skipped = [];
|
|
328
|
+
const running = spec.overlap === 'allow' ? [] : await this.options.running(record);
|
|
329
|
+
const busy = () => running.length > 0 || st.pending.length > 0;
|
|
330
|
+
if (st.buffered !== null && !busy()) {
|
|
331
|
+
st.pending.push({ at: st.buffered, cancel: [] });
|
|
332
|
+
st.buffered = null;
|
|
333
|
+
}
|
|
334
|
+
if (st.next !== null && st.next <= now) {
|
|
335
|
+
const due = dueOccurrences(spec, st.next, now);
|
|
336
|
+
if (due.skipped) {
|
|
337
|
+
skipped.push({ reason: 'missed', ...due.skipped });
|
|
338
|
+
}
|
|
339
|
+
let starts = due.start;
|
|
340
|
+
if (spec.overlap === 'cancel-previous' && starts.length > 1) {
|
|
341
|
+
// Each would cancel the one before it at once: only the latest runs.
|
|
342
|
+
skipped.push({ reason: 'overlap', from: starts[0], to: starts[starts.length - 2] });
|
|
343
|
+
starts = starts.slice(-1);
|
|
344
|
+
}
|
|
345
|
+
for (const at of starts) {
|
|
346
|
+
if (limitReached(spec, st.runs)) {
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
if (spec.overlap === 'allow' || !busy()) {
|
|
350
|
+
st.pending.push({ at, cancel: [] });
|
|
351
|
+
}
|
|
352
|
+
else if (spec.overlap === 'cancel-previous') {
|
|
353
|
+
st.pending.push({ at, cancel: [...running, ...st.pending.map((start) => occurrenceId(record.id, start.at))] });
|
|
354
|
+
}
|
|
355
|
+
else if (spec.overlap === 'buffer-one' && st.buffered === null) {
|
|
356
|
+
st.buffered = at;
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
skipped.push({ reason: 'overlap', from: at, to: at });
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
st.runs++;
|
|
363
|
+
}
|
|
364
|
+
st.next = limitReached(spec, st.runs) ? null : nextOccurrence(spec, now);
|
|
365
|
+
}
|
|
366
|
+
// The decisions first: whatever happens to this worker from here on, the next producer finishes them.
|
|
367
|
+
if (canonical(st) !== canonical(state) && !(await this.encoded.writeSchedule(record.id, token, { now, state: st, wakeAt: now, release: false }))) {
|
|
368
|
+
return { started: 0, cancelled: [] };
|
|
369
|
+
}
|
|
370
|
+
const production = { started: 0, cancelled: [] };
|
|
371
|
+
const retries = [];
|
|
372
|
+
let skippedStarts = 0;
|
|
373
|
+
for (const start of st.pending) {
|
|
374
|
+
const occurrence = new Date(start.at).toISOString();
|
|
375
|
+
try {
|
|
376
|
+
for (const id of start.cancel) {
|
|
377
|
+
const reason = `Cancelled: schedule "${record.id}" started its occurrence of ${occurrence} (overlap: 'cancel-previous').`;
|
|
378
|
+
if (await this.options.cancel(id, reason, now)) {
|
|
379
|
+
production.cancelled.push(id);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const started = await this.options.fire({ schedule: record, spec, declared, id: occurrenceId(record.id, start.at), at: start.at, now });
|
|
383
|
+
if (!started) {
|
|
384
|
+
skippedStarts++;
|
|
385
|
+
}
|
|
386
|
+
else if (started.created) {
|
|
387
|
+
production.started++;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
// The store refused the start: tried again (after a backoff) by the next production, a few times, so a start
|
|
392
|
+
// that can never be stored doesn't hold the schedule up forever.
|
|
393
|
+
const attempts = (start.attempts ?? 0) + 1;
|
|
394
|
+
const message = `Schedule "${record.id}" couldn't start its occurrence of ${occurrence} (attempt ${attempts} of ${MAX_START_ATTEMPTS})`;
|
|
395
|
+
this.logger.error(attempts < MAX_START_ATTEMPTS ? `${message}; it tries again.` : `${message}; it gives up on it.`, error);
|
|
396
|
+
if (attempts < MAX_START_ATTEMPTS) {
|
|
397
|
+
retries.push({ ...start, attempts });
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
skippedStarts++;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// An occurrence that couldn't start (its payload threw, or the store kept refusing it) doesn't count toward `limit`.
|
|
405
|
+
const runs = st.runs - skippedStarts;
|
|
406
|
+
const done = { ...st, runs, next: st.next === null && !limitReached(spec, runs) ? nextOccurrence(spec, now) : st.next, pending: retries };
|
|
407
|
+
const backoff = Math.min(1_000 * 2 ** (Math.max(0, ...retries.map((start) => start.attempts ?? 0)) - 1), 60_000);
|
|
408
|
+
const due = retries.length > 0 ? Math.min(now + backoff, wakeAt({ ...done, pending: [] }, now) ?? Infinity) : wakeAt(done, now);
|
|
409
|
+
await this.encoded.writeSchedule(record.id, token, { now, state: done, wakeAt: due, release: true });
|
|
410
|
+
for (const skip of skipped) {
|
|
411
|
+
this.options.skipped?.(record, skip, now);
|
|
412
|
+
}
|
|
413
|
+
return production;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
/** What the code declares: the stored spec without its bookkeeping. */
|
|
417
|
+
function declaration({ declaredBy: _declaredBy, confirmedAt: _confirmedAt, ...spec }) {
|
|
418
|
+
return spec;
|
|
419
|
+
}
|
|
420
|
+
/** Whether a process whose code declares the schedule confirmed it lately: the code of a running process declares it. */
|
|
421
|
+
function confirmed(record, now) {
|
|
422
|
+
const { confirmedAt } = record.spec;
|
|
423
|
+
return confirmedAt !== undefined && now - confirmedAt < STALE_MS;
|
|
424
|
+
}
|
|
425
|
+
function fieldsOf(record) {
|
|
426
|
+
const { target, declared, spec, payload, paused, wakeAt, state } = record;
|
|
427
|
+
return { target, declared, spec, payload, paused, wakeAt, state };
|
|
428
|
+
}
|
|
429
|
+
/** When a worker next has something to do for the schedule: now for starts in flight or a buffered one (checked at every production), else its next occurrence. */
|
|
430
|
+
function wakeAt(state, now) {
|
|
431
|
+
return state.pending.length > 0 || state.buffered !== null ? now : state.next;
|
|
432
|
+
}
|
|
433
|
+
function limitReached(spec, runs) {
|
|
434
|
+
return spec.limit !== null && runs >= spec.limit;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* The due occurrences in `[from, now]` to start, by `spec.missed`, and the ones it passes over (as a range). Only the
|
|
438
|
+
* latest can be on time; `'skip'` starts it if it is, `'once'` either way, and `'all'` the latest 100.
|
|
439
|
+
*/
|
|
440
|
+
function dueOccurrences(spec, from, now) {
|
|
441
|
+
const keep = spec.missed === 'all' ? MAX_CATCH_UP : 1;
|
|
442
|
+
const latest = latestOccurrences(spec, from, now, keep + 1);
|
|
443
|
+
const due = latest.slice(-keep);
|
|
444
|
+
const start = spec.missed === 'skip' && due.length > 0 && now - due[due.length - 1] > ON_TIME_MS ? [] : due;
|
|
445
|
+
if (start.length === 0) {
|
|
446
|
+
return { start, skipped: latest.length > 0 ? { from, to: latest[latest.length - 1] } : null };
|
|
447
|
+
}
|
|
448
|
+
const passed = latest.length > keep ? latest[0] : null;
|
|
449
|
+
return { start, skipped: passed === null ? null : { from, to: passed } };
|
|
450
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Throws a TypeError naming `owner` for a zone Intl doesn't know (e.g. 'Europe/Warsaw', 'UTC', 'Asia/Kolkata' are fine). */
|
|
2
|
+
export declare function assertTimeZone(tz: unknown, owner: string): asserts tz is string;
|
|
3
|
+
/** The zone's UTC offset (ms, e.g. +3_600_000 for CET) at instant `utc`. */
|
|
4
|
+
export declare function offsetAt(utc: number, tz: string): number;
|
|
5
|
+
/** The wall time in `tz` at instant `utc` (ms kept). */
|
|
6
|
+
export declare function toWallTime(utc: number, tz: string): number;
|
|
7
|
+
/**
|
|
8
|
+
* The instant of wall time `wall` in `tz`. A wall time that happens twice (clocks going back) is its first
|
|
9
|
+
* instant; one that doesn't exist (clocks going forward) is read with the offset from before the change, so it
|
|
10
|
+
* lands that much later (02:30 on the day Europe/Warsaw skips 02:00-03:00 is 03:30 CEST) — RFC 5545's rule.
|
|
11
|
+
*/
|
|
12
|
+
export declare function fromWallTime(wall: number, tz: string): number;
|
|
13
|
+
/**
|
|
14
|
+
* How far clocks moved (ms, positive when they went forward) at a change less than that long before instant `utc`,
|
|
15
|
+
* else 0. Until then wall times and instants run out of step: when clocks skip 02:00-03:00, the skipped 02:30 lands
|
|
16
|
+
* at 03:30, still ahead at 03:15; when they repeat that hour, the first 02:45 comes before the second 02:15.
|
|
17
|
+
*/
|
|
18
|
+
export declare function recentClockChange(utc: number, tz: string): number;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const HOUR = 3_600_000;
|
|
2
|
+
const DAY = 86_400_000;
|
|
3
|
+
const formats = new Map();
|
|
4
|
+
/** Throws a TypeError naming `owner` for a zone Intl doesn't know (e.g. 'Europe/Warsaw', 'UTC', 'Asia/Kolkata' are fine). */
|
|
5
|
+
export function assertTimeZone(tz, owner) {
|
|
6
|
+
if (typeof tz !== 'string' || !isTimeZone(tz)) {
|
|
7
|
+
throw new TypeError(`Invalid time zone ${JSON.stringify(tz)} for ${owner}. Use an IANA name, such as "Europe/Warsaw" or "UTC".`);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** The zone's UTC offset (ms, e.g. +3_600_000 for CET) at instant `utc`. */
|
|
11
|
+
export function offsetAt(utc, tz) {
|
|
12
|
+
const second = Math.floor(utc / 1_000) * 1_000;
|
|
13
|
+
return wallSecond(second, tz) - second;
|
|
14
|
+
}
|
|
15
|
+
/** The wall time in `tz` at instant `utc` (ms kept). */
|
|
16
|
+
export function toWallTime(utc, tz) {
|
|
17
|
+
return utc + offsetAt(utc, tz);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The instant of wall time `wall` in `tz`. A wall time that happens twice (clocks going back) is its first
|
|
21
|
+
* instant; one that doesn't exist (clocks going forward) is read with the offset from before the change, so it
|
|
22
|
+
* lands that much later (02:30 on the day Europe/Warsaw skips 02:00-03:00 is 03:30 CEST) — RFC 5545's rule.
|
|
23
|
+
*/
|
|
24
|
+
export function fromWallTime(wall, tz) {
|
|
25
|
+
// A zone's changes are months apart, so the offsets half a day either side of `wall` are the ones in force
|
|
26
|
+
// before and after any change near it.
|
|
27
|
+
const before = wall - offsetAt(wall - 12 * HOUR, tz);
|
|
28
|
+
const after = wall - offsetAt(wall + 12 * HOUR, tz);
|
|
29
|
+
const first = Math.min(before, after);
|
|
30
|
+
if (toWallTime(first, tz) === wall) {
|
|
31
|
+
return first;
|
|
32
|
+
}
|
|
33
|
+
const second = Math.max(before, after);
|
|
34
|
+
if (second !== first && toWallTime(second, tz) === wall) {
|
|
35
|
+
return second;
|
|
36
|
+
}
|
|
37
|
+
return before;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* How far clocks moved (ms, positive when they went forward) at a change less than that long before instant `utc`,
|
|
41
|
+
* else 0. Until then wall times and instants run out of step: when clocks skip 02:00-03:00, the skipped 02:30 lands
|
|
42
|
+
* at 03:30, still ahead at 03:15; when they repeat that hour, the first 02:45 comes before the second 02:15.
|
|
43
|
+
*/
|
|
44
|
+
export function recentClockChange(utc, tz) {
|
|
45
|
+
const now = offsetAt(utc, tz);
|
|
46
|
+
const change = now - offsetAt(utc - DAY, tz);
|
|
47
|
+
return change !== 0 && offsetAt(utc - Math.abs(change), tz) !== now ? change : 0;
|
|
48
|
+
}
|
|
49
|
+
function isTimeZone(tz) {
|
|
50
|
+
try {
|
|
51
|
+
formatter(tz);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function formatter(tz) {
|
|
59
|
+
let format = formats.get(tz);
|
|
60
|
+
if (!format) {
|
|
61
|
+
// `era` tells year 1 from 1 BC, which both print as "1".
|
|
62
|
+
format = new Intl.DateTimeFormat('en-US', {
|
|
63
|
+
timeZone: tz,
|
|
64
|
+
hourCycle: 'h23',
|
|
65
|
+
era: 'short',
|
|
66
|
+
year: 'numeric',
|
|
67
|
+
month: '2-digit',
|
|
68
|
+
day: '2-digit',
|
|
69
|
+
hour: '2-digit',
|
|
70
|
+
minute: '2-digit',
|
|
71
|
+
second: '2-digit',
|
|
72
|
+
});
|
|
73
|
+
formats.set(tz, format);
|
|
74
|
+
}
|
|
75
|
+
return format;
|
|
76
|
+
}
|
|
77
|
+
/** The wall time in `tz` at `utc`, a whole second. */
|
|
78
|
+
function wallSecond(utc, tz) {
|
|
79
|
+
const parts = {};
|
|
80
|
+
for (const { type, value } of formatter(tz).formatToParts(utc)) {
|
|
81
|
+
parts[type] = value;
|
|
82
|
+
}
|
|
83
|
+
const year = parts.era === 'BC' ? 1 - Number(parts.year) : Number(parts.year);
|
|
84
|
+
// Not Date.UTC, which reads years 0-99 as 1900-1999.
|
|
85
|
+
const wall = new Date(Date.UTC(2000, 0, 1, Number(parts.hour), Number(parts.minute), Number(parts.second)));
|
|
86
|
+
wall.setUTCFullYear(year, Number(parts.month) - 1, Number(parts.day));
|
|
87
|
+
return wall.getTime();
|
|
88
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Clock } from '../interfaces/clock.interface.js';
|
|
2
|
+
import { type Duration } from './duration.js';
|
|
3
|
+
/**
|
|
4
|
+
* The clock of the real world: `Date.now()`.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* const clock = options.clock ?? systemClock;
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
export declare const systemClock: Clock;
|
|
11
|
+
/**
|
|
12
|
+
* A clock that only moves when told to, for tests: it starts at 1 January 2026, 00:00 UTC, or at the time it is given.
|
|
13
|
+
* Also exported by `@nestjs/workflows` as `ManualWorkflowClock`.
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* const clock = new ManualClock(Date.UTC(2026, 0, 5, 8));
|
|
17
|
+
* clock.advance('1h'); // 09:00
|
|
18
|
+
* clock.set(new Date('2026-01-06T00:00:00Z'));
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export declare class ManualClock implements Clock {
|
|
22
|
+
private current;
|
|
23
|
+
constructor(current?: number);
|
|
24
|
+
now(): number;
|
|
25
|
+
/** Moves the clock forward by `duration`, and returns the new time. */
|
|
26
|
+
advance(duration: Duration): number;
|
|
27
|
+
set(time: Date | number): void;
|
|
28
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { parseDuration } from './duration.js';
|
|
2
|
+
/**
|
|
3
|
+
* The clock of the real world: `Date.now()`.
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* const clock = options.clock ?? systemClock;
|
|
7
|
+
* ```
|
|
8
|
+
*/
|
|
9
|
+
export const systemClock = { now: () => Date.now() };
|
|
10
|
+
/**
|
|
11
|
+
* A clock that only moves when told to, for tests: it starts at 1 January 2026, 00:00 UTC, or at the time it is given.
|
|
12
|
+
* Also exported by `@nestjs/workflows` as `ManualWorkflowClock`.
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* const clock = new ManualClock(Date.UTC(2026, 0, 5, 8));
|
|
16
|
+
* clock.advance('1h'); // 09:00
|
|
17
|
+
* clock.set(new Date('2026-01-06T00:00:00Z'));
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export class ManualClock {
|
|
21
|
+
constructor(current = Date.UTC(2026, 0, 1)) {
|
|
22
|
+
this.current = current;
|
|
23
|
+
}
|
|
24
|
+
now() {
|
|
25
|
+
return this.current;
|
|
26
|
+
}
|
|
27
|
+
/** Moves the clock forward by `duration`, and returns the new time. */
|
|
28
|
+
advance(duration) {
|
|
29
|
+
this.current += parseDuration(duration);
|
|
30
|
+
return this.current;
|
|
31
|
+
}
|
|
32
|
+
set(time) {
|
|
33
|
+
this.current = time instanceof Date ? time.getTime() : time;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Milliseconds, or a string such as `"250ms"`, `"30s"`, `"15m"`, `"6h"`, `"3d"`, `"1w"`: the family's type for every
|
|
3
|
+
* time-valued option.
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* interface JobOptions {
|
|
7
|
+
* timeout?: Duration; // '2m', or 120_000
|
|
8
|
+
* }
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
export type Duration = number | `${number}${'ms' | 's' | 'm' | 'h' | 'd' | 'w'}`;
|
|
12
|
+
/**
|
|
13
|
+
* A `Duration` in milliseconds (a string's rounded to a whole one). Throws a `TypeError` for a negative or
|
|
14
|
+
* non-finite number, or a string it can't read.
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* parseDuration('15m'); // 900_000
|
|
18
|
+
* parseDuration(250); // 250
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export declare function parseDuration(duration: Duration): number;
|