@anteros/core 0.0.42 → 0.0.44
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/database/replication.ts +614 -26
- package/lib/agent.ts +279 -39
- package/lib/agentMemory.ts +103 -0
- package/lib/agents.ts +25 -2
- package/lib/define.ts +1 -1
- package/lib/mcpClient.ts +128 -0
- package/lib/providers.ts +169 -66
- package/lib/skills.ts +481 -0
- package/package.json +1 -1
- package/server/agents.ts +27 -6
- package/server/api.ts +74 -1
- package/tests/agent-mcp.test.ts +116 -0
- package/tests/agent-memory.test.ts +6 -6
- package/tests/agent-provider.test.ts +288 -0
- package/tests/agent-runtime.test.ts +369 -0
- package/tests/agent-skills.test.ts +177 -0
- package/tests/agent-summarize.test.ts +124 -0
- package/tests/agent-working-memory.test.ts +5 -3
- package/tests/agent.test.ts +18 -16
- package/tests/agents-api.test.ts +1 -1
- package/tests/api-private-fields.test.ts +196 -0
- package/tests/file-collection-replication.test.ts +2 -0
- package/tests/fixtures/agent-tenant/agents/support.agent.ts +1 -1
- package/tests/fixtures/agent-tenant/agents/weather.agent.ts +1 -1
- package/tests/fixtures/agents-api/agents/assistant.agent.ts +1 -1
- package/tests/fixtures/agents-api/agents/plain.agent.ts +1 -1
- package/tests/fixtures/agents-api/agents/private.agent.ts +1 -1
- package/tests/fixtures/repl-only/app.ts +3 -0
- package/tests/fixtures/replication-meta/agents/chat.agent.ts +1 -1
- package/tests/fixtures/skills-tenant/agents/docs.agent.ts +23 -0
- package/tests/fixtures/skills-tenant/agents/incomplete.agent.ts +14 -0
- package/tests/fixtures/skills-tenant/broken/bad-name.md +6 -0
- package/tests/fixtures/skills-tenant/broken/dup-a.md +6 -0
- package/tests/fixtures/skills-tenant/broken/dup-b.md +6 -0
- package/tests/fixtures/skills-tenant/broken/empty-body.md +4 -0
- package/tests/fixtures/skills-tenant/broken/long-compat.md +7 -0
- package/tests/fixtures/skills-tenant/broken/long-description.md +6 -0
- package/tests/fixtures/skills-tenant/broken/mismatch/SKILL.md +6 -0
- package/tests/fixtures/skills-tenant/broken/no-description.md +5 -0
- package/tests/fixtures/skills-tenant/broken/no-name.md +5 -0
- package/tests/fixtures/skills-tenant/broken/plain.md +4 -0
- package/tests/fixtures/skills-tenant/broken/unknown-key.md +9 -0
- package/tests/fixtures/skills-tenant/docs/pdf-forms.md +18 -0
- package/tests/fixtures/skills-tenant/guide/ml.md +9 -0
- package/tests/fixtures/skills-tenant/guides/pdf/SKILL.md +9 -0
- package/tests/fixtures/skills-tenant/runbooks/incident.md +9 -0
- package/tests/fixtures/skills-tenant/runbooks/notes.txt +2 -0
- package/tests/replication-change-stream.test.ts +240 -0
- package/tests/replication-control.test.ts +2 -0
- package/tests/replication-initial-sync.test.ts +2 -0
- package/tests/replication-meta.test.ts +3 -0
- package/tests/replication.test.ts +2 -0
- package/tests/vars-replication.test.ts +2 -0
- package/types/agent.d.ts +237 -7
- package/types/replication.d.ts +41 -4
package/database/replication.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
ReplicationDestination,
|
|
14
14
|
ReplicationInitialSync,
|
|
15
15
|
ReplicationMetaName,
|
|
16
|
+
ReplicationMode,
|
|
16
17
|
ReplicationResetOptions,
|
|
17
18
|
ReplicationResetResult,
|
|
18
19
|
ReplicationRunResult,
|
|
@@ -34,6 +35,27 @@ const DEFAULT_BATCH = 1000;
|
|
|
34
35
|
const BULK_CHUNK = 1000;
|
|
35
36
|
/** Distributed-lock TTL used to guard a run across cluster workers. */
|
|
36
37
|
const LOCK_TTL = 5 * 60_000;
|
|
38
|
+
/** Change detection out of the box — a date scan has to be opted into. */
|
|
39
|
+
const DEFAULT_MODE: ReplicationMode = "changeStream";
|
|
40
|
+
/** How often a watcher persists its resume token (bounded write rate on `_replication_`). */
|
|
41
|
+
const STATE_FLUSH_MS = 5_000;
|
|
42
|
+
/** Reopen delay of a failed change stream, doubling up to `WATCH_MAX_BACKOFF_MS`. */
|
|
43
|
+
const WATCH_BACKOFF_MS = 1_000;
|
|
44
|
+
const WATCH_MAX_BACKOFF_MS = 30_000;
|
|
45
|
+
/**
|
|
46
|
+
* Driver / server errors that mean the resume token is unusable and the stream must
|
|
47
|
+
* start from a fresh position: `CursorNotFound`, `ChangeStreamHistoryLost`,
|
|
48
|
+
* `ChangeStreamInvalidated`, `ChangeStreamFatalError`.
|
|
49
|
+
*/
|
|
50
|
+
const RESUME_TOKEN_ERRORS = new Set([43, 280, 286, 40573]);
|
|
51
|
+
/**
|
|
52
|
+
* How long a boot gives the collections it could not take at once — the ones a
|
|
53
|
+
* catch-up run is holding for the time of its pass. Shared by all of them, and
|
|
54
|
+
* **after** a first pass that takes everything free: a collection another node's
|
|
55
|
+
* stream owns must delay neither the boot nor its neighbours.
|
|
56
|
+
*/
|
|
57
|
+
const WATCH_LOCK_WAIT_MS = 5_000;
|
|
58
|
+
const WATCH_LOCK_RETRY_MS = 500;
|
|
37
59
|
|
|
38
60
|
/**
|
|
39
61
|
* Framework collections replication covers **by default**, with the date key each
|
|
@@ -145,6 +167,15 @@ function validateReplication(config: ReplicationConfig): void {
|
|
|
145
167
|
}
|
|
146
168
|
}
|
|
147
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Change detection for a tenant. Deliberately tenant-wide (not per collection): the
|
|
172
|
+
* mode decides how the *runs* are scheduled, and a mixed mode would need a schedule
|
|
173
|
+
* and a stream at once for the same destination.
|
|
174
|
+
*/
|
|
175
|
+
function resolveMode(config: ReplicationConfig): ReplicationMode {
|
|
176
|
+
return config.mode ?? DEFAULT_MODE;
|
|
177
|
+
}
|
|
178
|
+
|
|
148
179
|
function resolveKey(collection: Collection, config: ReplicationConfig): string {
|
|
149
180
|
return collection.replication?.key ?? config.key ?? DEFAULT_KEY;
|
|
150
181
|
}
|
|
@@ -153,6 +184,15 @@ function stateIdFor(tenantId: string, destinationId: string, collection: string)
|
|
|
153
184
|
return `rep:${tenantId}:${destinationId}:${collection}`;
|
|
154
185
|
}
|
|
155
186
|
|
|
187
|
+
/**
|
|
188
|
+
* The single lock of a (destination, collection) pair — taken by whichever writer
|
|
189
|
+
* owns that collection: a one-shot run for the duration of its pass, or a change
|
|
190
|
+
* stream for as long as it watches.
|
|
191
|
+
*/
|
|
192
|
+
function watchLockName(destinationId: string, slug: string): string {
|
|
193
|
+
return `replication:${destinationId}:${slug}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
156
196
|
function deleteMarkerId(collection: Collection, docId: string): string {
|
|
157
197
|
return `rep.delete:${collection._tenant_}:${collection.slug}:${docId}`;
|
|
158
198
|
}
|
|
@@ -281,6 +321,7 @@ async function saveState(
|
|
|
281
321
|
stats: ReplicationStats,
|
|
282
322
|
status: ReplicationState["status"],
|
|
283
323
|
error: string | null,
|
|
324
|
+
mode: ReplicationMode = DEFAULT_MODE,
|
|
284
325
|
): Promise<void> {
|
|
285
326
|
const now = new Date();
|
|
286
327
|
await meta.updateOne(
|
|
@@ -292,6 +333,7 @@ async function saveState(
|
|
|
292
333
|
destination: runtime.destination.id,
|
|
293
334
|
collection: collection.slug,
|
|
294
335
|
key,
|
|
336
|
+
mode,
|
|
295
337
|
cursor,
|
|
296
338
|
lastRunAt: now,
|
|
297
339
|
status,
|
|
@@ -463,6 +505,71 @@ async function replicateCollection(
|
|
|
463
505
|
key: string,
|
|
464
506
|
meta: any,
|
|
465
507
|
): Promise<ReplicationStats> {
|
|
508
|
+
const startedAt = Date.now();
|
|
509
|
+
|
|
510
|
+
// In `changeStream` mode the stream is the change detector: a run only seeds a
|
|
511
|
+
// destination that has never been backfilled (a fresh tenant, or after
|
|
512
|
+
// `replication.reset()`). Scanning on top of a live stream would let a stale
|
|
513
|
+
// read overwrite a newer event — see `startWatcher` for the per-collection lock
|
|
514
|
+
// that keeps the two apart.
|
|
515
|
+
if (resolveMode(config) === "changeStream") {
|
|
516
|
+
const stateId = stateIdFor(tenant.id, runtime.destination.id, collection.slug);
|
|
517
|
+
if (await meta.findOne({ _id: stateId })) {
|
|
518
|
+
return { inserted: 0, updated: 0, deleted: 0, failed: 0, batches: 0, durationMs: Date.now() - startedAt };
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const { stats, cursor } = await scanCollection(tenant, runtime, collection, config, key, meta);
|
|
523
|
+
|
|
524
|
+
// Deletes are always propagated too — a deleted document no longer exists, so
|
|
525
|
+
// a date cursor can never see it; they are captured by the hook + tombstone.
|
|
526
|
+
// In `changeStream` mode the stream carries deletes natively, so this is only a
|
|
527
|
+
// safety net for a backfill that raced with an applicative delete.
|
|
528
|
+
{
|
|
529
|
+
const expected = deleteDestinationsFor(config, collection);
|
|
530
|
+
while (true) {
|
|
531
|
+
const { deleted, processed } = await flushDeletes(meta, runtime, collection, key, config.batchSize ?? DEFAULT_BATCH, expected);
|
|
532
|
+
stats.deleted += deleted;
|
|
533
|
+
if (processed < (config.batchSize ?? DEFAULT_BATCH)) break;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
stats.durationMs = Date.now() - startedAt;
|
|
538
|
+
await saveState(
|
|
539
|
+
meta,
|
|
540
|
+
stateIdFor(tenant.id, runtime.destination.id, collection.slug),
|
|
541
|
+
tenant,
|
|
542
|
+
runtime,
|
|
543
|
+
collection,
|
|
544
|
+
key,
|
|
545
|
+
cursor,
|
|
546
|
+
stats,
|
|
547
|
+
"success",
|
|
548
|
+
null,
|
|
549
|
+
resolveMode(config),
|
|
550
|
+
);
|
|
551
|
+
return stats;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* One pass over the source, from the position stored in `_replication_`.
|
|
556
|
+
*
|
|
557
|
+
* This is the scan engine's core **and** the backfill a change stream runs on its
|
|
558
|
+
* first run — which is why it is shared: the indexes, the mirrored
|
|
559
|
+
* audit/workflow retention, the `initialSync` seeding and the batched upserts are
|
|
560
|
+
* identical, only the change *detection* differs.
|
|
561
|
+
*
|
|
562
|
+
* It leaves the state `running` and never flushes delete tombstones: the caller
|
|
563
|
+
* owns the run's final status.
|
|
564
|
+
*/
|
|
565
|
+
async function scanCollection(
|
|
566
|
+
tenant: Tenant,
|
|
567
|
+
runtime: Runtime,
|
|
568
|
+
collection: Collection,
|
|
569
|
+
config: ReplicationConfig,
|
|
570
|
+
key: string,
|
|
571
|
+
meta: any,
|
|
572
|
+
): Promise<{ stats: ReplicationStats; cursor: { value: any; id: any } }> {
|
|
466
573
|
const stateId = stateIdFor(tenant.id, runtime.destination.id, collection.slug);
|
|
467
574
|
const state = (await meta.findOne({ _id: stateId })) as ReplicationState | null;
|
|
468
575
|
let cursor = state?.cursor ?? { value: null, id: null };
|
|
@@ -529,24 +636,405 @@ async function replicateCollection(
|
|
|
529
636
|
if (!last) break;
|
|
530
637
|
cursor = { value: last[key] ?? null, id: last._id ?? null };
|
|
531
638
|
|
|
532
|
-
await saveState(meta, stateId, tenant, runtime, collection, key, cursor, stats, "running", null);
|
|
639
|
+
await saveState(meta, stateId, tenant, runtime, collection, key, cursor, stats, "running", null, resolveMode(config));
|
|
533
640
|
if (docs.length < batchSize) break;
|
|
534
641
|
}
|
|
535
642
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
643
|
+
stats.durationMs = Date.now() - startedAt;
|
|
644
|
+
return { stats, cursor };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/* ------------------------------------------------------------------ */
|
|
648
|
+
/* Change streams (`mode: 'changeStream'`) */
|
|
649
|
+
/* ------------------------------------------------------------------ */
|
|
650
|
+
|
|
651
|
+
/** One watcher per (tenant, destination) — it tails every collection in parallel. */
|
|
652
|
+
type Watcher = { stop: () => Promise<void> };
|
|
653
|
+
|
|
654
|
+
const watchers = new Map<string, Watcher>();
|
|
655
|
+
/** Change-stream support, probed once per (tenant, database). */
|
|
656
|
+
const changeStreamSupport = new Map<string, boolean>();
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Does this source serve change streams? They only exist on a replica set or a
|
|
660
|
+
* sharded cluster — a standalone `mongod` raises `$changeStream is only supported
|
|
661
|
+
* on replica sets` on the first `watch()`.
|
|
662
|
+
*/
|
|
663
|
+
async function supportsChangeStreams(tenantId: string, db: Db): Promise<boolean> {
|
|
664
|
+
const cacheKey = `${tenantId}:${db.databaseName}`;
|
|
665
|
+
const cached = changeStreamSupport.get(cacheKey);
|
|
666
|
+
if (cached !== undefined) return cached;
|
|
667
|
+
|
|
668
|
+
let supported = false;
|
|
669
|
+
try {
|
|
670
|
+
const hello: any = await db.admin().command({ hello: 1 });
|
|
671
|
+
supported = Boolean(hello?.setName) || hello?.msg === "isdbgrid";
|
|
672
|
+
} catch (err: any) {
|
|
673
|
+
logger.file("warn", "replication: change-stream capability probe failed", { tenant: tenantId, error: err?.message });
|
|
674
|
+
}
|
|
675
|
+
changeStreamSupport.set(cacheKey, supported);
|
|
676
|
+
return supported;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* The source's current operation time — the `startAtOperationTime` of a fresh
|
|
681
|
+
* stream, so a first run replays from "now" rather than from the oplog head at
|
|
682
|
+
* whatever instant the cursor happened to be created.
|
|
683
|
+
*/
|
|
684
|
+
async function currentOperationTime(db: Db): Promise<any | null> {
|
|
685
|
+
try {
|
|
686
|
+
const hello: any = await db.admin().command({ hello: 1 });
|
|
687
|
+
return hello?.operationTime ?? null;
|
|
688
|
+
} catch {
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Change-stream pipeline: the collection's base filter, read off the **post-image**.
|
|
695
|
+
*
|
|
696
|
+
* Change streams `$match` the *event*, whose fields are not the document's (`ns` is
|
|
697
|
+
* the namespace of the event, not the `ns` field of a `_vars_` document), so every
|
|
698
|
+
* condition is prefixed with `fullDocument`. A `delete` event has no post-image and
|
|
699
|
+
* `$nin` / `$eq` match a missing field, so a delete of a filtered-out document
|
|
700
|
+
* simply becomes a no-op `deleteOne` on the target.
|
|
701
|
+
*/
|
|
702
|
+
function watchPipeline(collection: Collection): Record<string, any>[] {
|
|
703
|
+
const base = (collection as any)._baseFilter_ as Record<string, any> | undefined;
|
|
704
|
+
if (!base || !Object.keys(base).length) return [];
|
|
705
|
+
const match: Record<string, any> = {};
|
|
706
|
+
for (const [field, condition] of Object.entries(base)) match[`fullDocument.${field}`] = condition;
|
|
707
|
+
return [{ $match: match }];
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** Persist the watcher's position — the resume token, and nothing else. */
|
|
711
|
+
async function saveWatchState(
|
|
712
|
+
meta: any,
|
|
713
|
+
stateId: string,
|
|
714
|
+
tenant: Tenant,
|
|
715
|
+
runtime: Runtime,
|
|
716
|
+
collection: Collection,
|
|
717
|
+
key: string,
|
|
718
|
+
resumeToken: any,
|
|
719
|
+
stats: ReplicationStats,
|
|
720
|
+
status: ReplicationState["status"],
|
|
721
|
+
): Promise<void> {
|
|
722
|
+
const now = new Date();
|
|
723
|
+
const set: Record<string, any> = {
|
|
724
|
+
type: "state",
|
|
725
|
+
tenant: tenant.id,
|
|
726
|
+
destination: runtime.destination.id,
|
|
727
|
+
collection: collection.slug,
|
|
728
|
+
key,
|
|
729
|
+
mode: "changeStream",
|
|
730
|
+
status,
|
|
731
|
+
lastRunAt: now,
|
|
732
|
+
stats: { ...stats },
|
|
733
|
+
pid: process.pid,
|
|
734
|
+
hostname: os.hostname(),
|
|
735
|
+
updatedAt: now,
|
|
736
|
+
};
|
|
737
|
+
if (resumeToken) set.resumeToken = resumeToken;
|
|
738
|
+
|
|
739
|
+
await meta.updateOne(
|
|
740
|
+
{ _id: stateId },
|
|
741
|
+
// `cursor` belongs to the scan and `resumeToken` to the stream: neither
|
|
742
|
+
// overwrites the other, so `replication.now()` stays usable in this mode
|
|
743
|
+
{ $set: set, $setOnInsert: { createdAt: now, cursor: { value: null, id: null } } },
|
|
744
|
+
{ upsert: true },
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** Mirror one change-stream event on the destination. */
|
|
749
|
+
async function applyChangeEvent(
|
|
750
|
+
target: any,
|
|
751
|
+
collection: Collection,
|
|
752
|
+
event: any,
|
|
753
|
+
): Promise<{ written: boolean; deleted: boolean }> {
|
|
754
|
+
switch (event?.operationType) {
|
|
755
|
+
case "insert":
|
|
756
|
+
case "update":
|
|
757
|
+
case "replace": {
|
|
758
|
+
const doc = event.fullDocument;
|
|
759
|
+
// `updateLookup` could not resolve the document (it was deleted in the
|
|
760
|
+
// meantime): the `delete` event that follows removes it from the target
|
|
761
|
+
if (!doc) return { written: false, deleted: false };
|
|
762
|
+
await withRetry(() => target.replaceOne({ _id: doc._id }, doc, { upsert: true }));
|
|
763
|
+
return { written: true, deleted: false };
|
|
764
|
+
}
|
|
765
|
+
case "delete": {
|
|
766
|
+
const id = event.documentKey?._id;
|
|
767
|
+
if (id === undefined) return { written: false, deleted: false };
|
|
768
|
+
// `_id` comes back typed from the oplog: no string round-trip like the
|
|
769
|
+
// tombstone path, so an `ObjectId` is deleted as an `ObjectId`
|
|
770
|
+
await withRetry(() => target.deleteOne({ _id: toId(id) }));
|
|
771
|
+
return { written: false, deleted: true };
|
|
544
772
|
}
|
|
773
|
+
default:
|
|
774
|
+
// `drop` / `rename` / `dropDatabase` / `invalidate`: applying them would
|
|
775
|
+
// destroy data on the destination on a whim — report instead
|
|
776
|
+
logger.file("warn", "replication: unhandled change-stream event", {
|
|
777
|
+
collection: collection.slug,
|
|
778
|
+
operationType: event?.operationType,
|
|
779
|
+
ns: event?.ns,
|
|
780
|
+
});
|
|
781
|
+
return { written: false, deleted: false };
|
|
545
782
|
}
|
|
783
|
+
}
|
|
546
784
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
785
|
+
/**
|
|
786
|
+
* Tail one collection and mirror every event on the destination, until `stopped`
|
|
787
|
+
* is set.
|
|
788
|
+
*
|
|
789
|
+
* Deletes come straight from the oplog, which is the one thing a date cursor can
|
|
790
|
+
* never observe — including the TTL expiries of `_audit_` / `_workflows_`, which no
|
|
791
|
+
* hook sees.
|
|
792
|
+
*
|
|
793
|
+
* A transient driver error reopens the stream from the last **persisted** resume
|
|
794
|
+
* token: at most `STATE_FLUSH_MS` of events are replayed, and every write is an
|
|
795
|
+
* idempotent `replaceOne` / `deleteOne`, so replaying is harmless.
|
|
796
|
+
*/
|
|
797
|
+
async function watchCollection(
|
|
798
|
+
tenant: Tenant,
|
|
799
|
+
runtime: Runtime,
|
|
800
|
+
collection: Collection,
|
|
801
|
+
config: ReplicationConfig,
|
|
802
|
+
key: string,
|
|
803
|
+
meta: any,
|
|
804
|
+
stopped: { value: boolean },
|
|
805
|
+
openStreams: Set<any>,
|
|
806
|
+
): Promise<void> {
|
|
807
|
+
const stateId = stateIdFor(tenant.id, runtime.destination.id, collection.slug);
|
|
808
|
+
const source = tenant.database.db!.collection(collection.slug);
|
|
809
|
+
const target = runtime.db.collection(collection.slug);
|
|
810
|
+
const pipeline = watchPipeline(collection);
|
|
811
|
+
const stats: ReplicationStats = { inserted: 0, updated: 0, deleted: 0, failed: 0, batches: 0, durationMs: 0 };
|
|
812
|
+
|
|
813
|
+
let state = (await meta.findOne({ _id: stateId })) as ReplicationState | null;
|
|
814
|
+
let token: any = state?.resumeToken ?? null;
|
|
815
|
+
let startAt: any = null;
|
|
816
|
+
let lastFlush = 0;
|
|
817
|
+
|
|
818
|
+
// Nothing to resume from (first run, `seed()`, `reset()`, or a state left by a
|
|
819
|
+
// scan-mode pass): backfill first. The stream's starting point is taken
|
|
820
|
+
// **before** it, so a write that lands while the backfill runs is replayed by the
|
|
821
|
+
// stream afterwards instead of being lost — the upsert is idempotent, a lost
|
|
822
|
+
// event is not.
|
|
823
|
+
if (!token) {
|
|
824
|
+
startAt = await currentOperationTime(tenant.database.db!);
|
|
825
|
+
try {
|
|
826
|
+
const { stats: backfill } = await scanCollection(tenant, runtime, collection, config, key, meta);
|
|
827
|
+
Object.assign(stats, { ...backfill, deleted: 0 });
|
|
828
|
+
} catch (err: any) {
|
|
829
|
+
logger.file("error", "replication: backfill failed, watching from now", {
|
|
830
|
+
tenant: tenant.id,
|
|
831
|
+
destination: runtime.destination.id,
|
|
832
|
+
collection: collection.slug,
|
|
833
|
+
error: err?.message,
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
state = (await meta.findOne({ _id: stateId })) as ReplicationState | null;
|
|
837
|
+
token = state?.resumeToken ?? null;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
let backoff = WATCH_BACKOFF_MS;
|
|
841
|
+
while (!stopped.value) {
|
|
842
|
+
let stream: any = null;
|
|
843
|
+
try {
|
|
844
|
+
stream = source.watch(pipeline as any, {
|
|
845
|
+
// We replace the whole document on the target, so an update must carry
|
|
846
|
+
// its post-image rather than just the delta
|
|
847
|
+
fullDocument: "updateLookup",
|
|
848
|
+
// No `maxAwaitTimeMS` here: it must stay below the client's `timeoutMS`
|
|
849
|
+
// (the driver refuses a tailable awaitData cursor otherwise) and the
|
|
850
|
+
// default 1s server-side wait is what we want anyway
|
|
851
|
+
...(token ? { resumeAfter: token } : startAt ? { startAtOperationTime: startAt } : {}),
|
|
852
|
+
} as any);
|
|
853
|
+
openStreams.add(stream);
|
|
854
|
+
backoff = WATCH_BACKOFF_MS;
|
|
855
|
+
|
|
856
|
+
for await (const event of stream) {
|
|
857
|
+
if (stopped.value) break;
|
|
858
|
+
const { written, deleted } = await applyChangeEvent(target, collection, event);
|
|
859
|
+
if (written) stats.inserted += 1;
|
|
860
|
+
if (deleted) stats.deleted += 1;
|
|
861
|
+
if (event?._id) token = event._id;
|
|
862
|
+
|
|
863
|
+
const now = Date.now();
|
|
864
|
+
if (now - lastFlush >= STATE_FLUSH_MS) {
|
|
865
|
+
lastFlush = now;
|
|
866
|
+
await saveWatchState(meta, stateId, tenant, runtime, collection, key, token, stats, "watching");
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
} catch (err: any) {
|
|
870
|
+
if (stopped.value) break;
|
|
871
|
+
const message = err?.message ?? String(err);
|
|
872
|
+
|
|
873
|
+
// The resume token outlived the oplog window (or the replica set was
|
|
874
|
+
// rolled back): the stream must restart from a fresh position, and what
|
|
875
|
+
// happened in between is gone — only a full rescan brings it back
|
|
876
|
+
if (RESUME_TOKEN_ERRORS.has(err?.code)) {
|
|
877
|
+
logger.file("error", "replication: resume token invalid, restarting from now — run replication.reset() for a full rescan", {
|
|
878
|
+
tenant: tenant.id,
|
|
879
|
+
destination: runtime.destination.id,
|
|
880
|
+
collection: collection.slug,
|
|
881
|
+
code: err?.code,
|
|
882
|
+
});
|
|
883
|
+
token = null;
|
|
884
|
+
startAt = await currentOperationTime(tenant.database.db!);
|
|
885
|
+
} else {
|
|
886
|
+
logger.file("error", "replication: change stream failed, reopening", {
|
|
887
|
+
tenant: tenant.id,
|
|
888
|
+
destination: runtime.destination.id,
|
|
889
|
+
collection: collection.slug,
|
|
890
|
+
retryInMs: backoff,
|
|
891
|
+
error: message,
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
await markError(meta, stateId, message);
|
|
896
|
+
await Bun.sleep(backoff);
|
|
897
|
+
backoff = Math.min(backoff * 2, WATCH_MAX_BACKOFF_MS);
|
|
898
|
+
} finally {
|
|
899
|
+
if (stream) {
|
|
900
|
+
openStreams.delete(stream);
|
|
901
|
+
try { await stream.close(); } catch { /* already closed */ }
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// Do not resurrect a state deleted underneath us (`reset()`): only refresh a
|
|
907
|
+
// position that is still there — the next run replays from its own last durable
|
|
908
|
+
// token, and every write is idempotent
|
|
909
|
+
const alive = await meta.findOne({ _id: stateId }, { projection: { _id: 1 } });
|
|
910
|
+
if (alive) {
|
|
911
|
+
await saveWatchState(meta, stateId, tenant, runtime, collection, key, token, stats, "idle").catch(() => {});
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Watch one destination: tail every replicated collection in parallel, each under its
|
|
917
|
+
* own **`replication:<destination>:<collection>`** lock.
|
|
918
|
+
*
|
|
919
|
+
* That lock is the whole arbitration: a one-shot run (`replication.now()`, the boot
|
|
920
|
+
* catch-up) takes the same one for the duration of its pass, so the stream and the
|
|
921
|
+
* scan can never write the same collection at the same time — and two cluster nodes
|
|
922
|
+
* can never tail the same collection onto the same destination.
|
|
923
|
+
*/
|
|
924
|
+
async function startWatcher(tenant: Tenant, runtime: Runtime, config: ReplicationConfig, meta: any): Promise<void> {
|
|
925
|
+
const watcherKey = `${tenant.id}:${runtime.destination.id}`;
|
|
926
|
+
if (watchers.has(watcherKey)) return;
|
|
927
|
+
|
|
928
|
+
const rest = new useRest({ tenant_id: tenant.id });
|
|
929
|
+
const stopped = { value: false };
|
|
930
|
+
const openStreams = new Set<any>();
|
|
931
|
+
const collections = filterCollections(replicatedCollectionsFor(tenant.id, config), runtime.destination);
|
|
932
|
+
const locks: string[] = [];
|
|
933
|
+
const running: Promise<void>[] = [];
|
|
934
|
+
|
|
935
|
+
let heartbeat: any = null;
|
|
936
|
+
const stop = async () => {
|
|
937
|
+
if (stopped.value) return;
|
|
938
|
+
stopped.value = true;
|
|
939
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
940
|
+
for (const stream of openStreams) {
|
|
941
|
+
try { await stream.close(); } catch { /* already closed */ }
|
|
942
|
+
}
|
|
943
|
+
openStreams.clear();
|
|
944
|
+
await Promise.allSettled(running);
|
|
945
|
+
for (const name of locks) await rest.unlock(name).catch(() => {});
|
|
946
|
+
locks.length = 0;
|
|
947
|
+
watchers.delete(watcherKey);
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
const launch = (collection: Collection) => {
|
|
951
|
+
running.push(
|
|
952
|
+
watchCollection(tenant, runtime, collection, config, resolveKey(collection, config), meta, stopped, openStreams)
|
|
953
|
+
.catch((err) => logger.file("error", "replication: watcher stopped", {
|
|
954
|
+
tenant: tenant.id,
|
|
955
|
+
destination: runtime.destination.id,
|
|
956
|
+
collection: collection.slug,
|
|
957
|
+
error: err?.message,
|
|
958
|
+
})),
|
|
959
|
+
);
|
|
960
|
+
};
|
|
961
|
+
|
|
962
|
+
// First pass — no waiting: a boot never delays on a lock, and one collection held
|
|
963
|
+
// elsewhere must not starve the others
|
|
964
|
+
const held: Collection[] = [];
|
|
965
|
+
for (const collection of collections) {
|
|
966
|
+
const name = watchLockName(runtime.destination.id, collection.slug);
|
|
967
|
+
try {
|
|
968
|
+
await rest.lock(name, LOCK_TTL);
|
|
969
|
+
locks.push(name);
|
|
970
|
+
launch(collection);
|
|
971
|
+
} catch {
|
|
972
|
+
held.push(collection);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Second pass — what is left is held by a catch-up run, whose pass ends soon
|
|
977
|
+
if (held.length && !stopped.value) {
|
|
978
|
+
const deadline = Date.now() + WATCH_LOCK_WAIT_MS;
|
|
979
|
+
for (const collection of held) {
|
|
980
|
+
if (stopped.value) break;
|
|
981
|
+
const name = watchLockName(runtime.destination.id, collection.slug);
|
|
982
|
+
let acquired = false;
|
|
983
|
+
while (!stopped.value && !acquired && Date.now() < deadline) {
|
|
984
|
+
try {
|
|
985
|
+
await rest.lock(name, LOCK_TTL);
|
|
986
|
+
locks.push(name);
|
|
987
|
+
acquired = true;
|
|
988
|
+
launch(collection);
|
|
989
|
+
} catch {
|
|
990
|
+
await Bun.sleep(WATCH_LOCK_RETRY_MS);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
if (!acquired) {
|
|
994
|
+
logger.file("warn", "replication: collection lock unavailable, not watched here", {
|
|
995
|
+
tenant: tenant.id,
|
|
996
|
+
destination: runtime.destination.id,
|
|
997
|
+
collection: collection.slug,
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
if (!running.length) {
|
|
1004
|
+
await stop();
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// The locks are leases, not gifts: renew them, and stand down if one is lost —
|
|
1009
|
+
// another node took it over and replaying the same oplog twice would be worse
|
|
1010
|
+
heartbeat = setInterval(async () => {
|
|
1011
|
+
for (const name of [...locks]) {
|
|
1012
|
+
const res = await rest.db.collection("_locks_")
|
|
1013
|
+
.updateOne(
|
|
1014
|
+
{ _id: `${tenant.id}:${name}` as any, pid: process.pid },
|
|
1015
|
+
{ $set: { expiresAt: Date.now() + LOCK_TTL } },
|
|
1016
|
+
)
|
|
1017
|
+
.catch(() => null);
|
|
1018
|
+
if (!res || (res.matchedCount ?? 0) === 0) {
|
|
1019
|
+
logger.file("error", "replication: watcher lost a collection lock, stopping", {
|
|
1020
|
+
tenant: tenant.id,
|
|
1021
|
+
destination: runtime.destination.id,
|
|
1022
|
+
lock: name,
|
|
1023
|
+
});
|
|
1024
|
+
await stop();
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}, Math.floor(LOCK_TTL / 3));
|
|
1029
|
+
(heartbeat as any)?.unref?.();
|
|
1030
|
+
|
|
1031
|
+
watchers.set(watcherKey, { stop });
|
|
1032
|
+
logger.file("replication: watching", {
|
|
1033
|
+
tenant: tenant.id,
|
|
1034
|
+
destination: runtime.destination.id,
|
|
1035
|
+
collections: collections.map((collection) => collection.slug),
|
|
1036
|
+
pid: process.pid,
|
|
1037
|
+
});
|
|
550
1038
|
}
|
|
551
1039
|
|
|
552
1040
|
async function replicateTenant(
|
|
@@ -567,15 +1055,6 @@ async function replicateTenant(
|
|
|
567
1055
|
if (opts?.destinationId && runtime.destination.id !== opts.destinationId) continue;
|
|
568
1056
|
if (runtime.running) continue;
|
|
569
1057
|
|
|
570
|
-
const lockName = `replication:${runtime.destination.id}`;
|
|
571
|
-
let locked = false;
|
|
572
|
-
try {
|
|
573
|
-
await rest.lock(lockName, LOCK_TTL);
|
|
574
|
-
locked = true;
|
|
575
|
-
} catch {
|
|
576
|
-
continue; // another node holds the lock
|
|
577
|
-
}
|
|
578
|
-
|
|
579
1058
|
runtime.running = true;
|
|
580
1059
|
try {
|
|
581
1060
|
for (const collection of filterCollections(collections, runtime.destination)) {
|
|
@@ -584,6 +1063,26 @@ async function replicateTenant(
|
|
|
584
1063
|
const startedAt = Date.now();
|
|
585
1064
|
const totals: ReplicationStats = { inserted: 0, updated: 0, deleted: 0, failed: 0, batches: 0, durationMs: 0 };
|
|
586
1065
|
|
|
1066
|
+
// One writer per (destination, collection): a change stream holds this
|
|
1067
|
+
// lock for as long as it watches, so a run neither writes the same
|
|
1068
|
+
// collection concurrently nor queues behind a long-lived stream
|
|
1069
|
+
const lockName = watchLockName(runtime.destination.id, collection.slug);
|
|
1070
|
+
let locked = false;
|
|
1071
|
+
try {
|
|
1072
|
+
await rest.lock(lockName, LOCK_TTL);
|
|
1073
|
+
locked = true;
|
|
1074
|
+
} catch {
|
|
1075
|
+
// The stream (or another node) owns this collection: it is already up
|
|
1076
|
+
// to date, and letting two writers share it is how a stale scan
|
|
1077
|
+
// overwrites a fresh event
|
|
1078
|
+
logger.file("replication: collection owned by another writer, skipped", {
|
|
1079
|
+
tenant: tenantId,
|
|
1080
|
+
destination: runtime.destination.id,
|
|
1081
|
+
collection: collection.slug,
|
|
1082
|
+
});
|
|
1083
|
+
continue;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
587
1086
|
try {
|
|
588
1087
|
const stats = await replicateCollection(tenant, runtime, collection, config, key, meta);
|
|
589
1088
|
Object.assign(totals, stats);
|
|
@@ -597,17 +1096,56 @@ async function replicateTenant(
|
|
|
597
1096
|
});
|
|
598
1097
|
totals.durationMs = Date.now() - startedAt;
|
|
599
1098
|
results.push({ tenant: tenantId, destination: runtime.destination.id, collection: collection.slug, ...totals, error: message });
|
|
1099
|
+
} finally {
|
|
1100
|
+
if (locked) await rest.unlock(lockName).catch(() => {});
|
|
600
1101
|
}
|
|
601
1102
|
}
|
|
602
1103
|
} finally {
|
|
603
1104
|
runtime.running = false;
|
|
604
|
-
if (locked) await rest.unlock(lockName).catch(() => {});
|
|
605
1105
|
}
|
|
606
1106
|
}
|
|
607
1107
|
|
|
608
1108
|
return results;
|
|
609
1109
|
}
|
|
610
1110
|
|
|
1111
|
+
/**
|
|
1112
|
+
* Stand a tenant's watchers down, returning the keys it stopped — so
|
|
1113
|
+
* `startWatchers()` can re-arm exactly those, once the position has been rewritten.
|
|
1114
|
+
*
|
|
1115
|
+
* `reset()` and `seed()` need that order: a running stream holds its position in
|
|
1116
|
+
* memory and would overwrite the new one on its next flush.
|
|
1117
|
+
*/
|
|
1118
|
+
async function stopWatchers(tenantId: string, opts?: { destinationId?: string }): Promise<string[]> {
|
|
1119
|
+
const stopped: string[] = [];
|
|
1120
|
+
for (const [key, watcher] of [...watchers]) {
|
|
1121
|
+
if (!key.startsWith(`${tenantId}:`)) continue;
|
|
1122
|
+
if (opts?.destinationId && key !== `${tenantId}:${opts.destinationId}`) continue;
|
|
1123
|
+
await watcher.stop().catch(() => {});
|
|
1124
|
+
stopped.push(key);
|
|
1125
|
+
}
|
|
1126
|
+
return stopped;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/** Re-arm the watchers `stopWatchers()` stopped, after the position was rewritten. */
|
|
1130
|
+
async function startWatchers(tenantId: string, keys: string[]): Promise<void> {
|
|
1131
|
+
if (!keys.length) return;
|
|
1132
|
+
|
|
1133
|
+
const tenant = getTenant(tenantId);
|
|
1134
|
+
const config = tenant ? resolveConfig(tenant) : null;
|
|
1135
|
+
const mainDb = tenant?.database?.db;
|
|
1136
|
+
if (!tenant || !config || !mainDb) return;
|
|
1137
|
+
|
|
1138
|
+
const meta = mainDb.collection(META_COLLECTION);
|
|
1139
|
+
for (const key of keys) {
|
|
1140
|
+
const destinationId = key.slice(`${tenantId}:`.length);
|
|
1141
|
+
const runtime = runtimes.get(key);
|
|
1142
|
+
if (!runtime) continue;
|
|
1143
|
+
await startWatcher(tenant, runtime, config, meta).catch((err) =>
|
|
1144
|
+
logger.file("error", "replication: watcher restart failed", { tenant: tenantId, destination: destinationId, error: err?.message }),
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
611
1149
|
async function replicationNow(tenantId?: string): Promise<ReplicationRunResult[]> {
|
|
612
1150
|
if (tenantId) return replicateTenant(tenantId);
|
|
613
1151
|
const all: ReplicationRunResult[] = [];
|
|
@@ -646,6 +1184,11 @@ async function resetReplication(
|
|
|
646
1184
|
|
|
647
1185
|
const meta = db.collection(META_COLLECTION);
|
|
648
1186
|
|
|
1187
|
+
// A running stream holds its position in memory: standing it down **before**
|
|
1188
|
+
// clearing is what makes the reset stick (its next flush would restore it), and
|
|
1189
|
+
// re-arming it afterwards is what makes it cold-start again
|
|
1190
|
+
const stoppedWatchers = await stopWatchers(tenantId, { destinationId: opts?.destination }).catch(() => []);
|
|
1191
|
+
|
|
649
1192
|
const stateFilter: Record<string, any> = { type: "state", tenant: tenantId };
|
|
650
1193
|
if (opts?.destination) stateFilter.destination = opts.destination;
|
|
651
1194
|
if (opts?.collection) stateFilter.collection = opts.collection;
|
|
@@ -668,6 +1211,10 @@ async function resetReplication(
|
|
|
668
1211
|
tombstones,
|
|
669
1212
|
});
|
|
670
1213
|
|
|
1214
|
+
// A running stream keeps its position in memory and would silently undo the reset
|
|
1215
|
+
// on its next flush: restart it, so it cold-starts from the cleared position
|
|
1216
|
+
await startWatchers(tenantId, stoppedWatchers).catch(() => {});
|
|
1217
|
+
|
|
671
1218
|
return { tenant: tenantId, state, tombstones };
|
|
672
1219
|
}
|
|
673
1220
|
|
|
@@ -697,6 +1244,8 @@ async function seedReplication(
|
|
|
697
1244
|
);
|
|
698
1245
|
|
|
699
1246
|
let seeded = 0;
|
|
1247
|
+
// Same order as `reset()`: a live stream must not overwrite the seeded position
|
|
1248
|
+
const stoppedWatchers = await stopWatchers(tenantId, { destinationId: opts?.destination }).catch(() => []);
|
|
700
1249
|
for (const destination of destinations) {
|
|
701
1250
|
for (const collection of filterCollections(collections, destination)) {
|
|
702
1251
|
const key = resolveKey(collection, config);
|
|
@@ -730,6 +1279,10 @@ async function seedReplication(
|
|
|
730
1279
|
lastError: null,
|
|
731
1280
|
updatedAt: now,
|
|
732
1281
|
},
|
|
1282
|
+
// The seeded position **replaces** the stream's: a leftover resume
|
|
1283
|
+
// token would make the watcher resume where it was instead of
|
|
1284
|
+
// cold-starting from what was just seeded
|
|
1285
|
+
$unset: { resumeToken: "" },
|
|
733
1286
|
$setOnInsert: { createdAt: now },
|
|
734
1287
|
},
|
|
735
1288
|
{ upsert: true },
|
|
@@ -746,6 +1299,10 @@ async function seedReplication(
|
|
|
746
1299
|
seeded,
|
|
747
1300
|
});
|
|
748
1301
|
|
|
1302
|
+
// A running stream does not re-read the state it holds in memory: restart it so
|
|
1303
|
+
// the seeded position becomes its new cold-start point
|
|
1304
|
+
await startWatchers(tenantId, stoppedWatchers).catch(() => {});
|
|
1305
|
+
|
|
749
1306
|
return { tenant: tenantId, seeded };
|
|
750
1307
|
}
|
|
751
1308
|
|
|
@@ -792,6 +1349,16 @@ async function startReplication(): Promise<void> {
|
|
|
792
1349
|
const mainDb = tenant.database?.db;
|
|
793
1350
|
if (!config || !mainDb) continue;
|
|
794
1351
|
|
|
1352
|
+
// Change detection: change streams unless the tenant asked for a scan, or the
|
|
1353
|
+
// source is a standalone mongod — they are a replica-set/sharded feature
|
|
1354
|
+
const requested = config.mode ?? DEFAULT_MODE;
|
|
1355
|
+
const streaming = requested === "changeStream" && await supportsChangeStreams(tenant.id, mainDb);
|
|
1356
|
+
if (requested === "changeStream" && !streaming) {
|
|
1357
|
+
logger.file("warn", "replication: change streams unavailable on this source, falling back to scan mode", {
|
|
1358
|
+
tenant: tenant.id,
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
|
|
795
1362
|
let connected = false;
|
|
796
1363
|
for (const destination of config.destinations ?? []) {
|
|
797
1364
|
if (destination.enabled === false || !destination.id || !destination.uri) continue;
|
|
@@ -815,16 +1382,31 @@ async function startReplication(): Promise<void> {
|
|
|
815
1382
|
|
|
816
1383
|
if (!connected) continue;
|
|
817
1384
|
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
1385
|
+
// In `changeStream` mode the stream carries deletes natively — including the TTL
|
|
1386
|
+
// expiries no hook can see — so a tombstone would only pile up in
|
|
1387
|
+
// `_replication_` without anyone flushing it (one-shot runs leave the streamed
|
|
1388
|
+
// collections alone). The hook is therefore installed for the scan mode only.
|
|
1389
|
+
if (!streaming) {
|
|
821
1390
|
for (const collection of [...(cfg.collections ?? []), ...((cfg.fileCollections ?? []) as any[])]) {
|
|
822
1391
|
if (collection._tenant_ === tenant.id && collection.replication?.enabled) {
|
|
823
1392
|
instrumentCollection(collection);
|
|
824
1393
|
}
|
|
825
1394
|
}
|
|
1395
|
+
}
|
|
826
1396
|
|
|
827
|
-
|
|
1397
|
+
if (streaming) {
|
|
1398
|
+
// The stream is continuous: a schedule would only add load on top of it.
|
|
1399
|
+
// `runOnBoot` (below) is what catches up a destination that was down.
|
|
1400
|
+
if (config.schedule) {
|
|
1401
|
+
logger.file("warn", "replication: schedule ignored in changeStream mode", { tenant: tenant.id });
|
|
1402
|
+
}
|
|
1403
|
+
const meta = mainDb.collection(META_COLLECTION);
|
|
1404
|
+
for (const runtime of runtimesFor(tenant.id)) {
|
|
1405
|
+
await startWatcher(tenant, runtime, config, meta);
|
|
1406
|
+
}
|
|
1407
|
+
} else {
|
|
1408
|
+
scheduleTenant(tenant, config);
|
|
1409
|
+
}
|
|
828
1410
|
|
|
829
1411
|
if (config.runOnBoot !== false) {
|
|
830
1412
|
replicateTenant(tenant.id).catch((err) =>
|
|
@@ -835,6 +1417,11 @@ async function startReplication(): Promise<void> {
|
|
|
835
1417
|
}
|
|
836
1418
|
|
|
837
1419
|
async function stopReplication(): Promise<void> {
|
|
1420
|
+
// Watchers first: their streams must stop writing before the clients go away
|
|
1421
|
+
for (const watcher of [...watchers.values()]) {
|
|
1422
|
+
try { await watcher.stop(); } catch { /* already stopped */ }
|
|
1423
|
+
}
|
|
1424
|
+
watchers.clear();
|
|
838
1425
|
for (const job of jobs) {
|
|
839
1426
|
try { job.stop(); } catch { /* ignore */ }
|
|
840
1427
|
}
|
|
@@ -844,6 +1431,7 @@ async function stopReplication(): Promise<void> {
|
|
|
844
1431
|
}
|
|
845
1432
|
runtimes.clear();
|
|
846
1433
|
ensured.clear();
|
|
1434
|
+
changeStreamSupport.clear();
|
|
847
1435
|
started = false;
|
|
848
1436
|
}
|
|
849
1437
|
|