@anteros/core 0.0.41 → 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/audit.ts +9 -59
- package/database/collection.ts +3 -1
- package/database/deleteLog.ts +67 -3
- package/database/file.ts +4 -3
- package/database/mongodbadapter.ts +103 -42
- package/database/replication.ts +724 -58
- package/database/ttl.ts +68 -0
- package/database/workflow.ts +466 -240
- package/index.ts +6 -0
- package/lib/agent.ts +1567 -0
- package/lib/agentMemory.ts +689 -0
- package/lib/agents.ts +180 -0
- package/lib/ai.ts +0 -0
- package/lib/api.ts +246 -0
- package/lib/attachment.ts +351 -0
- package/lib/auditResult.ts +13 -0
- package/lib/define.ts +43 -1
- package/lib/endpoints.ts +80 -0
- package/lib/files.ts +2 -1
- package/lib/jsonSchema.ts +183 -0
- package/lib/lifecycle.ts +7 -1
- package/lib/load.ts +17 -0
- package/lib/mcp.ts +3 -2
- package/lib/mcpClient.ts +128 -0
- package/lib/messages.ts +299 -0
- package/lib/providers.ts +970 -0
- package/lib/routes.ts +14 -1
- package/lib/scriptWorker.ts +4 -1
- package/lib/scripts.ts +6 -1
- package/lib/skills.ts +481 -0
- package/lib/sockets.ts +3 -1
- package/lib/workflow.ts +209 -37
- package/lib/zip.ts +109 -0
- package/package.json +1 -1
- package/server/access.ts +96 -0
- package/server/agents.ts +472 -0
- package/server/api.ts +80 -65
- package/server/boot.ts +11 -0
- package/server/hono.ts +8 -3
- package/server/mcp.ts +17 -63
- package/server/routes.ts +19 -24
- package/tests/agent-mcp.test.ts +116 -0
- package/tests/agent-memory.test.ts +617 -0
- 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 +404 -0
- package/tests/agent.test.ts +1241 -0
- package/tests/agents-api.test.ts +593 -0
- package/tests/api-private-fields.test.ts +196 -0
- package/tests/api.test.ts +51 -0
- package/tests/attachment.test.ts +241 -0
- package/tests/audit-operations.test.ts +1 -1
- package/tests/audit-redact.test.ts +29 -3
- package/tests/audit-run-action.test.ts +1 -1
- package/tests/endpoints.test.ts +80 -0
- package/tests/file-collection-replication.test.ts +2 -0
- package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
- package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
- package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
- package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
- package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
- package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
- package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
- package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
- package/tests/fixtures/attachment/data.csv +2 -0
- package/tests/fixtures/fake-provider.ts +128 -0
- package/tests/fixtures/repl-only/app.ts +3 -0
- package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
- package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
- package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
- package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
- 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/fixtures/vars-tenant/vars/private.var.ts +4 -1
- package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
- package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
- package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
- package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
- package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
- package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
- package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
- package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
- package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
- package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
- package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
- package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
- package/tests/hono.test.ts +6 -3
- package/tests/locks.test.ts +120 -0
- package/tests/mcp.test.ts +11 -11
- package/tests/messages.test.ts +258 -0
- package/tests/replication-change-stream.test.ts +240 -0
- package/tests/replication-control.test.ts +4 -2
- package/tests/replication-initial-sync.test.ts +2 -0
- package/tests/replication-meta.test.ts +246 -0
- package/tests/replication.test.ts +2 -0
- package/tests/upload.test.ts +44 -12
- package/tests/vars-api.test.ts +1 -1
- package/tests/vars-replication.test.ts +5 -2
- package/tests/workflow-engine.test.ts +340 -0
- package/tests/workflow-retention.test.ts +103 -0
- package/tests/workflow.test.ts +186 -0
- package/types/agent.d.ts +1067 -0
- package/types/config.d.ts +16 -0
- package/types/file.d.ts +7 -7
- package/types/hook.d.ts +6 -0
- package/types/lifecycle.d.ts +6 -0
- package/types/mcp.d.ts +11 -1
- package/types/replication.d.ts +70 -4
- package/types/route.d.ts +7 -1
- package/types/scripts.d.ts +6 -0
- package/types/service.d.ts +7 -1
- package/types/tenant.d.ts +13 -0
- package/types/vars.d.ts +2 -2
- package/types/websocket.d.ts +6 -0
- package/types/workflow.d.ts +50 -3
package/database/replication.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { getTenant } from "./tenant";
|
|
|
4
4
|
import { useRest } from "./rest";
|
|
5
5
|
import { logger } from "../utils/logger";
|
|
6
6
|
import * as func from "../utils/func";
|
|
7
|
+
import * as os from "node:os";
|
|
7
8
|
import type { Collection } from "../types/collection";
|
|
8
9
|
import type { Tenant } from "../types/tenant";
|
|
9
10
|
import type { HookContext } from "../types/hook";
|
|
@@ -11,6 +12,8 @@ import type {
|
|
|
11
12
|
ReplicationConfig,
|
|
12
13
|
ReplicationDestination,
|
|
13
14
|
ReplicationInitialSync,
|
|
15
|
+
ReplicationMetaName,
|
|
16
|
+
ReplicationMode,
|
|
14
17
|
ReplicationResetOptions,
|
|
15
18
|
ReplicationResetResult,
|
|
16
19
|
ReplicationRunResult,
|
|
@@ -20,6 +23,10 @@ import type {
|
|
|
20
23
|
ReplicationState,
|
|
21
24
|
ReplicationStats,
|
|
22
25
|
} from "../types/replication";
|
|
26
|
+
import { AUDIT_TTL_INDEX, resolveAuditRetention } from "./audit";
|
|
27
|
+
import { WORKFLOW_RUNS_TTL_INDEX, resolveWorkflowsRetention } from "../lib/workflow";
|
|
28
|
+
import { agentMemoryCollections } from "../lib/agents";
|
|
29
|
+
import { syncTtlRetention } from "./ttl";
|
|
23
30
|
|
|
24
31
|
const META_COLLECTION = "_replication_";
|
|
25
32
|
const DEFAULT_KEY = "updatedAt";
|
|
@@ -28,6 +35,97 @@ const DEFAULT_BATCH = 1000;
|
|
|
28
35
|
const BULK_CHUNK = 1000;
|
|
29
36
|
/** Distributed-lock TTL used to guard a run across cluster workers. */
|
|
30
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;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Framework collections replication covers **by default**, with the date key each
|
|
62
|
+
* one is read with (the engine's cursor). `'vars'` is handled apart, per namespace,
|
|
63
|
+
* and `'memory'` covers the agent memory collections — whose names the tenant
|
|
64
|
+
* chooses, so they come from the agent registry instead (see below).
|
|
65
|
+
*/
|
|
66
|
+
const META_COLLECTIONS: Record<Exclude<ReplicationMetaName, 'vars' | 'memory'>, { slug: string; key: string }> = {
|
|
67
|
+
audit: { slug: "_audit_", key: "ts" },
|
|
68
|
+
workflows: { slug: "_workflows_", key: "updatedAt" },
|
|
69
|
+
locks: { slug: "_locks_", key: "expiresAt" },
|
|
70
|
+
replication: { slug: "_replication_", key: "updatedAt" },
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Everything replicated for a tenant: the collections that opted in, plus the
|
|
75
|
+
* framework's meta collections — which are replicated **by default** and left out
|
|
76
|
+
* with `replication.exclude: ['audit', 'locks', …]`.
|
|
77
|
+
*/
|
|
78
|
+
function replicatedCollectionsFor(tenantId: string, config: ReplicationConfig): Collection[] {
|
|
79
|
+
const excluded = new Set<string>(config.exclude ?? []);
|
|
80
|
+
|
|
81
|
+
const collections: Collection[] = [
|
|
82
|
+
...(cfg.collections ?? []).filter((c) => c._tenant_ === tenantId && c.replication?.enabled),
|
|
83
|
+
...(cfg.fileCollections ?? [])
|
|
84
|
+
.filter((c) => c._tenant_ === tenantId && c.replication?.enabled)
|
|
85
|
+
.map((c) => ({ slug: c.slug, _tenant_: tenantId, replication: c.replication }) as any),
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
for (const [name, meta] of Object.entries(META_COLLECTIONS)) {
|
|
89
|
+
if (excluded.has(name)) continue;
|
|
90
|
+
// Each meta collection knows its own date key (`_audit_` is indexed on `ts`),
|
|
91
|
+
// so a tenant-level `key` never breaks it
|
|
92
|
+
collections.push({
|
|
93
|
+
slug: meta.slug,
|
|
94
|
+
_tenant_: tenantId,
|
|
95
|
+
replication: { enabled: true, key: meta.key },
|
|
96
|
+
} as any);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!excluded.has('vars')) {
|
|
100
|
+
// Every namespace, minus the ones explicitly opting out — a per-document
|
|
101
|
+
// filter: `replication.exclude: ['vars']` is the blunt way out.
|
|
102
|
+
const optedOut = (cfg.vars ?? [])
|
|
103
|
+
.filter((v) => v._tenant_ === tenantId && v.replication?.enabled === false)
|
|
104
|
+
.map((v) => v.namespace);
|
|
105
|
+
collections.push({
|
|
106
|
+
slug: "_vars_",
|
|
107
|
+
_tenant_: tenantId,
|
|
108
|
+
replication: { enabled: true },
|
|
109
|
+
_baseFilter_: { ns: { $nin: optedOut } },
|
|
110
|
+
} as any);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Agent memory collections — the tenant names them, the agent loader publishes
|
|
114
|
+
// them (`cfg.agentMemories`). `exclude: ['memory']` covers all of them.
|
|
115
|
+
if (!excluded.has('memory')) {
|
|
116
|
+
for (const slug of agentMemoryCollections(tenantId)) {
|
|
117
|
+
// A memory collection may also be declared as a collection: one entry only
|
|
118
|
+
if (collections.some((collection) => collection.slug === slug)) continue;
|
|
119
|
+
collections.push({
|
|
120
|
+
slug,
|
|
121
|
+
_tenant_: tenantId,
|
|
122
|
+
replication: { enabled: true, key: "updatedAt" },
|
|
123
|
+
} as any);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return collections;
|
|
128
|
+
}
|
|
31
129
|
|
|
32
130
|
type Runtime = {
|
|
33
131
|
tenantId: string;
|
|
@@ -69,6 +167,15 @@ function validateReplication(config: ReplicationConfig): void {
|
|
|
69
167
|
}
|
|
70
168
|
}
|
|
71
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
|
+
|
|
72
179
|
function resolveKey(collection: Collection, config: ReplicationConfig): string {
|
|
73
180
|
return collection.replication?.key ?? config.key ?? DEFAULT_KEY;
|
|
74
181
|
}
|
|
@@ -77,6 +184,15 @@ function stateIdFor(tenantId: string, destinationId: string, collection: string)
|
|
|
77
184
|
return `rep:${tenantId}:${destinationId}:${collection}`;
|
|
78
185
|
}
|
|
79
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
|
+
|
|
80
196
|
function deleteMarkerId(collection: Collection, docId: string): string {
|
|
81
197
|
return `rep.delete:${collection._tenant_}:${collection.slug}:${docId}`;
|
|
82
198
|
}
|
|
@@ -205,6 +321,7 @@ async function saveState(
|
|
|
205
321
|
stats: ReplicationStats,
|
|
206
322
|
status: ReplicationState["status"],
|
|
207
323
|
error: string | null,
|
|
324
|
+
mode: ReplicationMode = DEFAULT_MODE,
|
|
208
325
|
): Promise<void> {
|
|
209
326
|
const now = new Date();
|
|
210
327
|
await meta.updateOne(
|
|
@@ -216,11 +333,17 @@ async function saveState(
|
|
|
216
333
|
destination: runtime.destination.id,
|
|
217
334
|
collection: collection.slug,
|
|
218
335
|
key,
|
|
336
|
+
mode,
|
|
219
337
|
cursor,
|
|
220
338
|
lastRunAt: now,
|
|
221
339
|
status,
|
|
222
340
|
error,
|
|
223
341
|
stats: { ...stats },
|
|
342
|
+
// Which process owns this run — `reusePort`/forked deployments can
|
|
343
|
+
// see at a glance which worker is replicating (the lock holds the
|
|
344
|
+
// same information)
|
|
345
|
+
pid: process.pid,
|
|
346
|
+
hostname: os.hostname(),
|
|
224
347
|
updatedAt: now,
|
|
225
348
|
},
|
|
226
349
|
$setOnInsert: { createdAt: now },
|
|
@@ -311,7 +434,15 @@ async function flushDeletes(
|
|
|
311
434
|
|
|
312
435
|
const ops = markers.map((m: any) => ({
|
|
313
436
|
deleteOne: {
|
|
314
|
-
filter: {
|
|
437
|
+
filter: {
|
|
438
|
+
_id: toId(m.docId),
|
|
439
|
+
// The key is a Date on most collections, but a number on `_locks_`:
|
|
440
|
+
// accept either, so a numeric key never silently skips the delete
|
|
441
|
+
$or: [
|
|
442
|
+
{ [key]: { $lte: m.deletedAt } },
|
|
443
|
+
{ [key]: { $lte: m.deletedAt?.getTime?.() ?? m.deletedAt } },
|
|
444
|
+
],
|
|
445
|
+
},
|
|
315
446
|
},
|
|
316
447
|
}));
|
|
317
448
|
const res = await withRetry(() => runtime.db.collection(collection.slug).bulkWrite(ops as any, { ordered: false }));
|
|
@@ -374,6 +505,71 @@ async function replicateCollection(
|
|
|
374
505
|
key: string,
|
|
375
506
|
meta: any,
|
|
376
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 } }> {
|
|
377
573
|
const stateId = stateIdFor(tenant.id, runtime.destination.id, collection.slug);
|
|
378
574
|
const state = (await meta.findOne({ _id: stateId })) as ReplicationState | null;
|
|
379
575
|
let cursor = state?.cursor ?? { value: null, id: null };
|
|
@@ -388,6 +584,23 @@ async function replicateCollection(
|
|
|
388
584
|
await ensureIndex(tenant.database.db!, collection.slug, key, "source");
|
|
389
585
|
await ensureIndex(runtime.db, collection.slug, key, `destination:${runtime.destination.id}`);
|
|
390
586
|
|
|
587
|
+
// Meta collections have no hooks, so their deletions never reach the
|
|
588
|
+
// destination (an audit TTL expiry is invisible to a date cursor): mirror the
|
|
589
|
+
// source's retention on the destination, otherwise the copy grows forever.
|
|
590
|
+
if (collection.slug === '_audit_' || collection.slug === '_workflows_') {
|
|
591
|
+
const isAudit = collection.slug === '_audit_';
|
|
592
|
+
const retention = isAudit ? resolveAuditRetention(tenant) : resolveWorkflowsRetention(tenant);
|
|
593
|
+
if (retention !== undefined) {
|
|
594
|
+
await syncTtlRetention(runtime.db, {
|
|
595
|
+
collection: collection.slug,
|
|
596
|
+
index: isAudit ? AUDIT_TTL_INDEX : WORKFLOW_RUNS_TTL_INDEX,
|
|
597
|
+
field: isAudit ? 'ts' : 'completedAt',
|
|
598
|
+
retention,
|
|
599
|
+
label: `${collection.slug} (destination)`,
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
391
604
|
// Resolve the initial cursor on the first run — `initialSync` lets a
|
|
392
605
|
// pre-loaded destination skip the full backfill (`full` = default).
|
|
393
606
|
if (!state) {
|
|
@@ -423,24 +636,405 @@ async function replicateCollection(
|
|
|
423
636
|
if (!last) break;
|
|
424
637
|
cursor = { value: last[key] ?? null, id: last._id ?? null };
|
|
425
638
|
|
|
426
|
-
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));
|
|
427
640
|
if (docs.length < batchSize) break;
|
|
428
641
|
}
|
|
429
642
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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 };
|
|
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 };
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
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
|
+
});
|
|
438
835
|
}
|
|
836
|
+
state = (await meta.findOne({ _id: stateId })) as ReplicationState | null;
|
|
837
|
+
token = state?.resumeToken ?? null;
|
|
439
838
|
}
|
|
440
839
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
+
});
|
|
444
1038
|
}
|
|
445
1039
|
|
|
446
1040
|
async function replicateTenant(
|
|
@@ -452,29 +1046,7 @@ async function replicateTenant(
|
|
|
452
1046
|
const mainDb = tenant?.database?.db;
|
|
453
1047
|
if (!tenant || !config || !mainDb) return [];
|
|
454
1048
|
|
|
455
|
-
const collections = (
|
|
456
|
-
|
|
457
|
-
// File collections opt in the same way (`define.FileCollection({ replication })`).
|
|
458
|
-
collections.push(
|
|
459
|
-
...(cfg.fileCollections ?? [])
|
|
460
|
-
.filter((c) => c._tenant_ === tenantId && c.replication?.enabled)
|
|
461
|
-
.map((c) => ({ slug: c.slug, _tenant_: tenantId, replication: c.replication }) as any),
|
|
462
|
-
);
|
|
463
|
-
|
|
464
|
-
// `define.Vars({ replication: { enabled: true } })` → replicate the opted-in
|
|
465
|
-
// namespaces of `_vars_`. A per-document filter keeps the namespaces that
|
|
466
|
-
// did NOT opt in (e.g. secrets) out of the destination.
|
|
467
|
-
const varsNamespaces = (cfg.vars ?? [])
|
|
468
|
-
.filter((v) => v._tenant_ === tenantId && v.replication?.enabled)
|
|
469
|
-
.map((v) => v.namespace);
|
|
470
|
-
if (varsNamespaces.length) {
|
|
471
|
-
collections.push({
|
|
472
|
-
slug: "_vars_",
|
|
473
|
-
_tenant_: tenantId,
|
|
474
|
-
replication: { enabled: true },
|
|
475
|
-
_baseFilter_: { ns: { $in: varsNamespaces } },
|
|
476
|
-
} as any);
|
|
477
|
-
}
|
|
1049
|
+
const collections = replicatedCollectionsFor(tenantId, config);
|
|
478
1050
|
const results: ReplicationRunResult[] = [];
|
|
479
1051
|
const meta = mainDb.collection(META_COLLECTION);
|
|
480
1052
|
const rest = new useRest({ tenant_id: tenantId });
|
|
@@ -483,15 +1055,6 @@ async function replicateTenant(
|
|
|
483
1055
|
if (opts?.destinationId && runtime.destination.id !== opts.destinationId) continue;
|
|
484
1056
|
if (runtime.running) continue;
|
|
485
1057
|
|
|
486
|
-
const lockName = `replication:${runtime.destination.id}`;
|
|
487
|
-
let locked = false;
|
|
488
|
-
try {
|
|
489
|
-
await rest.lock(lockName, LOCK_TTL);
|
|
490
|
-
locked = true;
|
|
491
|
-
} catch {
|
|
492
|
-
continue; // another node holds the lock
|
|
493
|
-
}
|
|
494
|
-
|
|
495
1058
|
runtime.running = true;
|
|
496
1059
|
try {
|
|
497
1060
|
for (const collection of filterCollections(collections, runtime.destination)) {
|
|
@@ -500,6 +1063,26 @@ async function replicateTenant(
|
|
|
500
1063
|
const startedAt = Date.now();
|
|
501
1064
|
const totals: ReplicationStats = { inserted: 0, updated: 0, deleted: 0, failed: 0, batches: 0, durationMs: 0 };
|
|
502
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
|
+
|
|
503
1086
|
try {
|
|
504
1087
|
const stats = await replicateCollection(tenant, runtime, collection, config, key, meta);
|
|
505
1088
|
Object.assign(totals, stats);
|
|
@@ -513,17 +1096,56 @@ async function replicateTenant(
|
|
|
513
1096
|
});
|
|
514
1097
|
totals.durationMs = Date.now() - startedAt;
|
|
515
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(() => {});
|
|
516
1101
|
}
|
|
517
1102
|
}
|
|
518
1103
|
} finally {
|
|
519
1104
|
runtime.running = false;
|
|
520
|
-
if (locked) await rest.unlock(lockName).catch(() => {});
|
|
521
1105
|
}
|
|
522
1106
|
}
|
|
523
1107
|
|
|
524
1108
|
return results;
|
|
525
1109
|
}
|
|
526
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
|
+
|
|
527
1149
|
async function replicationNow(tenantId?: string): Promise<ReplicationRunResult[]> {
|
|
528
1150
|
if (tenantId) return replicateTenant(tenantId);
|
|
529
1151
|
const all: ReplicationRunResult[] = [];
|
|
@@ -562,6 +1184,11 @@ async function resetReplication(
|
|
|
562
1184
|
|
|
563
1185
|
const meta = db.collection(META_COLLECTION);
|
|
564
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
|
+
|
|
565
1192
|
const stateFilter: Record<string, any> = { type: "state", tenant: tenantId };
|
|
566
1193
|
if (opts?.destination) stateFilter.destination = opts.destination;
|
|
567
1194
|
if (opts?.collection) stateFilter.collection = opts.collection;
|
|
@@ -584,6 +1211,10 @@ async function resetReplication(
|
|
|
584
1211
|
tombstones,
|
|
585
1212
|
});
|
|
586
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
|
+
|
|
587
1218
|
return { tenant: tenantId, state, tombstones };
|
|
588
1219
|
}
|
|
589
1220
|
|
|
@@ -606,19 +1237,15 @@ async function seedReplication(
|
|
|
606
1237
|
if (!tenant || !config || !mainDb) return { tenant: tenantId, seeded: 0 };
|
|
607
1238
|
|
|
608
1239
|
const meta: any = mainDb.collection(META_COLLECTION);
|
|
609
|
-
const collections = (
|
|
610
|
-
(c) =>
|
|
611
|
-
);
|
|
612
|
-
collections.push(
|
|
613
|
-
...(cfg.fileCollections ?? [])
|
|
614
|
-
.filter((c) => c._tenant_ === tenantId && c.replication?.enabled && (!opts?.collection || c.slug === opts.collection))
|
|
615
|
-
.map((c) => ({ slug: c.slug, _tenant_: tenantId, replication: c.replication }) as any),
|
|
616
|
-
);
|
|
1240
|
+
const collections = replicatedCollectionsFor(tenantId, config)
|
|
1241
|
+
.filter((c) => !opts?.collection || c.slug === opts.collection);
|
|
617
1242
|
const destinations = (config.destinations ?? []).filter(
|
|
618
1243
|
(d) => d.enabled !== false && d.id && d.uri && (!opts?.destination || d.id === opts.destination),
|
|
619
1244
|
);
|
|
620
1245
|
|
|
621
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(() => []);
|
|
622
1249
|
for (const destination of destinations) {
|
|
623
1250
|
for (const collection of filterCollections(collections, destination)) {
|
|
624
1251
|
const key = resolveKey(collection, config);
|
|
@@ -652,6 +1279,10 @@ async function seedReplication(
|
|
|
652
1279
|
lastError: null,
|
|
653
1280
|
updatedAt: now,
|
|
654
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: "" },
|
|
655
1286
|
$setOnInsert: { createdAt: now },
|
|
656
1287
|
},
|
|
657
1288
|
{ upsert: true },
|
|
@@ -668,6 +1299,10 @@ async function seedReplication(
|
|
|
668
1299
|
seeded,
|
|
669
1300
|
});
|
|
670
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
|
+
|
|
671
1306
|
return { tenant: tenantId, seeded };
|
|
672
1307
|
}
|
|
673
1308
|
|
|
@@ -714,6 +1349,16 @@ async function startReplication(): Promise<void> {
|
|
|
714
1349
|
const mainDb = tenant.database?.db;
|
|
715
1350
|
if (!config || !mainDb) continue;
|
|
716
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
|
+
|
|
717
1362
|
let connected = false;
|
|
718
1363
|
for (const destination of config.destinations ?? []) {
|
|
719
1364
|
if (destination.enabled === false || !destination.id || !destination.uri) continue;
|
|
@@ -737,16 +1382,31 @@ async function startReplication(): Promise<void> {
|
|
|
737
1382
|
|
|
738
1383
|
if (!connected) continue;
|
|
739
1384
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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) {
|
|
743
1390
|
for (const collection of [...(cfg.collections ?? []), ...((cfg.fileCollections ?? []) as any[])]) {
|
|
744
1391
|
if (collection._tenant_ === tenant.id && collection.replication?.enabled) {
|
|
745
1392
|
instrumentCollection(collection);
|
|
746
1393
|
}
|
|
747
1394
|
}
|
|
1395
|
+
}
|
|
748
1396
|
|
|
749
|
-
|
|
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
|
+
}
|
|
750
1410
|
|
|
751
1411
|
if (config.runOnBoot !== false) {
|
|
752
1412
|
replicateTenant(tenant.id).catch((err) =>
|
|
@@ -757,6 +1417,11 @@ async function startReplication(): Promise<void> {
|
|
|
757
1417
|
}
|
|
758
1418
|
|
|
759
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();
|
|
760
1425
|
for (const job of jobs) {
|
|
761
1426
|
try { job.stop(); } catch { /* ignore */ }
|
|
762
1427
|
}
|
|
@@ -766,6 +1431,7 @@ async function stopReplication(): Promise<void> {
|
|
|
766
1431
|
}
|
|
767
1432
|
runtimes.clear();
|
|
768
1433
|
ensured.clear();
|
|
1434
|
+
changeStreamSupport.clear();
|
|
769
1435
|
started = false;
|
|
770
1436
|
}
|
|
771
1437
|
|