mongodash 2.6.0 → 2.8.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 +45 -0
- package/dist/lib/ConcurrentRunner.js +47 -2
- package/dist/lib/ConcurrentRunner.js.map +1 -1
- package/dist/lib/createContinuousLock.js +23 -6
- package/dist/lib/createContinuousLock.js.map +1 -1
- package/dist/lib/cronTasks.js +119 -64
- package/dist/lib/cronTasks.js.map +1 -1
- package/dist/lib/index.js +11 -6
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/reactiveTasks/LeaderElector.js +21 -3
- package/dist/lib/reactiveTasks/LeaderElector.js.map +1 -1
- package/dist/lib/reactiveTasks/MetricsCollector.js +118 -39
- package/dist/lib/reactiveTasks/MetricsCollector.js.map +1 -1
- package/dist/lib/reactiveTasks/ReactiveTaskPlanner.js +66 -31
- package/dist/lib/reactiveTasks/ReactiveTaskPlanner.js.map +1 -1
- package/dist/lib/reactiveTasks/ReactiveTaskRepository.js +19 -1
- package/dist/lib/reactiveTasks/ReactiveTaskRepository.js.map +1 -1
- package/dist/lib/reactiveTasks/ReactiveTaskTypes.js +7 -1
- package/dist/lib/reactiveTasks/ReactiveTaskTypes.js.map +1 -1
- package/dist/lib/reactiveTasks/ReactiveTaskWorker.js +80 -5
- package/dist/lib/reactiveTasks/ReactiveTaskWorker.js.map +1 -1
- package/dist/lib/reactiveTasks/index.js +20 -13
- package/dist/lib/reactiveTasks/index.js.map +1 -1
- package/dist/lib/task-management/OperationalTaskController.js +1 -1
- package/dist/lib/task-management/OperationalTaskController.js.map +1 -1
- package/dist/lib/testing/assertNoReactiveTaskErrors.js +16 -12
- package/dist/lib/testing/assertNoReactiveTaskErrors.js.map +1 -1
- package/dist/lib/testing/index.js +2 -0
- package/dist/lib/testing/index.js.map +1 -1
- package/dist/lib/testing/resolveWhitelistFilter.js +48 -0
- package/dist/lib/testing/resolveWhitelistFilter.js.map +1 -0
- package/dist/lib/testing/waitUntilReactiveTasksIdle.js +17 -46
- package/dist/lib/testing/waitUntilReactiveTasksIdle.js.map +1 -1
- package/dist/types/ConcurrentRunner.d.ts +16 -0
- package/dist/types/createContinuousLock.d.ts +17 -1
- package/dist/types/cronTasks.d.ts +17 -2
- package/dist/types/index.d.ts +2 -2
- package/dist/types/reactiveTasks/LeaderElector.d.ts +15 -1
- package/dist/types/reactiveTasks/MetricsCollector.d.ts +19 -8
- package/dist/types/reactiveTasks/ReactiveTaskPlanner.d.ts +11 -0
- package/dist/types/reactiveTasks/ReactiveTaskRepository.d.ts +10 -1
- package/dist/types/reactiveTasks/ReactiveTaskTypes.d.ts +19 -0
- package/dist/types/reactiveTasks/index.d.ts +8 -2
- package/dist/types/testing/assertNoReactiveTaskErrors.d.ts +4 -4
- package/dist/types/testing/index.d.ts +2 -0
- package/dist/types/testing/resolveWhitelistFilter.d.ts +35 -0
- package/dist/types/testing/waitUntilReactiveTasksIdle.d.ts +7 -13
- package/docs/.vitepress/config.mts +9 -1
- package/docs/cron-tasks.md +130 -1
- package/docs/error-handling.md +156 -0
- package/docs/reactive-tasks/guides.md +1 -1
- package/docs/reactive-tasks/index.md +2 -2
- package/docs/reactive-tasks/monitoring.md +7 -0
- package/docs/reactive-tasks/testing.md +187 -0
- package/docs/testing.md +60 -94
- package/package.json +36 -24
- package/docs/.vitepress/cache/deps/_metadata.json +0 -31
- package/docs/.vitepress/cache/deps/chunk-LE5NDSFD.js +0 -12824
- package/docs/.vitepress/cache/deps/chunk-LE5NDSFD.js.map +0 -7
- package/docs/.vitepress/cache/deps/package.json +0 -3
- package/docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js +0 -4505
- package/docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js.map +0 -7
- package/docs/.vitepress/cache/deps/vitepress___@vueuse_core.js +0 -9731
- package/docs/.vitepress/cache/deps/vitepress___@vueuse_core.js.map +0 -7
- package/docs/.vitepress/cache/deps/vue.js +0 -347
- package/docs/.vitepress/cache/deps/vue.js.map +0 -7
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { WhitelistRule } from './resolveWhitelistFilter';
|
|
2
2
|
import { WaitUntilOptions } from './waitUntil';
|
|
3
3
|
/**
|
|
4
4
|
* Waits until the reactive task system is idle.
|
|
@@ -8,6 +8,11 @@ import { WaitUntilOptions } from './waitUntil';
|
|
|
8
8
|
* 3. No tasks in the database are in a pending or processing state.
|
|
9
9
|
*
|
|
10
10
|
* This enables robust E2E testing by ensuring that all side effects and cascading tasks have finished.
|
|
11
|
+
*
|
|
12
|
+
* @remarks
|
|
13
|
+
* Pending tasks scheduled far in the future (beyond `timeoutMs + stabilityDurationMs + 100ms`)
|
|
14
|
+
* are treated as "future work" and ignored. This prevents long-running retries (e.g. exponential backoff
|
|
15
|
+
* pushing `nextRunAt` hours ahead) from blocking the idle check forever.
|
|
11
16
|
*/
|
|
12
17
|
export interface WaitUntilReactiveTasksIdleOptions extends Partial<WaitUntilOptions> {
|
|
13
18
|
/**
|
|
@@ -15,17 +20,6 @@ export interface WaitUntilReactiveTasksIdleOptions extends Partial<WaitUntilOpti
|
|
|
15
20
|
* Global checks (Planner buffer, Active workers) are SKIPPED in this mode to ensure isolation
|
|
16
21
|
* from other running tests.
|
|
17
22
|
*/
|
|
18
|
-
whitelist?:
|
|
19
|
-
collection: string;
|
|
20
|
-
/**
|
|
21
|
-
* Filter to find relevant documents.
|
|
22
|
-
* If not provided, ALL documents in the collection are considered (use carefully!).
|
|
23
|
-
*/
|
|
24
|
-
filter?: Filter<Document>;
|
|
25
|
-
/**
|
|
26
|
-
* Optional task name filter.
|
|
27
|
-
*/
|
|
28
|
-
task?: string;
|
|
29
|
-
}>;
|
|
23
|
+
whitelist?: WhitelistRule[];
|
|
30
24
|
}
|
|
31
25
|
export declare function waitUntilReactiveTasksIdle(customOptions?: WaitUntilReactiveTasksIdleOptions): Promise<void>;
|
|
@@ -53,7 +53,15 @@ export default defineConfig({
|
|
|
53
53
|
text: 'Utilities',
|
|
54
54
|
items: [
|
|
55
55
|
{ text: 'Process In Batches', link: '/process-in-batches' },
|
|
56
|
-
{ text: 'Getters', link: '/getters' }
|
|
56
|
+
{ text: 'Getters', link: '/getters' },
|
|
57
|
+
{ text: 'Error Handling', link: '/error-handling' }
|
|
58
|
+
]
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
text: 'Testing',
|
|
62
|
+
items: [
|
|
63
|
+
{ text: 'Overview', link: '/testing' },
|
|
64
|
+
{ text: 'Testing Reactive Tasks', link: '/reactive-tasks/testing' }
|
|
57
65
|
]
|
|
58
66
|
}
|
|
59
67
|
],
|
package/docs/cron-tasks.md
CHANGED
|
@@ -135,10 +135,15 @@ import mongodash from 'mongodash';
|
|
|
135
135
|
mongodash.init({
|
|
136
136
|
// database connection
|
|
137
137
|
uri: 'mongodb://mongodb0.example.com:27017',
|
|
138
|
-
|
|
138
|
+
|
|
139
139
|
// true by default
|
|
140
140
|
runCronTasks: false,
|
|
141
141
|
|
|
142
|
+
// Maximum number of cron tasks this instance executes in parallel.
|
|
143
|
+
// Default 1 (serial). See the "Parallel execution within one instance"
|
|
144
|
+
// section earlier on this page.
|
|
145
|
+
cronTaskConcurrency: 5,
|
|
146
|
+
|
|
142
147
|
// valid only if CRON expressions used
|
|
143
148
|
// see https://www.npmjs.com/package/cron-parser for valid options
|
|
144
149
|
cronExpressionParserOptions: {
|
|
@@ -170,3 +175,127 @@ The system handles concurrency by locking tasks in MongoDB.
|
|
|
170
175
|
The system maintains a brief execution history in the database:
|
|
171
176
|
- **Limit**: Only the **last 5 runs** are stored in the `runLog` of the task document.
|
|
172
177
|
- Use this to monitor recent successes or failures.
|
|
178
|
+
|
|
179
|
+
### Parallel execution within one instance
|
|
180
|
+
|
|
181
|
+
By default each instance runs one cron task at a time. When you have many
|
|
182
|
+
independent cron tasks and a single long-running one would block the
|
|
183
|
+
others, opt in to parallel execution:
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
await mongodash.init({
|
|
187
|
+
// ...
|
|
188
|
+
cronTaskConcurrency: 5, // up to 5 cron tasks in flight on this instance
|
|
189
|
+
});
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
- A single task can **never** run twice in parallel, regardless of the
|
|
193
|
+
value. The per-task `lockedTill` lock guarantees that even within one
|
|
194
|
+
instance — and across instances — only one execution of a given
|
|
195
|
+
`taskId` is in flight at a time.
|
|
196
|
+
- `cronTaskConcurrency: 1` (the default) keeps the historical single-loop
|
|
197
|
+
behaviour.
|
|
198
|
+
- Raising the value only affects *different* tasks running at the same
|
|
199
|
+
time. Use it when you see head-of-line blocking on the cron collection.
|
|
200
|
+
|
|
201
|
+
## Monitoring
|
|
202
|
+
|
|
203
|
+
Cron tasks emit structured events through the `onInfo` callback. Each event
|
|
204
|
+
has a stable `code` that you can route to your logging stack without
|
|
205
|
+
parsing strings.
|
|
206
|
+
|
|
207
|
+
| Code constant | When it fires | Payload |
|
|
208
|
+
| :--- | :--- | :--- |
|
|
209
|
+
| `CODE_CRON_TASK_STARTED` | Handler is about to be invoked. Also fired once during `init` to announce that cron processing has begun. | `{ taskId, code }` |
|
|
210
|
+
| `CODE_CRON_TASK_FINISHED` | Handler returned without throwing. | `{ taskId, code, duration }` |
|
|
211
|
+
| `CODE_CRON_TASK_FAILED` | Handler threw. The same error is also passed to `onError`. | `{ taskId, code, reason, duration }` |
|
|
212
|
+
| `CODE_CRON_TASK_SCHEDULED` | The task has been scheduled for its next run. | `{ taskId, code, nextRunDate }` |
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
import { CODE_CRON_TASK_FAILED } from 'mongodash';
|
|
216
|
+
|
|
217
|
+
await mongodash.init({
|
|
218
|
+
onInfo: (event) => {
|
|
219
|
+
if (event.code === CODE_CRON_TASK_FAILED) {
|
|
220
|
+
metrics.increment('cron.failed', { task: event.taskId });
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
See also [**Error Handling**](./error-handling.md) for how `onError` and
|
|
227
|
+
`onInfo` compose.
|
|
228
|
+
|
|
229
|
+
## Task Management
|
|
230
|
+
|
|
231
|
+
### getCronTasksList(query?) => Promise<CronPagedResult\<CronTaskRecord\>>
|
|
232
|
+
|
|
233
|
+
Inspect the state of registered tasks - useful for admin UIs, health
|
|
234
|
+
checks, or integration tests.
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
import { getCronTasksList } from 'mongodash';
|
|
238
|
+
|
|
239
|
+
const page = await getCronTasksList({
|
|
240
|
+
filter: 'daily', // regex match against taskId (case-insensitive)
|
|
241
|
+
limit: 20,
|
|
242
|
+
skip: 0,
|
|
243
|
+
sort: { field: 'nextRunAt', direction: 1 },
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
for (const task of page.items) {
|
|
247
|
+
console.log(task._id, task.status, task.lastRun?.error);
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`status` can be `'idle'`, `'running'` (lock held), `'scheduled'`
|
|
252
|
+
(manual trigger pending), or `'failed'` (last run errored).
|
|
253
|
+
|
|
254
|
+
### getRegisteredCronTaskIds() => string[]
|
|
255
|
+
|
|
256
|
+
Returns the IDs of tasks registered *on this instance* (useful when
|
|
257
|
+
`runCronTasks: false` on some instances).
|
|
258
|
+
|
|
259
|
+
## Testing
|
|
260
|
+
|
|
261
|
+
Cron tasks expose three helpers that are primarily useful in tests. They
|
|
262
|
+
live on the main `mongodash` module alongside the rest of the cron API.
|
|
263
|
+
|
|
264
|
+
### Run a task synchronously
|
|
265
|
+
|
|
266
|
+
```typescript
|
|
267
|
+
import { runCronTask } from 'mongodash';
|
|
268
|
+
|
|
269
|
+
it('processes pending invoices', async () => {
|
|
270
|
+
await runCronTask('invoice-sweep');
|
|
271
|
+
const processed = await invoices.countDocuments({ status: 'processed' });
|
|
272
|
+
expect(processed).toBeGreaterThan(0);
|
|
273
|
+
});
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
`runCronTask(taskId)` enqueues the task and awaits its completion. It
|
|
277
|
+
throws if called from inside another running cron task — use
|
|
278
|
+
`scheduleCronTaskImmediately` / `triggerCronTask` for the "fire and
|
|
279
|
+
forget" case.
|
|
280
|
+
|
|
281
|
+
### Disable the scheduler in tests
|
|
282
|
+
|
|
283
|
+
Running cron jobs in the background of unit tests causes non-determinism.
|
|
284
|
+
Two options:
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
// Option A: never auto-start. Tests trigger everything explicitly.
|
|
288
|
+
await mongodash.init({ ..., runCronTasks: false });
|
|
289
|
+
|
|
290
|
+
// Option B: stop after init. Useful for tests that register tasks and
|
|
291
|
+
// then inspect state without running them.
|
|
292
|
+
import { stopCronTasks, startCronTasks } from 'mongodash';
|
|
293
|
+
stopCronTasks();
|
|
294
|
+
// ...
|
|
295
|
+
startCronTasks(); // if a test needs it back
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Called before the first `cronTask()` registration, `stopCronTasks()`
|
|
299
|
+
also prevents any task from starting later in the process.
|
|
300
|
+
|
|
301
|
+
See [**Testing overview**](./testing.md) for cross-subsystem test helpers.
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Error handling
|
|
2
|
+
|
|
3
|
+
Mongodash routes all runtime errors and informational events through two
|
|
4
|
+
pluggable callbacks you supply at `init` time: `onError` and `onInfo`. Both
|
|
5
|
+
default to `console.error` / `console.log` respectively, so you can adopt
|
|
6
|
+
the library without any observability plumbing and tighten it later.
|
|
7
|
+
|
|
8
|
+
## `onError`
|
|
9
|
+
|
|
10
|
+
Called with an `Error` whenever something went wrong **but the library was
|
|
11
|
+
able to continue running** — a failed cron task, a change-stream hiccup,
|
|
12
|
+
a planner flush that needed to be retried, etc. Unrecoverable errors
|
|
13
|
+
throw from the calling code directly (e.g. `init()` on a bad URI); they
|
|
14
|
+
are never routed through `onError`.
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
import mongodash, { OnError } from 'mongodash';
|
|
18
|
+
|
|
19
|
+
const onError: OnError = (err) => {
|
|
20
|
+
sentry.captureException(err);
|
|
21
|
+
logger.error({ err }, 'mongodash runtime error');
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
await mongodash.init({ uri: '...', onError });
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Signature
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
type OnError = (error: Error) => void;
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The callback is wrapped in a secure handler internally — if your
|
|
34
|
+
`onError` itself throws, the wrapper catches and logs it so a faulty
|
|
35
|
+
observability layer cannot crash the library. Prefer to keep the
|
|
36
|
+
callback fast and synchronous; offload heavy work (HTTP to an APM, disk
|
|
37
|
+
IO) to a queue you drain elsewhere.
|
|
38
|
+
|
|
39
|
+
## `onInfo`
|
|
40
|
+
|
|
41
|
+
Called with a structured event object whenever the library wants to
|
|
42
|
+
announce something interesting that is **not an error**: task lifecycle
|
|
43
|
+
transitions, reconciliation progress, leader elections, metric pushes.
|
|
44
|
+
|
|
45
|
+
Each event carries a stable `code` that you can match on without
|
|
46
|
+
parsing the human-readable `message`:
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
import mongodash, {
|
|
50
|
+
OnInfo,
|
|
51
|
+
CODE_CRON_TASK_FAILED,
|
|
52
|
+
CODE_REACTIVE_TASK_FAILED,
|
|
53
|
+
CODE_REACTIVE_TASK_LOCK_LOST,
|
|
54
|
+
} from 'mongodash';
|
|
55
|
+
|
|
56
|
+
const onInfo: OnInfo = (event) => {
|
|
57
|
+
switch (event.code) {
|
|
58
|
+
case CODE_CRON_TASK_FAILED:
|
|
59
|
+
case CODE_REACTIVE_TASK_FAILED:
|
|
60
|
+
metrics.increment('tasks.failed', { task: event.taskId });
|
|
61
|
+
break;
|
|
62
|
+
case CODE_REACTIVE_TASK_LOCK_LOST:
|
|
63
|
+
metrics.increment('tasks.lock_lost', { task: event.taskId });
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
logger.info(event);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
await mongodash.init({ uri: '...', onInfo });
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Signature
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
type OnInfo = (event: { message: string; code: string; [key: string]: unknown }) => void;
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Event catalog
|
|
79
|
+
|
|
80
|
+
| Code constant | Subsystem | When it fires |
|
|
81
|
+
| :--- | :--- | :--- |
|
|
82
|
+
| `CODE_CRON_TASK_STARTED` | cron | Handler about to be invoked (also on `init` to announce cron processing). |
|
|
83
|
+
| `CODE_CRON_TASK_FINISHED` | cron | Handler returned successfully. |
|
|
84
|
+
| `CODE_CRON_TASK_FAILED` | cron | Handler threw. The same error is also passed to `onError`. |
|
|
85
|
+
| `CODE_CRON_TASK_SCHEDULED` | cron | Task scheduled for next run. |
|
|
86
|
+
| `CODE_REACTIVE_TASK_STARTED` | reactive | Handler about to be invoked. |
|
|
87
|
+
| `CODE_REACTIVE_TASK_FINISHED` | reactive | Handler succeeded (or skipped via `TaskConditionFailedError`). |
|
|
88
|
+
| `CODE_REACTIVE_TASK_FAILED` | reactive | Handler threw. |
|
|
89
|
+
| `CODE_REACTIVE_TASK_LOCK_LOST` | reactive | A long-running worker's lock was stolen by another; the worker is backing off. |
|
|
90
|
+
| `CODE_REACTIVE_TASK_CLEANUP` | reactive | Orphaned task records were deleted by the cleanup policy. |
|
|
91
|
+
| `CODE_REACTIVE_TASK_INITIALIZED` | reactive | A reactive task was registered (also fires on startup for existing registrations). |
|
|
92
|
+
| `CODE_REACTIVE_TASK_PLANNER_STARTED` | reactive | Planner started (leader elected or restarted after an error). |
|
|
93
|
+
| `CODE_REACTIVE_TASK_PLANNER_STOPPED` | reactive | Planner stopped (leader lost or shutdown). |
|
|
94
|
+
| `CODE_REACTIVE_TASK_PLANNER_STREAM_ERROR` | reactive | Raw change-stream error observed. |
|
|
95
|
+
| `CODE_REACTIVE_TASK_PLANNER_RECONCILIATION_STARTED` | reactive | Full-scan reconciliation began. |
|
|
96
|
+
| `CODE_REACTIVE_TASK_PLANNER_RECONCILIATION_FINISHED` | reactive | Full-scan reconciliation finished. |
|
|
97
|
+
| `CODE_REACTIVE_TASK_LEADER_LOCK_LOST` | reactive | This instance was the leader and the lock expired on it. |
|
|
98
|
+
|
|
99
|
+
See [**Reactive tasks - Monitoring**](./reactive-tasks/monitoring.md) for
|
|
100
|
+
the matching Prometheus metrics and
|
|
101
|
+
[**Cron tasks - Monitoring**](./cron-tasks.md#monitoring) for the cron
|
|
102
|
+
side.
|
|
103
|
+
|
|
104
|
+
## Typed errors
|
|
105
|
+
|
|
106
|
+
A handful of errors can be recognised by reference (they are exported
|
|
107
|
+
classes) and deserve special handling:
|
|
108
|
+
|
|
109
|
+
### `TaskConditionFailedError`
|
|
110
|
+
|
|
111
|
+
Thrown from `context.getDocument()` inside a **reactive-task handler**
|
|
112
|
+
when the source document no longer matches the task filter (typically
|
|
113
|
+
because the user deleted or updated it between planning and execution).
|
|
114
|
+
The library treats it as a soft skip — the task record is marked
|
|
115
|
+
finished without raising an error. Operators generally do not need to
|
|
116
|
+
react.
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
import { reactiveTask, TaskConditionFailedError } from 'mongodash';
|
|
120
|
+
|
|
121
|
+
await reactiveTask({
|
|
122
|
+
// ...
|
|
123
|
+
handler: async (ctx) => {
|
|
124
|
+
try {
|
|
125
|
+
const doc = await ctx.getDocument();
|
|
126
|
+
// ...
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (err instanceof TaskConditionFailedError) {
|
|
129
|
+
// Expected - the upstream filter no longer matches. Skip silently.
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### `LockAlreadyAcquiredError` / `isLockAlreadyAcquiredError`
|
|
139
|
+
|
|
140
|
+
Thrown from `withLock` when another caller already holds the lock and
|
|
141
|
+
`maxWaitForLock` elapses. Use `isLockAlreadyAcquiredError(err)` when you
|
|
142
|
+
do not want to take a static import dependency on the class.
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
import { withLock, LockAlreadyAcquiredError, isLockAlreadyAcquiredError } from 'mongodash';
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
await withLock('nightly-rollup', async () => { /* ... */ });
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (isLockAlreadyAcquiredError(err)) {
|
|
151
|
+
// Another instance is already running the rollup - that's fine.
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
```
|
|
@@ -301,4 +301,4 @@ Testing asynchronous, event-driven workflows can be challenging. Mongodash provi
|
|
|
301
301
|
|
|
302
302
|
Use \`waitUntilReactiveTasksIdle\` to robustly wait for all side-effects (including retries and cascading tasks) to finish before making assertions.
|
|
303
303
|
|
|
304
|
-
See **[Testing
|
|
304
|
+
See **[Testing Reactive Tasks](./testing.md)** for detailed usage and examples.
|
|
@@ -15,7 +15,7 @@ Reactive Tasks allow you to define background jobs that trigger automatically wh
|
|
|
15
15
|
- **[Concurrency Control](./configuration.md)**: Limit parallel execution to protect downstream resources.
|
|
16
16
|
- **[Deduplication](./guides.md#idempotency--re-execution)**: Automatic debouncing ("wait for data to settle") and task merging.
|
|
17
17
|
- **[Observability](./monitoring.md)**: First-class Prometheus metrics support.
|
|
18
|
-
- **[Testing Support](
|
|
18
|
+
- **[Testing Support](./testing.md)**: Built-in utilities (`waitUntilReactiveTasksIdle`) to ensure your reactive flows are robust and error-free.
|
|
19
19
|
- **[Dashboard](../dashboard.md)**: A visual Dashboard to monitor, retry, and debug tasks.
|
|
20
20
|
- **Developer Friendly**: Zero-config local development, fully typed with TypeScript.
|
|
21
21
|
|
|
@@ -28,7 +28,7 @@ It is important to distinguish between Reactive Tasks and standard schedulers (l
|
|
|
28
28
|
|
|
29
29
|
Reactive Tasks support time-based operations via `debounce` (e.g., "Wait 1m after data change to settle") and `deferCurrent` (e.g., "Retry in 5m"), but they are fundamentally event-driven. If you need purely time-based jobs (e.g., "Daily Report" without any data change trigger), you can trigger them via a [Cron job](../cron-tasks.md), although you can model them as "Run on insert to 'daily_reports' collection".
|
|
30
30
|
|
|
31
|
-
## Advantages over Standard
|
|
31
|
+
## Advantages over Standard Queue systems
|
|
32
32
|
|
|
33
33
|
Using Reactive Tasks instead of a traditional message broker (RabbitMQ, Kafka) provides distinct architectural benefits:
|
|
34
34
|
|
|
@@ -59,6 +59,13 @@ The system exposes the following metrics with standardized labels:
|
|
|
59
59
|
| `reactive_tasks_global_lag_seconds` | Gauge | `task_name` | Age of the oldest `pending` task, measured from `dueAt`. This ensures deferred tasks still reflect their true waiting time. |
|
|
60
60
|
| `reactive_tasks_change_stream_lag_seconds` | Gauge | *none* | Time difference between now and the last processed Change Stream event. |
|
|
61
61
|
| `reactive_tasks_last_reconciliation_timestamp_seconds` | Gauge | *none* | Timestamp when the last full reconciliation (recovery) finished. |
|
|
62
|
+
| `reactive_tasks_leader_elections_total` | Counter | *none* | Number of times this instance became leader. A high rate indicates leader flapping (clock skew, slow heartbeats, network partitions). |
|
|
63
|
+
| `reactive_tasks_lock_lost_total` | Counter | `task_name` | Number of tasks whose execution lock was stolen by another worker (detected via CAS). A non-zero value means work was duplicated; usually a signal to increase `visibilityTimeoutMs` or investigate slow handlers. |
|
|
64
|
+
| `reactive_tasks_stream_errors_total` | Counter | *none* | Number of change-stream errors observed by this instance (disconnects, oplog lost, etc.). |
|
|
65
|
+
| `reactive_tasks_flush_failures_total` | Counter | *none* | Number of planner batches that failed and required a stream restart. Distinct from stream errors: the DB was reachable but the upsert pipeline rejected a batch. |
|
|
66
|
+
|
|
67
|
+
> [!NOTE]
|
|
68
|
+
> All new counters are **per-instance** (exported via the instance's local registry). In `cluster` mode they are summed across instances at scrape time; in `local` mode each instance reports its own value.
|
|
62
69
|
|
|
63
70
|
## Grafana Dashboard
|
|
64
71
|
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Testing Reactive Tasks
|
|
2
|
+
|
|
3
|
+
Testing asynchronous, event-driven workflows is notoriously hard: the "done"
|
|
4
|
+
state is not a return value from the code you called but a quiescent state of
|
|
5
|
+
a background system. Mongodash ships with helpers that address this directly.
|
|
6
|
+
|
|
7
|
+
> [!TIP]
|
|
8
|
+
> For the generic polling helper (not tied to reactive tasks), see
|
|
9
|
+
> [**`waitUntil`**](../testing.md).
|
|
10
|
+
|
|
11
|
+
## `waitUntilReactiveTasksIdle`
|
|
12
|
+
|
|
13
|
+
Blocks until the reactive-task subsystem is completely quiesced. This is
|
|
14
|
+
essential for end-to-end tests where you want to assert the final state after
|
|
15
|
+
a chain of cascading tasks has settled.
|
|
16
|
+
|
|
17
|
+
### What it waits for
|
|
18
|
+
|
|
19
|
+
It resolves only when **all** of the following validation checks pass
|
|
20
|
+
simultaneously (and remain true for `stabilityDurationMs`):
|
|
21
|
+
|
|
22
|
+
1. **Planner empty** — the internal `ReactiveTaskPlanner` has no buffered
|
|
23
|
+
change-stream events waiting to be flushed.
|
|
24
|
+
2. **Workers idle** — no `ReactiveTaskWorker` is currently processing a task
|
|
25
|
+
(active count is 0).
|
|
26
|
+
3. **Database settled** — no tasks in any registered task collection are in
|
|
27
|
+
`pending`, `processing`, or `processing_dirty` state.
|
|
28
|
+
- **Exception**: pending tasks scheduled for the _distant future_
|
|
29
|
+
(beyond the current `timeoutMs + stabilityDurationMs + 100ms`) are
|
|
30
|
+
ignored, so long retries (e.g. "retry in 1 hour") do not block your
|
|
31
|
+
test forever.
|
|
32
|
+
|
|
33
|
+
### Usage
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
import { waitUntilReactiveTasksIdle } from 'mongodash/testing';
|
|
37
|
+
|
|
38
|
+
it('should process user registration workflow', async () => {
|
|
39
|
+
// 1. Trigger the workflow
|
|
40
|
+
await users.insertOne({ email: 'test@example.com', status: 'new' });
|
|
41
|
+
|
|
42
|
+
// 2. Wait for the reactive task to process the insert AND any cascading
|
|
43
|
+
// tasks it may have triggered (welcome email, provisioning, etc.)
|
|
44
|
+
await waitUntilReactiveTasksIdle();
|
|
45
|
+
|
|
46
|
+
// 3. Assert the final state
|
|
47
|
+
const emailTask = await emailTasks.findOne({ email: 'test@example.com' });
|
|
48
|
+
expect(emailTask).toBeDefined();
|
|
49
|
+
expect(emailTask.status).toBe('sent');
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Configuration
|
|
54
|
+
|
|
55
|
+
Defaults are tuned for general use; override as needed:
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
await waitUntilReactiveTasksIdle({
|
|
59
|
+
timeoutMs: 30000,
|
|
60
|
+
stabilityDurationMs: 200, // 200ms of "silence" to catch in-flight cascading tasks
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Isolation with `whitelist`
|
|
65
|
+
|
|
66
|
+
When running tests in parallel against a shared database, use `whitelist` to
|
|
67
|
+
wait only for tasks that belong to this test. In whitelist mode the **active
|
|
68
|
+
workers** check is skipped so another test's worker pool does not block you;
|
|
69
|
+
the planner buffer check is still applied (otherwise we could return idle
|
|
70
|
+
before a change event we care about has even been turned into a task row).
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
await waitUntilReactiveTasksIdle({
|
|
74
|
+
whitelist: [
|
|
75
|
+
{ collection: 'users', filter: { _id: userId } }, // specific user
|
|
76
|
+
{ collection: 'orders', task: 'processOrder' }, // specific task on collection
|
|
77
|
+
],
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
> [!NOTE]
|
|
82
|
+
> The idle check queries task records by `sourceDocId`. If your trigger is
|
|
83
|
+
> very fast (e.g. a write immediately followed by `waitUntilReactiveTasksIdle`)
|
|
84
|
+
> and the default `stabilityDurationMs` is low, you may need to raise
|
|
85
|
+
> `stabilityDurationMs` to ensure the planner has had a chance to flush the
|
|
86
|
+
> event into a task record. Alternatively, poll with `waitUntil` for the
|
|
87
|
+
> task record to exist first, then call `waitUntilReactiveTasksIdle`.
|
|
88
|
+
|
|
89
|
+
## `assertNoReactiveTaskErrors`
|
|
90
|
+
|
|
91
|
+
Catches "silent failures" — tasks that threw and were logged but never
|
|
92
|
+
propagated to the test as an assertion error.
|
|
93
|
+
|
|
94
|
+
### Features
|
|
95
|
+
|
|
96
|
+
- **Time filtering** — only checks for errors that occurred after a given
|
|
97
|
+
`since` timestamp (typically test start).
|
|
98
|
+
- **Scope** — check globally or limit to specific source documents / task
|
|
99
|
+
names via `whitelist`.
|
|
100
|
+
- **Exclusions** — allow known errors (string or regex) without failing the
|
|
101
|
+
test.
|
|
102
|
+
|
|
103
|
+
### Usage
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
import { assertNoReactiveTaskErrors } from 'mongodash/testing';
|
|
107
|
+
|
|
108
|
+
it('should process successfully', async () => {
|
|
109
|
+
const startTime = new Date();
|
|
110
|
+
|
|
111
|
+
// ... run test steps ...
|
|
112
|
+
await waitUntilReactiveTasksIdle();
|
|
113
|
+
|
|
114
|
+
await assertNoReactiveTaskErrors({ since: startTime });
|
|
115
|
+
});
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Options
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
await assertNoReactiveTaskErrors({
|
|
122
|
+
since: startTime, // required
|
|
123
|
+
whitelist: [{
|
|
124
|
+
collection: 'users',
|
|
125
|
+
filter: { _id: userId },
|
|
126
|
+
}],
|
|
127
|
+
excludeErrors: [
|
|
128
|
+
'Expected Failure',
|
|
129
|
+
/Authorization Error/,
|
|
130
|
+
],
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## `configureForTesting`
|
|
135
|
+
|
|
136
|
+
Production defaults (`debounce: 1000ms`, `minPollMs: 200ms`, etc.) are tuned
|
|
137
|
+
for real workloads but make tests unnecessarily slow. `configureForTesting`
|
|
138
|
+
overrides these globally with minimal values (typically `10ms`).
|
|
139
|
+
|
|
140
|
+
Call it **once** in your test setup (`jest.setup.js`, `beforeAll`, etc.). It
|
|
141
|
+
works whether called before or after task registration.
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
import { configureForTesting } from 'mongodash/testing';
|
|
145
|
+
|
|
146
|
+
beforeAll(() => {
|
|
147
|
+
configureForTesting();
|
|
148
|
+
});
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Options
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
configureForTesting({
|
|
155
|
+
debounce: 0, // 0ms debounce (immediate planning)
|
|
156
|
+
minPollMs: 50, // polling interval
|
|
157
|
+
minBatchIntervalMs: 50,
|
|
158
|
+
});
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Whitelist rules
|
|
162
|
+
|
|
163
|
+
`waitUntilReactiveTasksIdle` and `assertNoReactiveTaskErrors` share a
|
|
164
|
+
`whitelist` option of type `WhitelistRule[]`. The type is exported so test
|
|
165
|
+
suites can share scope definitions:
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
import type { WhitelistRule } from 'mongodash/testing';
|
|
169
|
+
|
|
170
|
+
const scopeToUser = (userId: string): WhitelistRule[] => [
|
|
171
|
+
{ collection: 'users', filter: { _id: userId } },
|
|
172
|
+
{ collection: 'notifications', filter: { userId } },
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
await waitUntilReactiveTasksIdle({ whitelist: scopeToUser(id) });
|
|
176
|
+
await assertNoReactiveTaskErrors({ since, whitelist: scopeToUser(id) });
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Rule semantics:
|
|
180
|
+
|
|
181
|
+
- **No `filter` and no `task`** — matches every document and every task in
|
|
182
|
+
that collection ("match-all").
|
|
183
|
+
- **`filter` that matches zero documents** — the rule is treated as
|
|
184
|
+
"skip this collection", useful when the rule is built from a variable
|
|
185
|
+
that may be empty.
|
|
186
|
+
- **Multiple rules for the same collection** — OR-merged into a single
|
|
187
|
+
filter. A `match-all` rule wins over others.
|