@dokebi/search 0.1.0 → 0.1.2

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/AI.md CHANGED
@@ -70,12 +70,12 @@ try {
70
70
  }
71
71
  ```
72
72
 
73
- - **`index(tx, index, item)`** writes in the transaction of `tx` (the pg client of `BEGIN`…`COMMIT`, or the mysql2/mariadb connection of the transaction). `item` is the item's **whole** searchable text: `id` (1–128), `sortKey` (a safe integer: the display order, newest first — usually the creation time in ms), `scopes` (the filters a search is combined with, at most 32 of at most 64 characters; default `['']`, everything), `fields` (a text per declared field; absent or null has no terms; at most 1,000,000 characters). It reads the item's terms by the item index and writes only the difference; a new `sortKey` rewrites them. Call it in the transaction that changes the item's row, so that row's lock orders two writers of the same item.
73
+ - **`index(tx, index, item)`** writes in the transaction of `tx` (the pg client of `BEGIN`…`COMMIT`, or the mysql2/mariadb connection of the transaction). `item` is the item's **whole** searchable text: `id` (1–128), `sortKey` (a safe integer: the display order, newest first — usually the creation time in ms), `scopes` (the filters a search is combined with, at most 32 of at most 64 characters; default `['']`, everything), `fields` (a text per declared field; absent or null has no terms; at most 1,000,000 characters). It reads the item's terms by the item index and writes only the difference; a new `sortKey` rewrites them. Call it in the transaction that changes the item's row, so that row's lock orders two writers of the same item. On MySQL and MariaDB a transaction reads the snapshot of its first plain read, which may predate another writer's commit of the item: after its own inserts the write counts the item's rows as committed now (a locking read) and, when there are more than its new text, removes the others (measured on a 1,000-character edit: p50 7.4 → 9.0 ms on MySQL 8, 6.2 → 7.0 ms on MariaDB; PostgreSQL's READ COMMITTED statements read the committed rows and skip it). A deadlock, lock wait timeout or changed snapshot (MariaDB errno 1020) of your transaction comes back as the driver's own error, not `STORE_FAILED`: run the whole transaction again (@dokebi/db AI.md 6.4); a route that does not answers 503 `DB_BUSY`.
74
74
  - **Scopes** are how a search is filtered without reading other rows: an item is indexed under each, so a board and its ancestor categories are `['', 'board:12', 'cat:3', 'cat:3/7']` and a search names one. Each scope adds the item's terms again: keep them few.
75
- - **`remove(tx, index, id)`** when the item is hidden or deleted → `{ removed }`. Items a viewer may not see must not be in the index (or in a scope that viewer searches): `find` answers ids, it does not check rights.
75
+ - **`remove(tx, index, id)`** when the item is hidden or deleted → `{ removed }` (on MySQL and MariaDB it reads the item's rows as committed now, as above). Items a viewer may not see must not be in the index (or in a scope that viewer searches): `find` answers ids, it does not check rights.
76
76
  - **`indexMany(tx, index, items)`**: a first fill or a re-index, in batches of a few hundred per transaction from a job; then `flush()` and `markCurrent(index)`.
77
77
  - **A display order that changes all the time** (views, likes) is not a `sortKey`: every change would rewrite every term row. Search in the index's order and rank a capped set of results yourself, saying so.
78
- - **Word counts** (which word is rarest) are a hint the plugin adds after the write, outside your transaction: each worker buffers them and writes them every `counts.flushMs`, at drain and on `flush()`. A common word's count is never a row every writer waits on, and a rolled-back write leaves its count changed: the counts may drift, the results never do.
78
+ - **Word counts** (which word is rarest) are a hint the plugin adds after the write, outside your transaction: each worker buffers them and writes them every `counts.flushMs`, at drain and on `flush()` (which also writes what was buffered while an earlier flush was running). A common word's count is never a row every writer waits on, and a rolled-back write leaves its count changed: the counts may drift, the results never do.
79
79
 
80
80
  ## 4. Search
81
81
 
@@ -121,7 +121,7 @@ debug ([D-0047](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decis
121
121
  | `UNSUPPORTED_RESOURCE` | the connection is not a pg, mysql2/promise or mariadb pool (MongoDB) |
122
122
  | `TABLE_MISSING` | the tables or their columns are missing at start (run the migration) |
123
123
  | `NOT_READY` | the database of an optional connection is down (503) |
124
- | `STORE_FAILED` | a statement failed (the driver error is the cause) |
124
+ | `STORE_FAILED` | a statement failed (the driver error is the cause); deadlocks, lock waits and changed snapshots in your transaction come out as the driver's own error instead |
125
125
  | `DESCRIBING` | a call during `dokebi describe` |
126
126
 
127
127
  ## 7. Cautions
package/dist/plugin.js CHANGED
@@ -4,8 +4,8 @@ import { Cursors, queryFingerprint } from './cursor.js';
4
4
  import { SearchError } from './errors.js';
5
5
  import { indexesOf } from './indexes.js';
6
6
  import { DEFAULT_SECRETS_ENV, MIN_SECRET_LENGTH, options } from './options.js';
7
- import { detectDriver, isMissingTable, isQueryable, serverDialect, session, } from './sql.js';
8
- import { COUNTS_TABLE, INDEXES_TABLE, Store, TERMS_TABLE, } from './store.js';
7
+ import { detectDriver, isMissingTable, isQueryable, isRetryable, serverDialect, session, } from './sql.js';
8
+ import { COUNT_BATCH, COUNTS_TABLE, INDEXES_TABLE, Store, TERMS_TABLE, } from './store.js';
9
9
  import { MAX_QUERY_WORDS, queryTerms, terms } from './terms.js';
10
10
  const db = providerDependency({
11
11
  name: 'dokebi-db',
@@ -130,7 +130,7 @@ class SearchRuntime {
130
130
  }
131
131
  this.#repeats = new diagnostics.Repeats({ logger: context.logger, intervalMs: REPEAT_MS });
132
132
  const timer = setInterval(() => {
133
- void this.#flush();
133
+ this.#flushSoon();
134
134
  }, settings.counts.flushMs);
135
135
  timer.unref();
136
136
  context.defer(async () => {
@@ -241,8 +241,11 @@ class SearchRuntime {
241
241
  data: { connection: this.#options.connection, retryAfterMs: RETRY_MS },
242
242
  });
243
243
  }
244
- /** A statement failure as a coded error that keeps the driver's cause. */
245
- async #run(operation, work) {
244
+ /**
245
+ * A statement failure as a coded error that keeps the driver's cause; `inCaller`: the work ran
246
+ * in the caller's transaction.
247
+ */
248
+ async #run(operation, work, inCaller = false) {
246
249
  const live = await this.#ready();
247
250
  try {
248
251
  return await work(live);
@@ -256,6 +259,10 @@ class SearchRuntime {
256
259
  operation,
257
260
  error: diagnostics.describeError(error),
258
261
  });
262
+ // The caller's transaction ended by a deadlock, a lock wait or a changed snapshot: its own
263
+ // error, so the caller's retry sees it and @dokebi/db answers 503 DB_BUSY / DB_TIMEOUT.
264
+ if (inCaller && isRetryable(error))
265
+ throw error;
259
266
  throw new SearchError('STORE_FAILED', `The search could not ${operation}`, {
260
267
  cause: error,
261
268
  data: { operation },
@@ -351,6 +358,23 @@ class SearchRuntime {
351
358
  await live.store.deleteRows(tx, index, item.id, gone);
352
359
  if (added.length)
353
360
  await live.store.insertRows(tx, index, item.id, item.sortKey, added);
361
+ // After the inserts the item holds at least its new rows, once each: only more means rows
362
+ // this transaction's snapshot did not show (one small locking read, the rows only then).
363
+ if (live.store.snapshots &&
364
+ (await live.store.lockedItemCount(tx, index, item.id)) !== rows.length) {
365
+ // MySQL and MariaDB: `old` came from the transaction's snapshot, which may predate another
366
+ // writer's commit of this item (its first plain read fixed it). Rows that writer added are
367
+ // read as committed now and removed (R8-g2-2); their words count as the item's before.
368
+ const extra = (await live.store.lockedItemRows(tx, index, item.id)).filter((row) => row.sortKey !== item.sortKey || !now.has(key(row)));
369
+ for (const row of extra)
370
+ oldPairs.add(`${row.field}${SEP}${row.term}`);
371
+ if (extra.some((row) => row.sortKey !== item.sortKey)) {
372
+ await live.store.deleteItem(tx, index, item.id);
373
+ await live.store.insertRows(tx, index, item.id, item.sortKey, rows);
374
+ }
375
+ else if (extra.length)
376
+ await live.store.deleteRows(tx, index, item.id, extra);
377
+ }
354
378
  }
355
379
  this.#count(index, oldPairs, pairs);
356
380
  }
@@ -371,16 +395,26 @@ class SearchRuntime {
371
395
  if (!after.has(pair))
372
396
  add(pair, -1);
373
397
  if (this.#pending.size >= FLUSH_WORDS)
374
- void this.#flush();
398
+ this.#flushSoon();
375
399
  }
376
400
  /**
377
- * Adds the buffered count changes to the table, one autocommit statement per index on the
378
- * plugin's pool, words in sorted order. A failure is logged and the changes are kept for the
401
+ * Adds the buffered count changes to the table, in autocommit statements of up to COUNT_BATCH
402
+ * words per index on the plugin's pool, words in sorted order. A failure is logged and the changes are kept for the
379
403
  * next flush. Never rejects.
380
404
  */
381
405
  #flush() {
406
+ // A flush in flight took the buffer before the newest changes: an explicit flush (flush(),
407
+ // the drain, the stop) waits for it, then writes what was buffered since.
382
408
  if (this.#flushing)
383
- return this.#flushing;
409
+ return this.#flushing.then(() => this.#flush());
410
+ return this.#flushNow();
411
+ }
412
+ /** The timer's and the buffer-size flush: none while one is in flight (it runs again soon). */
413
+ #flushSoon() {
414
+ if (!this.#flushing)
415
+ void this.#flushNow();
416
+ }
417
+ #flushNow() {
384
418
  if (!this.#pending.size || !this.#live)
385
419
  return Promise.resolve();
386
420
  const live = this.#live;
@@ -398,14 +432,23 @@ class SearchRuntime {
398
432
  }
399
433
  for (const [index, deltas] of byIndex) {
400
434
  deltas.sort((a, b) => byKey(a.field, b.field) || byKey(a.term, b.term));
435
+ // One autocommit statement per COUNT_BATCH words: `written` words are in the table.
436
+ let written = 0;
401
437
  try {
402
- await live.store.addCounts(live.pool, index, deltas);
438
+ for (; written < deltas.length; written += COUNT_BATCH)
439
+ await live.store.addCounts(live.pool, index, deltas.slice(written, written + COUNT_BATCH));
403
440
  }
404
441
  catch (error) {
405
- // Kept for the next flush: the counts are late, never lost while the process runs.
406
- for (const row of deltas) {
442
+ // Kept for the next flush: the counts are late, never lost while the process runs. Only
443
+ // the words from the failed statement on: the statements before it are committed, and
444
+ // keeping their words too would add them twice (R9-g14-3).
445
+ for (const row of deltas.slice(written)) {
407
446
  const key = `${index}${SEP}${row.field}${SEP}${row.term}`;
408
- this.#pending.set(key, (this.#pending.get(key) ?? 0) + row.delta);
447
+ const value = (this.#pending.get(key) ?? 0) + row.delta;
448
+ if (value === 0)
449
+ this.#pending.delete(key);
450
+ else
451
+ this.#pending.set(key, value);
409
452
  }
410
453
  this.#repeats?.log('error', 'Search word counts could not be written; they are kept for the next flush.', 'SEARCH_COUNTS_FAILED', error);
411
454
  }
@@ -582,7 +625,7 @@ class SearchRuntime {
582
625
  },
583
626
  // The item is checked (and its terms made) once, inside #write: a SearchError passes #run.
584
627
  index: async (tx, index, item) => {
585
- await this.#run('index an item', async (live) => this.#write(live, session(live.driver, tx), index, item));
628
+ await this.#run('index an item', async (live) => this.#write(live, session(live.driver, tx), index, item), true);
586
629
  },
587
630
  indexMany: async (tx, index, items) => {
588
631
  if (!Array.isArray(items))
@@ -591,20 +634,23 @@ class SearchRuntime {
591
634
  const client = session(live.driver, tx);
592
635
  for (const item of items)
593
636
  await this.#write(live, client, index, item);
594
- });
637
+ }, true);
595
638
  },
596
639
  remove: async (tx, index, id) => {
597
640
  this.#fields(index);
598
641
  const item = this.#id(id);
599
642
  return this.#run('remove an item', async (live) => {
600
643
  const client = session(live.driver, tx);
601
- const old = await live.store.itemRows(client, index, item);
644
+ // MySQL and MariaDB: as committed now, not as the transaction's snapshot (R8-g2-2).
645
+ const old = live.store.snapshots
646
+ ? await live.store.lockedItemRows(client, index, item)
647
+ : await live.store.itemRows(client, index, item);
602
648
  if (!old.length)
603
649
  return Object.freeze({ removed: false });
604
650
  await live.store.deleteItem(client, index, item);
605
651
  this.#count(index, new Set(old.map((row) => `${row.field}${SEP}${row.term}`)), new Set());
606
652
  return Object.freeze({ removed: true });
607
- });
653
+ }, true);
608
654
  },
609
655
  find: async (index, query) => {
610
656
  return this.#run('search', (live) => this.#find(live, index, query));
@@ -660,7 +706,7 @@ class SearchRuntime {
660
706
  /** Factory input: `{ app, options?, entry }`: `entry` is the module of src/infra/search/indexes.ts. */
661
707
  export const createPlugin = definePlugin({
662
708
  name: 'dokebi-search',
663
- version: '0.1.0',
709
+ version: '0.1.2',
664
710
  core: '^0.4.2',
665
711
  options,
666
712
  dependencies: () => [db],
package/dist/sql.d.ts CHANGED
@@ -26,6 +26,11 @@ export declare function serverDialect(driver: Driver, pool: Session): Promise<{
26
26
  }>;
27
27
  /** A missing table or column (PostgreSQL 42P01, 42703; MySQL and MariaDB 1146, 1054). */
28
28
  export declare function isMissingTable(error: unknown): boolean;
29
+ /**
30
+ * A deadlock, serialization failure or lock wait timeout (PostgreSQL 40001, 40P01, 55P03; MySQL
31
+ * and MariaDB 1213, 1020, 1205): the caller's transaction is over and is run again whole.
32
+ */
33
+ export declare function isRetryable(error: unknown): boolean;
29
34
  /** A number from a driver value: pg bigint strings, mariadb BigInt, mysql2 numbers. */
30
35
  export declare function num(value: unknown): number;
31
36
  export declare function text(value: unknown): string | undefined;
package/dist/sql.js CHANGED
@@ -95,6 +95,16 @@ export function isMissingTable(error) {
95
95
  const value = error;
96
96
  return MISSING_TABLE.has(value?.code) || MISSING_TABLE_ERRNO.has(Number(value?.errno));
97
97
  }
98
+ const RETRY = new Set(['40001', '40P01', '55P03']);
99
+ const RETRY_ERRNO = new Set([1213, 1020, 1205]);
100
+ /**
101
+ * A deadlock, serialization failure or lock wait timeout (PostgreSQL 40001, 40P01, 55P03; MySQL
102
+ * and MariaDB 1213, 1020, 1205): the caller's transaction is over and is run again whole.
103
+ */
104
+ export function isRetryable(error) {
105
+ const value = error;
106
+ return RETRY.has(value?.code) || RETRY_ERRNO.has(Number(value?.errno));
107
+ }
98
108
  /** A number from a driver value: pg bigint strings, mariadb BigInt, mysql2 numbers. */
99
109
  export function num(value) {
100
110
  return value === null || value === undefined ? 0 : Number(value);
package/dist/store.d.ts CHANGED
@@ -3,6 +3,8 @@ import type { Dialect, Session } from './sql.js';
3
3
  export declare const TERMS_TABLE = "dokebi_search_terms";
4
4
  export declare const COUNTS_TABLE = "dokebi_search_counts";
5
5
  export declare const INDEXES_TABLE = "dokebi_search_indexes";
6
+ /** Words of one count statement: a flush hands `addCounts` this many at a time. */
7
+ export declare const COUNT_BATCH = 500;
6
8
  export interface TermRow {
7
9
  readonly field: string;
8
10
  readonly term: string;
@@ -25,18 +27,32 @@ export interface Scanned extends SearchHit {
25
27
  }
26
28
  export declare class Store {
27
29
  #private;
30
+ /** MySQL and MariaDB read an older snapshot in a REPEATABLE READ transaction. */
31
+ get snapshots(): boolean;
28
32
  constructor(dialect: Dialect, slots: number);
29
33
  check(db: Session): Promise<void>;
30
34
  fingerprints(db: Session): Promise<Map<string, string>>;
31
35
  saveFingerprint(db: Session, name: string, fingerprint: string, at: number): Promise<void>;
32
36
  /** The rows of one item (the item index). */
33
37
  itemRows(db: Session, index: string, id: string): Promise<StoredRow[]>;
38
+ /**
39
+ * MySQL and MariaDB: the item's rows as committed now (a locking read), for a write whose
40
+ * transaction may read an older snapshot. Called after the write's own inserts: its range then
41
+ * holds the write's own rows, so the read waits for no other writer (an empty range would take a
42
+ * gap lock that two writers of new items share and then both insert into).
43
+ */
44
+ lockedItemRows(db: Session, index: string, id: string): Promise<StoredRow[]>;
45
+ /** MySQL and MariaDB: how many rows the item has as committed now (a locking read, as above). */
46
+ lockedItemCount(db: Session, index: string, id: string): Promise<number>;
34
47
  deleteItem(db: Session, index: string, id: string): Promise<number>;
35
48
  deleteRows(db: Session, index: string, id: string, rows: readonly TermRow[]): Promise<void>;
36
49
  insertRows(db: Session, index: string, id: string, sortKey: number, rows: readonly TermRow[]): Promise<void>;
37
50
  /**
38
51
  * Moves the item counts of words by their deltas, in one slot chosen at random, in the order
39
- * given (the caller sorts them, so two writers lock the same rows in the same order).
52
+ * given (the caller sorts them, so two writers lock the same rows in the same order). One
53
+ * statement for up to COUNT_BATCH deltas: all of them are written or none. More are several
54
+ * statements, and a failure then leaves the earlier ones written, so a caller that keeps what
55
+ * failed hands over COUNT_BATCH at a time.
40
56
  */
41
57
  addCounts(db: Session, index: string, deltas: readonly CountDelta[]): Promise<void>;
42
58
  /** The item count of each word (its slots summed); a word without rows is absent. */
package/dist/store.js CHANGED
@@ -10,8 +10,14 @@ export const COUNTS_TABLE = 'dokebi_search_counts';
10
10
  export const INDEXES_TABLE = 'dokebi_search_indexes';
11
11
  /** Rows of one multi-row INSERT or DELETE (parameters stay far below every driver's limit). */
12
12
  const BATCH = 500;
13
+ /** Words of one count statement: a flush hands `addCounts` this many at a time. */
14
+ export const COUNT_BATCH = BATCH;
13
15
  export class Store {
14
16
  #dialect;
17
+ /** MySQL and MariaDB read an older snapshot in a REPEATABLE READ transaction. */
18
+ get snapshots() {
19
+ return this.#dialect !== 'postgres';
20
+ }
15
21
  #slots;
16
22
  constructor(dialect, slots) {
17
23
  this.#dialect = dialect;
@@ -43,6 +49,26 @@ export class Store {
43
49
  sortKey: num(row.sort_key),
44
50
  }));
45
51
  }
52
+ /**
53
+ * MySQL and MariaDB: the item's rows as committed now (a locking read), for a write whose
54
+ * transaction may read an older snapshot. Called after the write's own inserts: its range then
55
+ * holds the write's own rows, so the read waits for no other writer (an empty range would take a
56
+ * gap lock that two writers of new items share and then both insert into).
57
+ */
58
+ async lockedItemRows(db, index, id) {
59
+ const { rows } = await db.query(`SELECT field, term, scope, sort_key FROM ${TERMS_TABLE} WHERE index_name = ? AND item_id = ? FOR UPDATE`, [index, id]);
60
+ return rows.map((row) => ({
61
+ field: String(row.field),
62
+ term: String(row.term),
63
+ scope: String(row.scope),
64
+ sortKey: num(row.sort_key),
65
+ }));
66
+ }
67
+ /** MySQL and MariaDB: how many rows the item has as committed now (a locking read, as above). */
68
+ async lockedItemCount(db, index, id) {
69
+ const { rows } = await db.query(`SELECT COUNT(*) AS n FROM ${TERMS_TABLE} WHERE index_name = ? AND item_id = ? FOR UPDATE`, [index, id]);
70
+ return num(rows[0]?.n);
71
+ }
46
72
  async deleteItem(db, index, id) {
47
73
  const result = await db.query(`DELETE FROM ${TERMS_TABLE} WHERE index_name = ? AND item_id = ?`, [index, id]);
48
74
  return result.affected;
@@ -66,12 +92,18 @@ export class Store {
66
92
  params.push(index, row.field, row.term, row.scope, sortKey, id);
67
93
  return '(?, ?, ?, ?, ?, ?)';
68
94
  });
69
- await db.query(`INSERT INTO ${TERMS_TABLE} (index_name, field, term, scope, sort_key, item_id) VALUES ${tuples.join(', ')}`, params);
95
+ // A row another writer committed after this transaction's snapshot may already be there
96
+ // (MySQL, MariaDB): it is the same row, kept.
97
+ const keep = this.#dialect === 'postgres' ? '' : ' ON DUPLICATE KEY UPDATE item_id = item_id';
98
+ await db.query(`INSERT INTO ${TERMS_TABLE} (index_name, field, term, scope, sort_key, item_id) VALUES ${tuples.join(', ')}${keep}`, params);
70
99
  }
71
100
  }
72
101
  /**
73
102
  * Moves the item counts of words by their deltas, in one slot chosen at random, in the order
74
- * given (the caller sorts them, so two writers lock the same rows in the same order).
103
+ * given (the caller sorts them, so two writers lock the same rows in the same order). One
104
+ * statement for up to COUNT_BATCH deltas: all of them are written or none. More are several
105
+ * statements, and a failure then leaves the earlier ones written, so a caller that keeps what
106
+ * failed hands over COUNT_BATCH at a time.
75
107
  */
76
108
  async addCounts(db, index, deltas) {
77
109
  const slot = Math.floor(Math.random() * this.#slots);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dokebi/search",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Search in an application's lists for Dokebi by @dokebi/db AI.md 6.10: a term table in display order written in the text's transaction, Korean word prefixes, the rarest word leads, bounded requests and searches with a signed cursor.",
5
5
  "keywords": [
6
6
  "dokebi",
@@ -45,7 +45,7 @@
45
45
  "@dokebi/db": "^0.4.1"
46
46
  },
47
47
  "devDependencies": {
48
- "@dokebi/db": "0.4.1"
48
+ "@dokebi/db": "0.4.2"
49
49
  },
50
50
  "dokebi": {
51
51
  "schemaVersion": 2,