@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 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
- | `RUNNING` | `reindex()` or a second `start()` while this sync is already following changes in this process. A reindex beside its own follower would remove what the follower has just indexed |
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
- - **Several processes sharing one sync — not yet.** There is no lock today,
229
- so run **one** follower per sync name. Two do the same writes twice, and a
230
- `reindex` in one while the other follows removes documents the follower has
231
- already indexed and will not send again. A lease on a sync name is being
232
- worked on: [the roadmap](docs/roadmap.md) says where it stands.
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'`), `sync: string`,
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
- - **Only one process per sync name**, while there is no lock; see *Not
324
- included*. Inside one process this package refuses it: `reindex()` and a
325
- second `start()` throw `RUNNING` while a sync of the same object is
326
- following.
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;
@@ -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,CAoCb"}
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"}
@@ -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;AAKnE;;;GAGG;AACH,wBAAsB,OAAO,CAC5B,GAAG,EAAE,WAAW,EAChB,OAAO,EAAE,OAAO,EAChB,QAAQ,EAAE,GAAG,GAAG,SAAS,GACvB,OAAO,CAAC,KAAK,CAAC,CAsBhB"}
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
- /** This sync is already following changes in this process. */
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 {
@@ -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,8DAA8D;GAC5D,SAAS;AACX,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"}
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
@@ -1 +1 @@
1
- {"version":3,"file":"follow.d.ts","sourceRoot":"","sources":["../src/follow.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAO,WAAW,EAAE,MAAM,WAAW,CAAC;AAMlD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAyJjD;;;;GAIG;AACH,wBAAsB,KAAK,CAAC,GAAG,EAAE,WAAW,GAAG,OAAO,CAAC,iBAAiB,CAAC,CA2BxE"}
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(options.stateCollection ?? DEFAULT_STATE_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 TypeError(`transform must return a document or null, not ${String(indexed)}`);
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/reindex.ts
114
- import { MeilisearchApiError } from "meilisearch";
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
- function historyLost(error) {
227
- const code = error?.serverCode;
228
- return code === 286 || code === 280;
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
- async function open(ctx, token) {
258
- const f = {
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
- const stop = () => {
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
- throw f.failure;
310
- if (reason === "invalidated")
311
- await clearState(ctx);
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
- stop();
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 reindex(ctx);
510
+ await reindexHeld(ctx, lease);
347
511
  state = await readState(ctx);
348
512
  }
349
513
  try {
350
- return await open(ctx, state.resumeToken);
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 reindex(ctx);
521
+ await reindexHeld(ctx, lease);
358
522
  const fresh = await readState(ctx);
359
523
  try {
360
- return await open(ctx, fresh.resumeToken);
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=3F6CF388DE058E4464756E2164756E21
546
+ //# debugId=663F5145BB58E86F64756E2164756E21
383
547
  //# sourceMappingURL=index.js.map