@nxgt/mongo-meilisearch 0.1.8 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -14
- package/dist/context.d.ts +5 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/documents.d.ts.map +1 -1
- package/dist/errors.d.ts +11 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/follow.d.ts +3 -6
- package/dist/follow.d.ts.map +1 -1
- package/dist/index.js +203 -39
- package/dist/index.js.map +11 -9
- package/dist/lease.d.ts +60 -0
- package/dist/lease.d.ts.map +1 -0
- package/dist/reindex.d.ts +13 -0
- package/dist/reindex.d.ts.map +1 -1
- package/dist/start.d.ts +9 -0
- package/dist/start.d.ts.map +1 -0
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/README.md +2 -2
- package/docs/guide/boundaries.md +24 -16
- package/docs/guide/following-changes.md +120 -6
- package/docs/guide/reindex.md +62 -12
- package/docs/guide/sync-lifecycle.md +11 -6
- package/docs/roadmap.md +14 -4
- package/docs/troubleshooting.md +160 -17
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -120,7 +120,8 @@ it: deleted, turned away by the transform, or never from this collection.
|
|
|
120
120
|
It first records where the collection's changes are, and saves that point
|
|
121
121
|
when it is done: a change made while it reads is followed again from there,
|
|
122
122
|
so none falls between the reindex and the stream. A reindex that fails
|
|
123
|
-
records nothing
|
|
123
|
+
records nothing, and one that finds its lease taken over stops before it
|
|
124
|
+
removes anything more (see *One process per sync name*).
|
|
124
125
|
|
|
125
126
|
## Following changes
|
|
126
127
|
|
|
@@ -155,6 +156,59 @@ await running.close(); // flushes, then stops; or `await using running = …`
|
|
|
155
156
|
resume from inside a collection that no longer exists. The next `start`
|
|
156
157
|
reindexes what the recreated collection holds.
|
|
157
158
|
|
|
159
|
+
### One process per sync name
|
|
160
|
+
|
|
161
|
+
`start` takes a **lease** on the sync's name before anything else, the first
|
|
162
|
+
reindex included, and a running sync renews it every third of `leaseMs`
|
|
163
|
+
(30 s). A second process that starts the same name is refused with
|
|
164
|
+
`RUNNING`, naming who holds it and until when. Run it as a standby that
|
|
165
|
+
tries again — on `RUNNING`, and on `LEASE_LOST`, which a `start()` whose
|
|
166
|
+
first reindex lost the name to another process rejects with:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
import { type RunningSearchSync, SearchSyncError } from '@nxgt/mongo-meilisearch';
|
|
170
|
+
|
|
171
|
+
// The name is held elsewhere, or was taken over while this start reindexed.
|
|
172
|
+
const heldElsewhere = (error: unknown) =>
|
|
173
|
+
error instanceof SearchSyncError &&
|
|
174
|
+
(error.code === 'RUNNING' || error.code === 'LEASE_LOST');
|
|
175
|
+
|
|
176
|
+
async function follow(): Promise<RunningSearchSync> {
|
|
177
|
+
for (;;) {
|
|
178
|
+
try {
|
|
179
|
+
return await articleSearch.start();
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (!heldElsewhere(error)) throw error;
|
|
182
|
+
await new Promise((resolve) => setTimeout(resolve, 10_000));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
A restart inside a crashed follower's `leaseMs` gets `RUNNING` too: run this
|
|
189
|
+
loop rather than exit.
|
|
190
|
+
|
|
191
|
+
- **`close()` lets go of the name**, and resolves once it has: a `start` or a
|
|
192
|
+
`reindex` right after finds it free. A `start` that fails lets go too.
|
|
193
|
+
- **A process that dies keeps the name** until its lease lapses, at most
|
|
194
|
+
`leaseMs` later; the next `start` then takes it over.
|
|
195
|
+
- **`reindex()` on its own takes the lease** for as long as it runs, so it
|
|
196
|
+
cannot run beside a follower in another process. `start()` renews it
|
|
197
|
+
during its first reindex too.
|
|
198
|
+
- **A sync that loses its lease stops.** When a renewal finds the name held
|
|
199
|
+
by someone else — this process stalled for longer than `leaseMs`, and
|
|
200
|
+
another took over — `closed` rejects with `LEASE_LOST`, and nothing more is
|
|
201
|
+
sent.
|
|
202
|
+
- **A reindex that loses its lease stops too.** `reindex()`, and `start()`
|
|
203
|
+
while it reindexes, check the lease after each page, and ask the server
|
|
204
|
+
before removing documents and again before recording the resume point;
|
|
205
|
+
`start()` asks once more before it opens the follower. They reject with
|
|
206
|
+
`LEASE_LOST` having recorded nothing, and removed nothing unless the lease
|
|
207
|
+
went while the removal itself ran; the pages already sent stay in the index.
|
|
208
|
+
|
|
209
|
+
The lease is a document in `stateCollection`, `_id: { lease: <name> }`, its
|
|
210
|
+
times the server's own: no new collection and no new privilege.
|
|
211
|
+
|
|
158
212
|
### When the history is gone
|
|
159
213
|
|
|
160
214
|
MongoDB keeps a bounded history of changes, the oplog. A sync stopped for
|
|
@@ -192,7 +246,8 @@ createSearchSync({
|
|
|
192
246
|
|
|
193
247
|
The transform must give each document its index id as primary key: a
|
|
194
248
|
document under another id could never be taken out again, and throws
|
|
195
|
-
`ID_MISMATCH`.
|
|
249
|
+
`ID_MISMATCH`. It must also give back a document, or `null` to keep the
|
|
250
|
+
document out of the index; anything else is `NOT_A_DOCUMENT`.
|
|
196
251
|
|
|
197
252
|
## Errors
|
|
198
253
|
|
|
@@ -202,7 +257,9 @@ This package throws `SearchSyncError`; what caused it is its `cause`.
|
|
|
202
257
|
| --- | --- |
|
|
203
258
|
| `HISTORY_LOST` | `start` with `onHistoryLost: 'fail'`, and the recorded point is older than the server's history. `cause` is `@nxgt/mongo`'s `DataError`, `serverCode` 286 or 280 |
|
|
204
259
|
| `ID_MISMATCH` | the transform gave a document whose primary key is not its index id |
|
|
205
|
-
| `
|
|
260
|
+
| `NOT_A_DOCUMENT` | the transform gave back something that is neither a document nor `null` — a string, a number, an array. The message says its shape, never its value |
|
|
261
|
+
| `RUNNING` | the name is taken. On this sync object: `reindex()` or a second `start()` while it is already following. Through the lease: `start()` or `reindex()` while another process holds the name — or another `createSearchSync` with the same name in this process, whose holder then starts with this process's own `host:pid`. The message names the holder and when its lease ends |
|
|
262
|
+
| `LEASE_LOST` | the name's lease is no longer this sync's: it was not renewed within `leaseMs` and another process took it over, or it was removed. `closed` rejects with it, and nothing more is sent; `reindex()` and `start()` reject with it while reindexing, having recorded nothing, and removed nothing unless the lease went while the removal itself ran |
|
|
206
263
|
| `FAILED` | anything else: the transform threw, MongoDB or Meilisearch refused. The message says what the sync was doing |
|
|
207
264
|
|
|
208
265
|
A running sync that meets one stops: `closed` rejects with it, and `flush`
|
|
@@ -225,11 +282,11 @@ out of range, an empty `name`, or a `transform` that is not a function.
|
|
|
225
282
|
|
|
226
283
|
## Not included
|
|
227
284
|
|
|
228
|
-
- **
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
285
|
+
- **Sharing one name's work between processes.** One process follows a
|
|
286
|
+
name at a time; the others wait for its lease. To spread the load, give
|
|
287
|
+
each process its own collection and name.
|
|
288
|
+
- **Fencing.** The lease keeps a second process out, but does not stop a
|
|
289
|
+
stalled one mid-write; see *Traps*.
|
|
233
290
|
- **Partial updates.** A change sends the whole document the transform
|
|
234
291
|
gives, never a patch.
|
|
235
292
|
- **Keeping the index's settings.** That is `@nxgt/meilisearch`'s `sync`.
|
|
@@ -259,6 +316,7 @@ function createSearchSync<C extends AnyCollectionDefinition, I extends AnyIndexD
|
|
|
259
316
|
| `batchSize: number` | `500` | changes sent at once |
|
|
260
317
|
| `flushIntervalMs: number` | `1000` | how long a change waits for others; `0` sends at the next tick |
|
|
261
318
|
| `positionIntervalMs: number` | `60000` | how often a sync with nothing to send records where the stream is |
|
|
319
|
+
| `leaseMs: number` | `30000` | how long the lease on the name lasts unrenewed; a running sync renews it every third of that |
|
|
262
320
|
| `pageSize: number` | `100` | documents a reindex reads per page; above the collection's `maxPageSize`, lowered to it |
|
|
263
321
|
| `onHistoryLost: 'reindex' \| 'fail'` | `'reindex'` | |
|
|
264
322
|
|
|
@@ -281,7 +339,8 @@ function createSearchSync<C extends AnyCollectionDefinition, I extends AnyIndexD
|
|
|
281
339
|
| `close(): Promise<void>` | flushes, then stops |
|
|
282
340
|
|
|
283
341
|
`class SearchSyncError extends Error`: `code: SearchSyncErrorCode`
|
|
284
|
-
(`'HISTORY_LOST' | 'ID_MISMATCH' | 'RUNNING' | 'FAILED'`),
|
|
342
|
+
(`'HISTORY_LOST' | 'ID_MISMATCH' | 'NOT_A_DOCUMENT' | 'RUNNING' | 'LEASE_LOST' | 'FAILED'`),
|
|
343
|
+
`sync: string`,
|
|
285
344
|
`cause`. Its constructor takes `(message, options: SearchSyncErrorOptions)`,
|
|
286
345
|
that is `{ code, sync, cause? }`; both types are exported.
|
|
287
346
|
|
|
@@ -320,10 +379,16 @@ Each is a `@ts-expect-error` case in this package's type tests.
|
|
|
320
379
|
- **Without post-images, a change carries the document as it is now**, not
|
|
321
380
|
as the change left it (`@nxgt/mongo`'s change streams). For an index, where
|
|
322
381
|
only the latest state counts, that is what you want.
|
|
323
|
-
- **
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
382
|
+
- **A lease is not fencing.** A stalled process learns it lost the name at
|
|
383
|
+
its next renewal (up to `leaseMs / 3` after it wakes), and a flush already
|
|
384
|
+
in flight still finishes. It may send an older version of a document after
|
|
385
|
+
the new holder sent a newer one; the index then holds the stale one until
|
|
386
|
+
that document's next change or the next reindex. A reindex removes and
|
|
387
|
+
records nothing once it finds its lease lost, but a page it already sent may
|
|
388
|
+
still land. Keep `leaseMs` well above the longest pause a process may take
|
|
389
|
+
(garbage collection, a blocked event loop).
|
|
390
|
+
- **Two syncs over the same collection and index share a name**, and so a
|
|
391
|
+
lease: give them different `name`s when both are meant to run.
|
|
327
392
|
- **A dropped collection stops the sync** (`'invalidated'`) and leaves the
|
|
328
393
|
index as it was. What the collection had is removed by the reindex the
|
|
329
394
|
next `start` runs.
|
|
@@ -347,7 +412,7 @@ Each is a `@ts-expect-error` case in this package's type tests.
|
|
|
347
412
|
- [Following changes](docs/guide/following-changes.md) — batches, the resume
|
|
348
413
|
point, and how a sync stops.
|
|
349
414
|
- [What it leaves out](docs/guide/boundaries.md) — the settings, the joins,
|
|
350
|
-
and the one follower per sync name.
|
|
415
|
+
and the lease that keeps one follower per sync name.
|
|
351
416
|
- [Troubleshooting](docs/troubleshooting.md) — the errors, by their message.
|
|
352
417
|
- [Roadmap](docs/roadmap.md) — what is next, and what is not planned.
|
|
353
418
|
|
package/dist/context.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AnyIndexDefinition, TypedIndex } from '@nxgt/meilisearch';
|
|
2
2
|
import type { AnyCollectionDefinition, TypedCollection } from '@nxgt/mongo';
|
|
3
3
|
import type { Collection } from 'mongodb';
|
|
4
|
+
import type { LeaseDocument } from './lease';
|
|
4
5
|
import type { SearchSyncOptions, SearchSyncState } from './types';
|
|
5
6
|
/** Loosely typed: this layer works on any documents; the public types are what callers see. */
|
|
6
7
|
export type Doc = Record<string, unknown>;
|
|
@@ -14,6 +15,10 @@ export interface SyncContext {
|
|
|
14
15
|
readonly index: TypedIndex<AnyIndexDefinition>;
|
|
15
16
|
readonly primaryKey: string;
|
|
16
17
|
readonly state: Collection<SearchSyncState>;
|
|
18
|
+
/** The same collection, for the lease documents it also holds. */
|
|
19
|
+
readonly leases: Collection<LeaseDocument>;
|
|
20
|
+
/** How long a lease lasts unrenewed, in ms. */
|
|
21
|
+
readonly leaseMs: number;
|
|
17
22
|
readonly batchSize: number;
|
|
18
23
|
readonly flushIntervalMs: number;
|
|
19
24
|
readonly positionIntervalMs: number;
|
package/dist/context.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACxE,OAAO,KAAK,EAAE,uBAAuB,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC5E,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAC1C,OAAO,KAAK,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAElE,+FAA+F;AAC/F,MAAM,MAAM,GAAG,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAE1C;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,UAAU,EAAE,eAAe,CAAC,uBAAuB,CAAC,CAAC;IAC9D,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC,kBAAkB,CAAC,CAAC;IAC/C,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC,eAAe,CAAC,CAAC;IAC5C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,aAAa,EAAE,SAAS,GAAG,MAAM,CAAC;IAC3C,QAAQ,CAAC,SAAS,EAAE,CAAC,QAAQ,EAAE,GAAG,KAAK,OAAO,CAAC;IAC/C,QAAQ,CAAC,SAAS,EAAE,CAAC,EAAE,EAAE,OAAO,KAAK,OAAO,CAAC;IAC7C;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC;CACxC;AAcD,wBAAgB,aAAa,CAC5B,OAAO,EAAE,iBAAiB,CAAC,uBAAuB,EAAE,kBAAkB,CAAC,GACrE,WAAW,
|
|
1
|
+
{"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACxE,OAAO,KAAK,EAAE,uBAAuB,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC5E,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAC1C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAC7C,OAAO,KAAK,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAElE,+FAA+F;AAC/F,MAAM,MAAM,GAAG,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAE1C;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,UAAU,EAAE,eAAe,CAAC,uBAAuB,CAAC,CAAC;IAC9D,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC,kBAAkB,CAAC,CAAC;IAC/C,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC,eAAe,CAAC,CAAC;IAC5C,kEAAkE;IAClE,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC,aAAa,CAAC,CAAC;IAC3C,+CAA+C;IAC/C,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,aAAa,EAAE,SAAS,GAAG,MAAM,CAAC;IAC3C,QAAQ,CAAC,SAAS,EAAE,CAAC,QAAQ,EAAE,GAAG,KAAK,OAAO,CAAC;IAC/C,QAAQ,CAAC,SAAS,EAAE,CAAC,EAAE,EAAE,OAAO,KAAK,OAAO,CAAC;IAC7C;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC;CACxC;AAcD,wBAAgB,aAAa,CAC5B,OAAO,EAAE,iBAAiB,CAAC,uBAAuB,EAAE,kBAAkB,CAAC,GACrE,WAAW,CAqCb"}
|
package/dist/documents.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"documents.d.ts","sourceRoot":"","sources":["../src/documents.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAGlD,4DAA4D;AAC5D,MAAM,MAAM,KAAK,GACd;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,GAAG,CAAA;CAAE,GAC9C;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,OAAO,CAAA;CAAE,CAAC;AAEhD,sEAAsE;AACtE,eAAO,MAAM,KAAK,GAAI,IAAI,OAAO,WAAiC,CAAC;
|
|
1
|
+
{"version":3,"file":"documents.d.ts","sourceRoot":"","sources":["../src/documents.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAGlD,4DAA4D;AAC5D,MAAM,MAAM,KAAK,GACd;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,GAAG,CAAA;CAAE,GAC9C;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,OAAO,CAAA;CAAE,CAAC;AAEhD,sEAAsE;AACtE,eAAO,MAAM,KAAK,GAAI,IAAI,OAAO,WAAiC,CAAC;AAYnE;;;GAGG;AACH,wBAAsB,OAAO,CAC5B,GAAG,EAAE,WAAW,EAChB,OAAO,EAAE,OAAO,EAChB,QAAQ,EAAE,GAAG,GAAG,SAAS,GACvB,OAAO,CAAC,KAAK,CAAC,CA6BhB"}
|
package/dist/errors.d.ts
CHANGED
|
@@ -4,8 +4,18 @@ export type SearchSyncErrorCode =
|
|
|
4
4
|
'HISTORY_LOST'
|
|
5
5
|
/** The transform gave a document whose primary key is not its index id. */
|
|
6
6
|
| 'ID_MISMATCH'
|
|
7
|
-
/**
|
|
7
|
+
/** The transform gave something that is neither a document nor `null`. */
|
|
8
|
+
| 'NOT_A_DOCUMENT'
|
|
9
|
+
/**
|
|
10
|
+
* This sync's name is taken: it is following changes in this process, or
|
|
11
|
+
* another process holds its lease.
|
|
12
|
+
*/
|
|
8
13
|
| 'RUNNING'
|
|
14
|
+
/**
|
|
15
|
+
* A running sync's lease was not renewed in time and is no longer its
|
|
16
|
+
* own: it stopped rather than follow beside whoever took the name over.
|
|
17
|
+
*/
|
|
18
|
+
| 'LEASE_LOST'
|
|
9
19
|
/** Anything else: the cause says what. */
|
|
10
20
|
| 'FAILED';
|
|
11
21
|
export interface SearchSyncErrorOptions {
|
package/dist/errors.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,qDAAqD;AACrD,MAAM,MAAM,mBAAmB;AAC9B,kEAAkE;AAChE,cAAc;AAChB,2EAA2E;GACzE,aAAa;AACf,
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,qDAAqD;AACrD,MAAM,MAAM,mBAAmB;AAC9B,kEAAkE;AAChE,cAAc;AAChB,2EAA2E;GACzE,aAAa;AACf,0EAA0E;GACxE,gBAAgB;AAClB;;;GAGG;GACD,SAAS;AACX;;;GAGG;GACD,YAAY;AACd,0CAA0C;GACxC,QAAQ,CAAC;AAEZ,MAAM,WAAW,sBAAsB;IACtC,IAAI,EAAE,mBAAmB,CAAC;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,mEAAmE;AACnE,qBAAa,eAAgB,SAAQ,KAAK;IAChC,IAAI,SAAqB;IAClC,QAAQ,CAAC,IAAI,EAAE,mBAAmB,CAAC;IACnC,uBAAuB;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;gBAEV,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,sBAAsB;CAQ5D;AAED,4EAA4E;AAC5E,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,mBAWjE"}
|
package/dist/follow.d.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
|
+
import type { ResumeToken } from '@nxgt/mongo';
|
|
1
2
|
import type { SyncContext } from './context';
|
|
3
|
+
import { type HeldLease } from './lease';
|
|
2
4
|
import type { RunningSearchSync } from './types';
|
|
3
|
-
|
|
4
|
-
* Follows the collection from its recorded point — after a reindex when
|
|
5
|
-
* nothing is recorded, or when the point is older than the server's history
|
|
6
|
-
* and `onHistoryLost` allows it.
|
|
7
|
-
*/
|
|
8
|
-
export declare function start(ctx: SyncContext): Promise<RunningSearchSync>;
|
|
5
|
+
export declare function open(ctx: SyncContext, token: ResumeToken, lease: HeldLease): Promise<RunningSearchSync>;
|
|
9
6
|
//# sourceMappingURL=follow.d.ts.map
|
package/dist/follow.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"follow.d.ts","sourceRoot":"","sources":["../src/follow.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"follow.d.ts","sourceRoot":"","sources":["../src/follow.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAKX,WAAW,EACX,MAAM,aAAa,CAAC;AAErB,OAAO,KAAK,EAAO,WAAW,EAAE,MAAM,WAAW,CAAC;AAGlD,OAAO,EAAE,KAAK,SAAS,EAAiC,MAAM,SAAS,CAAC;AAExE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AA+HjD,wBAAsB,IAAI,CACzB,GAAG,EAAE,WAAW,EAChB,KAAK,EAAE,WAAW,EAClB,KAAK,EAAE,SAAS,GACd,OAAO,CAAC,iBAAiB,CAAC,CAkE5B"}
|
package/dist/index.js
CHANGED
|
@@ -20,12 +20,15 @@ function createContext(options) {
|
|
|
20
20
|
if (!Number.isInteger(flushIntervalMs) || flushIntervalMs < 0) {
|
|
21
21
|
throw new TypeError(`createSearchSync: flushIntervalMs must be a whole number of milliseconds, not ${String(options.flushIntervalMs)}`);
|
|
22
22
|
}
|
|
23
|
+
const stateCollection = options.stateCollection ?? DEFAULT_STATE_COLLECTION;
|
|
23
24
|
return {
|
|
24
25
|
name,
|
|
25
26
|
collection,
|
|
26
27
|
index,
|
|
27
28
|
primaryKey: index.definition.primaryKey,
|
|
28
|
-
state: collection.db.collection(
|
|
29
|
+
state: collection.db.collection(stateCollection),
|
|
30
|
+
leases: collection.db.collection(stateCollection),
|
|
31
|
+
leaseMs: positive("leaseMs", options.leaseMs, 30000),
|
|
29
32
|
batchSize: positive("batchSize", options.batchSize, 500),
|
|
30
33
|
positionIntervalMs: positive("positionIntervalMs", options.positionIntervalMs, 60000),
|
|
31
34
|
flushIntervalMs,
|
|
@@ -37,6 +40,9 @@ function createContext(options) {
|
|
|
37
40
|
};
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
// src/reindex.ts
|
|
44
|
+
import { MeilisearchApiError } from "meilisearch";
|
|
45
|
+
|
|
40
46
|
// src/batch.ts
|
|
41
47
|
var WAIT = { wait: { timeout: 120000 } };
|
|
42
48
|
function chunks(items, size) {
|
|
@@ -92,6 +98,13 @@ function failed(sync, doing, error) {
|
|
|
92
98
|
// src/documents.ts
|
|
93
99
|
var keyOf = (id) => `${typeof id}:${String(id)}`;
|
|
94
100
|
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
101
|
+
function describe(value) {
|
|
102
|
+
if (Array.isArray(value))
|
|
103
|
+
return "an array";
|
|
104
|
+
if (value === undefined)
|
|
105
|
+
return "undefined";
|
|
106
|
+
return `a ${typeof value}`;
|
|
107
|
+
}
|
|
95
108
|
async function entryOf(ctx, mongoId, document) {
|
|
96
109
|
const id = ctx.toIndexId(mongoId);
|
|
97
110
|
const key = keyOf(id);
|
|
@@ -101,7 +114,7 @@ async function entryOf(ctx, mongoId, document) {
|
|
|
101
114
|
if (indexed === null)
|
|
102
115
|
return { kind: "delete", key, id };
|
|
103
116
|
if (!isRecord(indexed)) {
|
|
104
|
-
throw new
|
|
117
|
+
throw new SearchSyncError(`Search sync "${ctx.name}": transform gave ${describe(indexed)} for ` + `the document ${String(mongoId)}. It must give a document to ` + "index, or null to keep it out.", { code: "NOT_A_DOCUMENT", sync: ctx.name });
|
|
105
118
|
}
|
|
106
119
|
const given = indexed[ctx.primaryKey];
|
|
107
120
|
if (keyOf(given) !== key) {
|
|
@@ -110,8 +123,108 @@ async function entryOf(ctx, mongoId, document) {
|
|
|
110
123
|
return { kind: "upsert", key, document: indexed };
|
|
111
124
|
}
|
|
112
125
|
|
|
113
|
-
// src/
|
|
114
|
-
import {
|
|
126
|
+
// src/lease.ts
|
|
127
|
+
import { hostname } from "node:os";
|
|
128
|
+
import { ObjectId } from "mongodb";
|
|
129
|
+
var idOf = (ctx) => ({ lease: ctx.name });
|
|
130
|
+
async function take(ctx, holder) {
|
|
131
|
+
const free = {
|
|
132
|
+
$or: [
|
|
133
|
+
{ $eq: [{ $type: "$expiresAt" }, "missing"] },
|
|
134
|
+
{ $lte: ["$expiresAt", "$$NOW"] }
|
|
135
|
+
]
|
|
136
|
+
};
|
|
137
|
+
const pick = (mine, theirs) => ({
|
|
138
|
+
$cond: [free, mine, theirs]
|
|
139
|
+
});
|
|
140
|
+
try {
|
|
141
|
+
const after = await ctx.leases.findOneAndUpdate({ _id: idOf(ctx) }, [
|
|
142
|
+
{
|
|
143
|
+
$set: {
|
|
144
|
+
holder: pick({ $literal: holder }, "$holder"),
|
|
145
|
+
acquiredAt: pick("$$NOW", "$acquiredAt"),
|
|
146
|
+
expiresAt: pick({ $add: ["$$NOW", ctx.leaseMs] }, "$expiresAt")
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
], { upsert: true, returnDocument: "after" });
|
|
150
|
+
return after?.holder === holder;
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (error.code === 11000)
|
|
153
|
+
return false;
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async function acquire(ctx, doing) {
|
|
158
|
+
const holder = `${hostname()}:${process.pid}:${new ObjectId().toHexString()}`;
|
|
159
|
+
let current = null;
|
|
160
|
+
try {
|
|
161
|
+
for (let attempt = 0;attempt < 2; attempt += 1) {
|
|
162
|
+
if (await take(ctx, holder))
|
|
163
|
+
return { holder, lost: false };
|
|
164
|
+
current = await ctx.leases.findOne({ _id: idOf(ctx) });
|
|
165
|
+
if (current)
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
} catch (error) {
|
|
169
|
+
throw failed(ctx.name, "taking its lease", error);
|
|
170
|
+
}
|
|
171
|
+
const until = current?.expiresAt instanceof Date ? current.expiresAt.toISOString() : "it lets go";
|
|
172
|
+
throw new SearchSyncError(`Search sync "${ctx.name}" is held by ${current?.holder ?? "another process"} ` + `until ${until}: ` + `wait for it to close, or for its lease to lapse, before you ${doing}.`, { code: "RUNNING", sync: ctx.name });
|
|
173
|
+
}
|
|
174
|
+
async function renew(ctx, lease) {
|
|
175
|
+
try {
|
|
176
|
+
const result = await ctx.leases.updateOne({ _id: idOf(ctx), holder: lease.holder }, [{ $set: { expiresAt: { $add: ["$$NOW", ctx.leaseMs] } } }]);
|
|
177
|
+
if (result.matchedCount === 0)
|
|
178
|
+
lease.lost = true;
|
|
179
|
+
} catch {}
|
|
180
|
+
}
|
|
181
|
+
async function confirmLease(ctx, lease) {
|
|
182
|
+
let result;
|
|
183
|
+
try {
|
|
184
|
+
result = await ctx.leases.updateOne({ _id: idOf(ctx), holder: lease.holder }, [{ $set: { expiresAt: { $add: ["$$NOW", ctx.leaseMs] } } }]);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
throw failed(ctx.name, "checking its lease", error);
|
|
187
|
+
}
|
|
188
|
+
if (result.matchedCount === 0)
|
|
189
|
+
lease.lost = true;
|
|
190
|
+
if (lease.lost)
|
|
191
|
+
throw leaseLost(ctx);
|
|
192
|
+
}
|
|
193
|
+
async function release(ctx, lease) {
|
|
194
|
+
await ctx.leases.deleteOne({ _id: idOf(ctx), holder: lease.holder }).catch(() => {
|
|
195
|
+
return;
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
function leaseLost(ctx) {
|
|
199
|
+
return new SearchSyncError(`Search sync "${ctx.name}" lost its lease: another process holds the ` + "name now, or the lease was removed (it lapses when not renewed " + `within ${ctx.leaseMs} ms). It stopped rather than run beside it.`, { code: "LEASE_LOST", sync: ctx.name });
|
|
200
|
+
}
|
|
201
|
+
function keepLease(ctx, lease, onLost) {
|
|
202
|
+
let stopped = false;
|
|
203
|
+
const beat = setInterval(() => {
|
|
204
|
+
renew(ctx, lease).then(() => {
|
|
205
|
+
if (lease.lost && !stopped) {
|
|
206
|
+
clearInterval(beat);
|
|
207
|
+
onLost();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}, Math.max(1, Math.floor(ctx.leaseMs / 3)));
|
|
211
|
+
return () => {
|
|
212
|
+
stopped = true;
|
|
213
|
+
clearInterval(beat);
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
async function withLease(ctx, doing, fn) {
|
|
217
|
+
const lease = await acquire(ctx, doing);
|
|
218
|
+
const stop = keepLease(ctx, lease, () => {
|
|
219
|
+
return;
|
|
220
|
+
});
|
|
221
|
+
try {
|
|
222
|
+
return await fn(lease);
|
|
223
|
+
} finally {
|
|
224
|
+
stop();
|
|
225
|
+
await release(ctx, lease);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
115
228
|
|
|
116
229
|
// src/running.ts
|
|
117
230
|
function checkIdle(ctx, doing) {
|
|
@@ -160,7 +273,7 @@ async function currentToken(ctx) {
|
|
|
160
273
|
await stream.close();
|
|
161
274
|
}
|
|
162
275
|
}
|
|
163
|
-
async function sendAll(ctx, wanted) {
|
|
276
|
+
async function sendAll(ctx, wanted, lease) {
|
|
164
277
|
let indexed = 0;
|
|
165
278
|
let skipped = 0;
|
|
166
279
|
let after;
|
|
@@ -181,6 +294,8 @@ async function sendAll(ctx, wanted) {
|
|
|
181
294
|
}
|
|
182
295
|
await sendDocuments(ctx, documents);
|
|
183
296
|
indexed += documents.length;
|
|
297
|
+
if (lease.lost)
|
|
298
|
+
throw leaseLost(ctx);
|
|
184
299
|
after = page.nextCursor;
|
|
185
300
|
} while (after);
|
|
186
301
|
return { indexed, skipped };
|
|
@@ -210,11 +325,16 @@ async function removeUnwanted(ctx, wanted) {
|
|
|
210
325
|
}
|
|
211
326
|
async function reindex(ctx) {
|
|
212
327
|
checkIdle(ctx, "reindex");
|
|
328
|
+
return withLease(ctx, "reindex", (lease) => reindexHeld(ctx, lease));
|
|
329
|
+
}
|
|
330
|
+
async function reindexHeld(ctx, lease) {
|
|
213
331
|
try {
|
|
214
332
|
const token = await currentToken(ctx);
|
|
215
333
|
const wanted = new Set;
|
|
216
|
-
const { indexed, skipped } = await sendAll(ctx, wanted);
|
|
334
|
+
const { indexed, skipped } = await sendAll(ctx, wanted, lease);
|
|
335
|
+
await confirmLease(ctx, lease);
|
|
217
336
|
const removed = await removeUnwanted(ctx, wanted);
|
|
337
|
+
await confirmLease(ctx, lease);
|
|
218
338
|
await saveState(ctx, token, true);
|
|
219
339
|
return { indexed, skipped, removed };
|
|
220
340
|
} catch (error) {
|
|
@@ -223,10 +343,9 @@ async function reindex(ctx) {
|
|
|
223
343
|
}
|
|
224
344
|
|
|
225
345
|
// src/follow.ts
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
346
|
+
var noop = () => {
|
|
347
|
+
return;
|
|
348
|
+
};
|
|
230
349
|
function flush(ctx, f, subscription) {
|
|
231
350
|
f.chain = f.chain.then(async () => {
|
|
232
351
|
if (f.failure)
|
|
@@ -254,18 +373,50 @@ function flush(ctx, f, subscription) {
|
|
|
254
373
|
});
|
|
255
374
|
return f.chain;
|
|
256
375
|
}
|
|
257
|
-
|
|
258
|
-
|
|
376
|
+
function following(token) {
|
|
377
|
+
return {
|
|
259
378
|
pending: new Map,
|
|
260
379
|
last: undefined,
|
|
261
380
|
saved: token,
|
|
262
381
|
inFlight: 0,
|
|
263
382
|
timer: undefined,
|
|
264
383
|
beat: undefined,
|
|
384
|
+
stopLease: noop,
|
|
385
|
+
heard: false,
|
|
265
386
|
chain: Promise.resolve(),
|
|
266
387
|
done: false,
|
|
267
388
|
failure: undefined
|
|
268
389
|
};
|
|
390
|
+
}
|
|
391
|
+
function stopFollowing(ctx, f) {
|
|
392
|
+
f.done = true;
|
|
393
|
+
clearTimeout(f.timer);
|
|
394
|
+
clearInterval(f.beat);
|
|
395
|
+
f.stopLease();
|
|
396
|
+
ctx.follower.running = false;
|
|
397
|
+
}
|
|
398
|
+
function settle(ctx, f, subscription, lease) {
|
|
399
|
+
const letGo = () => f.heard ? release(ctx, lease) : Promise.resolve();
|
|
400
|
+
return subscription.closed.then(async (reason) => {
|
|
401
|
+
stopFollowing(ctx, f);
|
|
402
|
+
try {
|
|
403
|
+
await f.chain.catch(noop);
|
|
404
|
+
if (f.failure)
|
|
405
|
+
throw f.failure;
|
|
406
|
+
if (reason === "invalidated")
|
|
407
|
+
await clearState(ctx);
|
|
408
|
+
return reason;
|
|
409
|
+
} finally {
|
|
410
|
+
await letGo();
|
|
411
|
+
}
|
|
412
|
+
}, async (error) => {
|
|
413
|
+
stopFollowing(ctx, f);
|
|
414
|
+
await letGo();
|
|
415
|
+
throw f.failure ?? failed(ctx.name, "following changes", error);
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
async function open(ctx, token, lease) {
|
|
419
|
+
const f = following(token);
|
|
269
420
|
const handle = async (change) => {
|
|
270
421
|
if (f.failure)
|
|
271
422
|
throw f.failure;
|
|
@@ -294,41 +445,29 @@ async function open(ctx, token) {
|
|
|
294
445
|
f.beat = setInterval(() => {
|
|
295
446
|
flush(ctx, f, subscription).catch(() => subscription.close());
|
|
296
447
|
}, ctx.positionIntervalMs);
|
|
297
|
-
|
|
298
|
-
f.done = true;
|
|
299
|
-
clearTimeout(f.timer);
|
|
300
|
-
clearInterval(f.beat);
|
|
301
|
-
ctx.follower.running = false;
|
|
302
|
-
};
|
|
303
|
-
const closed = subscription.closed.then(async (reason) => {
|
|
304
|
-
stop();
|
|
305
|
-
await f.chain.catch(() => {
|
|
306
|
-
return;
|
|
307
|
-
});
|
|
448
|
+
f.stopLease = keepLease(ctx, lease, () => {
|
|
308
449
|
if (f.failure)
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
return reason;
|
|
313
|
-
}, (error) => {
|
|
314
|
-
stop();
|
|
315
|
-
throw f.failure ?? failed(ctx.name, "following changes", error);
|
|
450
|
+
return;
|
|
451
|
+
f.failure = leaseLost(ctx);
|
|
452
|
+
subscription.close();
|
|
316
453
|
});
|
|
454
|
+
const closed = settle(ctx, f, subscription, lease);
|
|
317
455
|
try {
|
|
318
456
|
await subscription.ready;
|
|
319
457
|
} catch (error) {
|
|
320
|
-
|
|
321
|
-
closed.catch(
|
|
322
|
-
return;
|
|
323
|
-
});
|
|
458
|
+
stopFollowing(ctx, f);
|
|
459
|
+
closed.catch(noop);
|
|
324
460
|
throw error;
|
|
325
461
|
}
|
|
462
|
+
f.heard = true;
|
|
326
463
|
ctx.follower.running = true;
|
|
327
464
|
const close = async () => {
|
|
328
465
|
try {
|
|
329
466
|
await flush(ctx, f, subscription);
|
|
330
467
|
} finally {
|
|
331
468
|
await subscription.close();
|
|
469
|
+
if (f.inFlight === 0)
|
|
470
|
+
await closed.then(noop, noop);
|
|
332
471
|
}
|
|
333
472
|
};
|
|
334
473
|
return {
|
|
@@ -339,25 +478,50 @@ async function open(ctx, token) {
|
|
|
339
478
|
[Symbol.asyncDispose]: close
|
|
340
479
|
};
|
|
341
480
|
}
|
|
481
|
+
|
|
482
|
+
// src/start.ts
|
|
483
|
+
function historyLost(error) {
|
|
484
|
+
const code = error?.serverCode;
|
|
485
|
+
return code === 286 || code === 280;
|
|
486
|
+
}
|
|
342
487
|
async function start(ctx) {
|
|
343
488
|
checkIdle(ctx, "start it twice");
|
|
489
|
+
const lease = await acquire(ctx, "start it");
|
|
490
|
+
const stop = keepLease(ctx, lease, () => {
|
|
491
|
+
return;
|
|
492
|
+
});
|
|
493
|
+
try {
|
|
494
|
+
const running = await startHeld(ctx, lease);
|
|
495
|
+
stop();
|
|
496
|
+
return running;
|
|
497
|
+
} catch (error) {
|
|
498
|
+
stop();
|
|
499
|
+
await release(ctx, lease);
|
|
500
|
+
throw error;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
async function openHeld(ctx, token, lease) {
|
|
504
|
+
await confirmLease(ctx, lease);
|
|
505
|
+
return open(ctx, token, lease);
|
|
506
|
+
}
|
|
507
|
+
async function startHeld(ctx, lease) {
|
|
344
508
|
let state = await readState(ctx);
|
|
345
509
|
if (!state) {
|
|
346
|
-
await
|
|
510
|
+
await reindexHeld(ctx, lease);
|
|
347
511
|
state = await readState(ctx);
|
|
348
512
|
}
|
|
349
513
|
try {
|
|
350
|
-
return await
|
|
514
|
+
return await openHeld(ctx, state.resumeToken, lease);
|
|
351
515
|
} catch (error) {
|
|
352
516
|
if (!historyLost(error))
|
|
353
517
|
throw failed(ctx.name, "starting", error);
|
|
354
518
|
if (ctx.onHistoryLost === "fail") {
|
|
355
519
|
throw new SearchSyncError(`Search sync "${ctx.name}" was last at a point the server's change ` + "history no longer reaches. Reindex it, or start it with " + "onHistoryLost: 'reindex'.", { code: "HISTORY_LOST", sync: ctx.name, cause: error });
|
|
356
520
|
}
|
|
357
|
-
await
|
|
521
|
+
await reindexHeld(ctx, lease);
|
|
358
522
|
const fresh = await readState(ctx);
|
|
359
523
|
try {
|
|
360
|
-
return await
|
|
524
|
+
return await openHeld(ctx, fresh.resumeToken, lease);
|
|
361
525
|
} catch (again) {
|
|
362
526
|
throw failed(ctx.name, "starting", again);
|
|
363
527
|
}
|
|
@@ -379,5 +543,5 @@ export {
|
|
|
379
543
|
createSearchSync
|
|
380
544
|
};
|
|
381
545
|
|
|
382
|
-
//# debugId=
|
|
546
|
+
//# debugId=663F5145BB58E86F64756E2164756E21
|
|
383
547
|
//# sourceMappingURL=index.js.map
|