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.
Files changed (66) hide show
  1. package/README.md +45 -0
  2. package/dist/lib/ConcurrentRunner.js +47 -2
  3. package/dist/lib/ConcurrentRunner.js.map +1 -1
  4. package/dist/lib/createContinuousLock.js +23 -6
  5. package/dist/lib/createContinuousLock.js.map +1 -1
  6. package/dist/lib/cronTasks.js +119 -64
  7. package/dist/lib/cronTasks.js.map +1 -1
  8. package/dist/lib/index.js +11 -6
  9. package/dist/lib/index.js.map +1 -1
  10. package/dist/lib/reactiveTasks/LeaderElector.js +21 -3
  11. package/dist/lib/reactiveTasks/LeaderElector.js.map +1 -1
  12. package/dist/lib/reactiveTasks/MetricsCollector.js +118 -39
  13. package/dist/lib/reactiveTasks/MetricsCollector.js.map +1 -1
  14. package/dist/lib/reactiveTasks/ReactiveTaskPlanner.js +66 -31
  15. package/dist/lib/reactiveTasks/ReactiveTaskPlanner.js.map +1 -1
  16. package/dist/lib/reactiveTasks/ReactiveTaskRepository.js +19 -1
  17. package/dist/lib/reactiveTasks/ReactiveTaskRepository.js.map +1 -1
  18. package/dist/lib/reactiveTasks/ReactiveTaskTypes.js +7 -1
  19. package/dist/lib/reactiveTasks/ReactiveTaskTypes.js.map +1 -1
  20. package/dist/lib/reactiveTasks/ReactiveTaskWorker.js +80 -5
  21. package/dist/lib/reactiveTasks/ReactiveTaskWorker.js.map +1 -1
  22. package/dist/lib/reactiveTasks/index.js +20 -13
  23. package/dist/lib/reactiveTasks/index.js.map +1 -1
  24. package/dist/lib/task-management/OperationalTaskController.js +1 -1
  25. package/dist/lib/task-management/OperationalTaskController.js.map +1 -1
  26. package/dist/lib/testing/assertNoReactiveTaskErrors.js +16 -12
  27. package/dist/lib/testing/assertNoReactiveTaskErrors.js.map +1 -1
  28. package/dist/lib/testing/index.js +2 -0
  29. package/dist/lib/testing/index.js.map +1 -1
  30. package/dist/lib/testing/resolveWhitelistFilter.js +48 -0
  31. package/dist/lib/testing/resolveWhitelistFilter.js.map +1 -0
  32. package/dist/lib/testing/waitUntilReactiveTasksIdle.js +17 -46
  33. package/dist/lib/testing/waitUntilReactiveTasksIdle.js.map +1 -1
  34. package/dist/types/ConcurrentRunner.d.ts +16 -0
  35. package/dist/types/createContinuousLock.d.ts +17 -1
  36. package/dist/types/cronTasks.d.ts +17 -2
  37. package/dist/types/index.d.ts +2 -2
  38. package/dist/types/reactiveTasks/LeaderElector.d.ts +15 -1
  39. package/dist/types/reactiveTasks/MetricsCollector.d.ts +19 -8
  40. package/dist/types/reactiveTasks/ReactiveTaskPlanner.d.ts +11 -0
  41. package/dist/types/reactiveTasks/ReactiveTaskRepository.d.ts +10 -1
  42. package/dist/types/reactiveTasks/ReactiveTaskTypes.d.ts +19 -0
  43. package/dist/types/reactiveTasks/index.d.ts +8 -2
  44. package/dist/types/testing/assertNoReactiveTaskErrors.d.ts +4 -4
  45. package/dist/types/testing/index.d.ts +2 -0
  46. package/dist/types/testing/resolveWhitelistFilter.d.ts +35 -0
  47. package/dist/types/testing/waitUntilReactiveTasksIdle.d.ts +7 -13
  48. package/docs/.vitepress/config.mts +9 -1
  49. package/docs/cron-tasks.md +130 -1
  50. package/docs/error-handling.md +156 -0
  51. package/docs/reactive-tasks/guides.md +1 -1
  52. package/docs/reactive-tasks/index.md +2 -2
  53. package/docs/reactive-tasks/monitoring.md +7 -0
  54. package/docs/reactive-tasks/testing.md +187 -0
  55. package/docs/testing.md +60 -94
  56. package/package.json +36 -24
  57. package/docs/.vitepress/cache/deps/_metadata.json +0 -31
  58. package/docs/.vitepress/cache/deps/chunk-LE5NDSFD.js +0 -12824
  59. package/docs/.vitepress/cache/deps/chunk-LE5NDSFD.js.map +0 -7
  60. package/docs/.vitepress/cache/deps/package.json +0 -3
  61. package/docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js +0 -4505
  62. package/docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js.map +0 -7
  63. package/docs/.vitepress/cache/deps/vitepress___@vueuse_core.js +0 -9731
  64. package/docs/.vitepress/cache/deps/vitepress___@vueuse_core.js.map +0 -7
  65. package/docs/.vitepress/cache/deps/vue.js +0 -347
  66. package/docs/.vitepress/cache/deps/vue.js.map +0 -7
@@ -1,4 +1,4 @@
1
- import { Document, Filter } from 'mongodb';
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?: Array<{
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
  ],
@@ -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 Utilities](../testing.md)** for detailed usage and examples.
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](../testing.md)**: Built-in utilities (`waitUntilReactiveTasksIdle`) to ensure your reactive flows are robust and error-free.
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 Messaging
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.