@nxgt/mongo-meilisearch 0.2.0 → 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 +75 -13
- package/dist/context.d.ts +5 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/errors.d.ts +9 -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 +195 -38
- package/dist/index.js.map +10 -8
- 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 +118 -6
- package/docs/guide/reindex.md +50 -10
- package/docs/guide/sync-lifecycle.md +8 -5
- package/docs/roadmap.md +9 -4
- package/docs/troubleshooting.md +146 -10
- package/package.json +3 -3
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
|
|
@@ -204,7 +258,8 @@ This package throws `SearchSyncError`; what caused it is its `cause`.
|
|
|
204
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 |
|
|
205
259
|
| `ID_MISMATCH` | the transform gave a document whose primary key is not its index id |
|
|
206
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 |
|
|
207
|
-
| `RUNNING` | `reindex()` or a second `start()` while
|
|
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 |
|
|
208
263
|
| `FAILED` | anything else: the transform threw, MongoDB or Meilisearch refused. The message says what the sync was doing |
|
|
209
264
|
|
|
210
265
|
A running sync that meets one stops: `closed` rejects with it, and `flush`
|
|
@@ -227,11 +282,11 @@ out of range, an empty `name`, or a `transform` that is not a function.
|
|
|
227
282
|
|
|
228
283
|
## Not included
|
|
229
284
|
|
|
230
|
-
- **
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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*.
|
|
235
290
|
- **Partial updates.** A change sends the whole document the transform
|
|
236
291
|
gives, never a patch.
|
|
237
292
|
- **Keeping the index's settings.** That is `@nxgt/meilisearch`'s `sync`.
|
|
@@ -261,6 +316,7 @@ function createSearchSync<C extends AnyCollectionDefinition, I extends AnyIndexD
|
|
|
261
316
|
| `batchSize: number` | `500` | changes sent at once |
|
|
262
317
|
| `flushIntervalMs: number` | `1000` | how long a change waits for others; `0` sends at the next tick |
|
|
263
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 |
|
|
264
320
|
| `pageSize: number` | `100` | documents a reindex reads per page; above the collection's `maxPageSize`, lowered to it |
|
|
265
321
|
| `onHistoryLost: 'reindex' \| 'fail'` | `'reindex'` | |
|
|
266
322
|
|
|
@@ -283,7 +339,7 @@ function createSearchSync<C extends AnyCollectionDefinition, I extends AnyIndexD
|
|
|
283
339
|
| `close(): Promise<void>` | flushes, then stops |
|
|
284
340
|
|
|
285
341
|
`class SearchSyncError extends Error`: `code: SearchSyncErrorCode`
|
|
286
|
-
(`'HISTORY_LOST' | 'ID_MISMATCH' | 'NOT_A_DOCUMENT' | 'RUNNING' | 'FAILED'`),
|
|
342
|
+
(`'HISTORY_LOST' | 'ID_MISMATCH' | 'NOT_A_DOCUMENT' | 'RUNNING' | 'LEASE_LOST' | 'FAILED'`),
|
|
287
343
|
`sync: string`,
|
|
288
344
|
`cause`. Its constructor takes `(message, options: SearchSyncErrorOptions)`,
|
|
289
345
|
that is `{ code, sync, cause? }`; both types are exported.
|
|
@@ -323,10 +379,16 @@ Each is a `@ts-expect-error` case in this package's type tests.
|
|
|
323
379
|
- **Without post-images, a change carries the document as it is now**, not
|
|
324
380
|
as the change left it (`@nxgt/mongo`'s change streams). For an index, where
|
|
325
381
|
only the latest state counts, that is what you want.
|
|
326
|
-
- **
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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.
|
|
330
392
|
- **A dropped collection stops the sync** (`'invalidated'`) and leaves the
|
|
331
393
|
index as it was. What the collection had is removed by the reindex the
|
|
332
394
|
next `start` runs.
|
|
@@ -350,7 +412,7 @@ Each is a `@ts-expect-error` case in this package's type tests.
|
|
|
350
412
|
- [Following changes](docs/guide/following-changes.md) — batches, the resume
|
|
351
413
|
point, and how a sync stops.
|
|
352
414
|
- [What it leaves out](docs/guide/boundaries.md) — the settings, the joins,
|
|
353
|
-
and the one follower per sync name.
|
|
415
|
+
and the lease that keeps one follower per sync name.
|
|
354
416
|
- [Troubleshooting](docs/troubleshooting.md) — the errors, by their message.
|
|
355
417
|
- [Roadmap](docs/roadmap.md) — what is next, and what is not planned.
|
|
356
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/errors.d.ts
CHANGED
|
@@ -6,8 +6,16 @@ export type SearchSyncErrorCode =
|
|
|
6
6
|
| 'ID_MISMATCH'
|
|
7
7
|
/** The transform gave something that is neither a document nor `null`. */
|
|
8
8
|
| 'NOT_A_DOCUMENT'
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* This sync's name is taken: it is following changes in this process, or
|
|
11
|
+
* another process holds its lease.
|
|
12
|
+
*/
|
|
10
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'
|
|
11
19
|
/** Anything else: the cause says what. */
|
|
12
20
|
| 'FAILED';
|
|
13
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,0EAA0E;GACxE,gBAAgB;AAClB
|
|
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) {
|
|
@@ -117,8 +123,108 @@ async function entryOf(ctx, mongoId, document) {
|
|
|
117
123
|
return { kind: "upsert", key, document: indexed };
|
|
118
124
|
}
|
|
119
125
|
|
|
120
|
-
// src/
|
|
121
|
-
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
|
+
}
|
|
122
228
|
|
|
123
229
|
// src/running.ts
|
|
124
230
|
function checkIdle(ctx, doing) {
|
|
@@ -167,7 +273,7 @@ async function currentToken(ctx) {
|
|
|
167
273
|
await stream.close();
|
|
168
274
|
}
|
|
169
275
|
}
|
|
170
|
-
async function sendAll(ctx, wanted) {
|
|
276
|
+
async function sendAll(ctx, wanted, lease) {
|
|
171
277
|
let indexed = 0;
|
|
172
278
|
let skipped = 0;
|
|
173
279
|
let after;
|
|
@@ -188,6 +294,8 @@ async function sendAll(ctx, wanted) {
|
|
|
188
294
|
}
|
|
189
295
|
await sendDocuments(ctx, documents);
|
|
190
296
|
indexed += documents.length;
|
|
297
|
+
if (lease.lost)
|
|
298
|
+
throw leaseLost(ctx);
|
|
191
299
|
after = page.nextCursor;
|
|
192
300
|
} while (after);
|
|
193
301
|
return { indexed, skipped };
|
|
@@ -217,11 +325,16 @@ async function removeUnwanted(ctx, wanted) {
|
|
|
217
325
|
}
|
|
218
326
|
async function reindex(ctx) {
|
|
219
327
|
checkIdle(ctx, "reindex");
|
|
328
|
+
return withLease(ctx, "reindex", (lease) => reindexHeld(ctx, lease));
|
|
329
|
+
}
|
|
330
|
+
async function reindexHeld(ctx, lease) {
|
|
220
331
|
try {
|
|
221
332
|
const token = await currentToken(ctx);
|
|
222
333
|
const wanted = new Set;
|
|
223
|
-
const { indexed, skipped } = await sendAll(ctx, wanted);
|
|
334
|
+
const { indexed, skipped } = await sendAll(ctx, wanted, lease);
|
|
335
|
+
await confirmLease(ctx, lease);
|
|
224
336
|
const removed = await removeUnwanted(ctx, wanted);
|
|
337
|
+
await confirmLease(ctx, lease);
|
|
225
338
|
await saveState(ctx, token, true);
|
|
226
339
|
return { indexed, skipped, removed };
|
|
227
340
|
} catch (error) {
|
|
@@ -230,10 +343,9 @@ async function reindex(ctx) {
|
|
|
230
343
|
}
|
|
231
344
|
|
|
232
345
|
// src/follow.ts
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
346
|
+
var noop = () => {
|
|
347
|
+
return;
|
|
348
|
+
};
|
|
237
349
|
function flush(ctx, f, subscription) {
|
|
238
350
|
f.chain = f.chain.then(async () => {
|
|
239
351
|
if (f.failure)
|
|
@@ -261,18 +373,50 @@ function flush(ctx, f, subscription) {
|
|
|
261
373
|
});
|
|
262
374
|
return f.chain;
|
|
263
375
|
}
|
|
264
|
-
|
|
265
|
-
|
|
376
|
+
function following(token) {
|
|
377
|
+
return {
|
|
266
378
|
pending: new Map,
|
|
267
379
|
last: undefined,
|
|
268
380
|
saved: token,
|
|
269
381
|
inFlight: 0,
|
|
270
382
|
timer: undefined,
|
|
271
383
|
beat: undefined,
|
|
384
|
+
stopLease: noop,
|
|
385
|
+
heard: false,
|
|
272
386
|
chain: Promise.resolve(),
|
|
273
387
|
done: false,
|
|
274
388
|
failure: undefined
|
|
275
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);
|
|
276
420
|
const handle = async (change) => {
|
|
277
421
|
if (f.failure)
|
|
278
422
|
throw f.failure;
|
|
@@ -301,41 +445,29 @@ async function open(ctx, token) {
|
|
|
301
445
|
f.beat = setInterval(() => {
|
|
302
446
|
flush(ctx, f, subscription).catch(() => subscription.close());
|
|
303
447
|
}, ctx.positionIntervalMs);
|
|
304
|
-
|
|
305
|
-
f.done = true;
|
|
306
|
-
clearTimeout(f.timer);
|
|
307
|
-
clearInterval(f.beat);
|
|
308
|
-
ctx.follower.running = false;
|
|
309
|
-
};
|
|
310
|
-
const closed = subscription.closed.then(async (reason) => {
|
|
311
|
-
stop();
|
|
312
|
-
await f.chain.catch(() => {
|
|
313
|
-
return;
|
|
314
|
-
});
|
|
448
|
+
f.stopLease = keepLease(ctx, lease, () => {
|
|
315
449
|
if (f.failure)
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
return reason;
|
|
320
|
-
}, (error) => {
|
|
321
|
-
stop();
|
|
322
|
-
throw f.failure ?? failed(ctx.name, "following changes", error);
|
|
450
|
+
return;
|
|
451
|
+
f.failure = leaseLost(ctx);
|
|
452
|
+
subscription.close();
|
|
323
453
|
});
|
|
454
|
+
const closed = settle(ctx, f, subscription, lease);
|
|
324
455
|
try {
|
|
325
456
|
await subscription.ready;
|
|
326
457
|
} catch (error) {
|
|
327
|
-
|
|
328
|
-
closed.catch(
|
|
329
|
-
return;
|
|
330
|
-
});
|
|
458
|
+
stopFollowing(ctx, f);
|
|
459
|
+
closed.catch(noop);
|
|
331
460
|
throw error;
|
|
332
461
|
}
|
|
462
|
+
f.heard = true;
|
|
333
463
|
ctx.follower.running = true;
|
|
334
464
|
const close = async () => {
|
|
335
465
|
try {
|
|
336
466
|
await flush(ctx, f, subscription);
|
|
337
467
|
} finally {
|
|
338
468
|
await subscription.close();
|
|
469
|
+
if (f.inFlight === 0)
|
|
470
|
+
await closed.then(noop, noop);
|
|
339
471
|
}
|
|
340
472
|
};
|
|
341
473
|
return {
|
|
@@ -346,25 +478,50 @@ async function open(ctx, token) {
|
|
|
346
478
|
[Symbol.asyncDispose]: close
|
|
347
479
|
};
|
|
348
480
|
}
|
|
481
|
+
|
|
482
|
+
// src/start.ts
|
|
483
|
+
function historyLost(error) {
|
|
484
|
+
const code = error?.serverCode;
|
|
485
|
+
return code === 286 || code === 280;
|
|
486
|
+
}
|
|
349
487
|
async function start(ctx) {
|
|
350
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) {
|
|
351
508
|
let state = await readState(ctx);
|
|
352
509
|
if (!state) {
|
|
353
|
-
await
|
|
510
|
+
await reindexHeld(ctx, lease);
|
|
354
511
|
state = await readState(ctx);
|
|
355
512
|
}
|
|
356
513
|
try {
|
|
357
|
-
return await
|
|
514
|
+
return await openHeld(ctx, state.resumeToken, lease);
|
|
358
515
|
} catch (error) {
|
|
359
516
|
if (!historyLost(error))
|
|
360
517
|
throw failed(ctx.name, "starting", error);
|
|
361
518
|
if (ctx.onHistoryLost === "fail") {
|
|
362
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 });
|
|
363
520
|
}
|
|
364
|
-
await
|
|
521
|
+
await reindexHeld(ctx, lease);
|
|
365
522
|
const fresh = await readState(ctx);
|
|
366
523
|
try {
|
|
367
|
-
return await
|
|
524
|
+
return await openHeld(ctx, fresh.resumeToken, lease);
|
|
368
525
|
} catch (again) {
|
|
369
526
|
throw failed(ctx.name, "starting", again);
|
|
370
527
|
}
|
|
@@ -386,5 +543,5 @@ export {
|
|
|
386
543
|
createSearchSync
|
|
387
544
|
};
|
|
388
545
|
|
|
389
|
-
//# debugId=
|
|
546
|
+
//# debugId=663F5145BB58E86F64756E2164756E21
|
|
390
547
|
//# sourceMappingURL=index.js.map
|