@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 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 this sync is already following changes in this process. A reindex beside its own follower would remove what the follower has just indexed |
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
- - **Several processes sharing one sync — not yet.** There is no lock today,
231
- so run **one** follower per sync name. Two do the same writes twice, and a
232
- `reindex` in one while the other follows removes documents the follower has
233
- already indexed and will not send again. A lease on a sync name is being
234
- 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*.
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
- - **Only one process per sync name**, while there is no lock; see *Not
327
- included*. Inside one process this package refuses it: `reindex()` and a
328
- second `start()` throw `RUNNING` while a sync of the same object is
329
- 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.
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;
@@ -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"}
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
- /** This sync is already following changes in this process. */
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 {
@@ -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,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) {
@@ -117,8 +123,108 @@ async function entryOf(ctx, mongoId, document) {
117
123
  return { kind: "upsert", key, document: indexed };
118
124
  }
119
125
 
120
- // src/reindex.ts
121
- 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
+ }
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
- function historyLost(error) {
234
- const code = error?.serverCode;
235
- return code === 286 || code === 280;
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
- async function open(ctx, token) {
265
- const f = {
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
- const stop = () => {
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
- throw f.failure;
317
- if (reason === "invalidated")
318
- await clearState(ctx);
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
- stop();
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 reindex(ctx);
510
+ await reindexHeld(ctx, lease);
354
511
  state = await readState(ctx);
355
512
  }
356
513
  try {
357
- return await open(ctx, state.resumeToken);
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 reindex(ctx);
521
+ await reindexHeld(ctx, lease);
365
522
  const fresh = await readState(ctx);
366
523
  try {
367
- return await open(ctx, fresh.resumeToken);
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=1220BC8109B997D064756E2164756E21
546
+ //# debugId=663F5145BB58E86F64756E2164756E21
390
547
  //# sourceMappingURL=index.js.map