akm-cli 0.9.15 → 0.9.16-alpha.1
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/CHANGELOG.md +144 -0
- package/dist/assets/tasks/core/index-refresh.yml +1 -1
- package/dist/cli/retired-commands.js +2 -0
- package/dist/cli/unknown-flags.js +36 -3
- package/dist/commands/improve/collapse-detector.js +2 -2
- package/dist/commands/improve/consolidate.js +6 -4
- package/dist/commands/improve/improve-cli.js +1 -1
- package/dist/commands/proposal/repository.js +12 -3
- package/dist/commands/read/curate.js +34 -44
- package/dist/commands/read/search.js +50 -2
- package/dist/commands/sources/index-status.js +99 -0
- package/dist/commands/sources/info.js +8 -8
- package/dist/commands/sources/installed-stashes.js +33 -12
- package/dist/commands/sources/source-add.js +21 -6
- package/dist/commands/sources/stash-cli.js +119 -111
- package/dist/core/adapter/adapters/akm-adapter.js +35 -3
- package/dist/core/adapter/adapters/akm-metadata.js +11 -1
- package/dist/core/asset/asset-placement.js +35 -0
- package/dist/core/config/schema/embedding.js +7 -30
- package/dist/core/config/schema/search.js +11 -9
- package/dist/core/errors.js +5 -2
- package/dist/core/hash.js +18 -0
- package/dist/core/maintenance-barrier.js +8 -6
- package/dist/core/paths.js +0 -11
- package/dist/core/run-lock.js +5 -2
- package/dist/core/state/migrations.js +26 -1
- package/dist/core/state-db.js +63 -27
- package/dist/indexer/drain.js +306 -0
- package/dist/indexer/embedding-identity.js +20 -0
- package/dist/indexer/enrich.js +260 -0
- package/dist/indexer/ensure-index.js +5 -0
- package/dist/indexer/index-written-assets.js +133 -171
- package/dist/indexer/indexer.js +458 -1621
- package/dist/indexer/lookup/adapter-concept-owner.js +19 -5
- package/dist/indexer/passes/metadata.js +18 -1
- package/dist/indexer/reconcile.js +890 -0
- package/dist/indexer/scan/drain-dir.js +27 -70
- package/dist/indexer/scan/parse-file.js +66 -0
- package/dist/indexer/search/db-search.js +373 -89
- package/dist/indexer/search/ranking-contributors.js +21 -16
- package/dist/indexer/search/ranking.js +135 -57
- package/dist/indexer/units/unit.js +159 -0
- package/dist/llm/client.js +10 -1
- package/dist/llm/embedder.js +10 -3
- package/dist/llm/embedders/provider-limits.js +288 -0
- package/dist/llm/embedders/remote.js +133 -104
- package/dist/llm/feature-gate.js +4 -2
- package/dist/llm/rerank-client.js +3 -3
- package/dist/output/shapes/passthrough.js +1 -0
- package/dist/output/text/command-format.js +19 -13
- package/dist/output/text/helpers.js +1 -1
- package/dist/output/text/index.js +5 -2
- package/dist/scripts/akm-migrate-node.js +1141 -1237
- package/dist/scripts/akm-migrate.js +1141 -1237
- package/dist/setup/semantic-assets.js +2 -2
- package/dist/setup/steps/connection.js +3 -2
- package/dist/storage/repositories/files-repository.js +181 -0
- package/dist/storage/repositories/index-connection.js +1 -3
- package/dist/storage/repositories/index-entries-repository.js +77 -68
- package/dist/storage/repositories/index-entry-schema.js +16 -25
- package/dist/storage/repositories/index-fts-repository.js +29 -263
- package/dist/storage/repositories/index-meta-repository.js +0 -29
- package/dist/storage/repositories/index-schema.js +115 -122
- package/dist/storage/repositories/index-utility-repository.js +1 -1
- package/dist/storage/repositories/index-vec-repository.js +21 -334
- package/dist/storage/repositories/units-repository.js +510 -0
- package/docs/migration/release-notes/0.9.15.md +34 -36
- package/docs/migration/release-notes/0.9.16.md +110 -0
- package/docs/migration/release-notes/README.md +5 -0
- package/docs/reference/cli.md +93 -87
- package/docs/reference/configuration.md +128 -89
- package/docs/reference/data-and-telemetry.md +2 -1
- package/package.json +1 -1
- package/schemas/akm-config.json +2 -58
- package/dist/indexer/index-db-contention.js +0 -56
- package/dist/indexer/index-rebuild-lock.js +0 -73
- package/dist/indexer/materialize-embeddings.js +0 -771
- package/dist/indexer/passes/dir-staleness.js +0 -161
- package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
|
@@ -23,8 +23,9 @@ const SearchGraphBoostSchema = z
|
|
|
23
23
|
})
|
|
24
24
|
.passthrough();
|
|
25
25
|
/**
|
|
26
|
-
* `search.
|
|
27
|
-
* `akm curate`
|
|
26
|
+
* `search.rerank` (#951, moved from `search.curateRerank` in 0.9.16 — the
|
|
27
|
+
* pass was always meant for `akm search`, not `akm curate`) — an optional
|
|
28
|
+
* cross-encoder rerank pass over search's already-ranked LOCAL stash hits.
|
|
28
29
|
*
|
|
29
30
|
* Deliberately its own small config arm rather than a third member of the
|
|
30
31
|
* `engines` map (`EngineConfigSchema` in ./engines.ts): that union's "llm" /
|
|
@@ -37,26 +38,27 @@ const SearchGraphBoostSchema = z
|
|
|
37
38
|
* connection shape as an LLM engine without inheriting that machinery.
|
|
38
39
|
*
|
|
39
40
|
* `curate_rerank` was removed as a dead `llm.features.*` key in 0.8.0 (no
|
|
40
|
-
* implementation ever sent a request);
|
|
41
|
-
* disabled by default.
|
|
41
|
+
* implementation ever sent a request); 0.9.15 shipped a real implementation
|
|
42
|
+
* wired to curate under `search.curateRerank`, disabled by default; 0.9.16
|
|
43
|
+
* moves it to search and renames the key (no compatibility alias — the old
|
|
44
|
+
* key shipped hours earlier, default-off, so nobody has it meaningfully set).
|
|
42
45
|
*/
|
|
43
|
-
export const
|
|
46
|
+
export const SearchRerankConfigSchema = z
|
|
44
47
|
.object({
|
|
45
48
|
enabled: z.boolean().optional(),
|
|
46
49
|
/** Full URL of the reranker's rerank endpoint, e.g. `http://host:port/rerank`. */
|
|
47
50
|
endpoint: httpUrl.optional(),
|
|
48
51
|
model: nonEmptyString.optional(),
|
|
49
|
-
apiKey: symbolicOrWarnApiKey("search.
|
|
52
|
+
apiKey: symbolicOrWarnApiKey("search.rerank.apiKey").optional(),
|
|
50
53
|
timeoutMs: positiveInt.optional(),
|
|
51
|
-
/** How many of
|
|
54
|
+
/** How many of search's already-ranked LOCAL hits to send to the reranker. Default 8. */
|
|
52
55
|
topN: positiveInt.max(50).optional(),
|
|
53
56
|
})
|
|
54
57
|
.passthrough();
|
|
55
58
|
export const SearchConfigSchema = z
|
|
56
59
|
.object({
|
|
57
|
-
minScore: nonNegativeNumber.optional(),
|
|
58
60
|
defaultExcludeTypes: z.array(nonEmptyString).optional(),
|
|
59
61
|
graphBoost: SearchGraphBoostSchema.optional(),
|
|
60
|
-
|
|
62
|
+
rerank: SearchRerankConfigSchema.optional(),
|
|
61
63
|
})
|
|
62
64
|
.passthrough();
|
package/dist/core/errors.js
CHANGED
|
@@ -18,6 +18,7 @@ const CONFIG_HINTS = {
|
|
|
18
18
|
UNKNOWN_IMPROVE_STRATEGY: "Pass one of the listed strategy names to `--strategy`, or define it under `improve.strategies`. Names are case-sensitive.",
|
|
19
19
|
EXECUTION_NOT_AUTHORIZED: "Change the selected tools or update the machine/user execution policy, then retry.",
|
|
20
20
|
SECRET_REFERENCE_UNRESOLVED: "Check the secret exists (`akm secret list`) and the name after `secret://` matches, or run `akm secret set <name> <value>` to store it.",
|
|
21
|
+
EMBEDDING_VEC_UNAVAILABLE: "Install sqlite-vec for unit-level semantic search, or rely on lexical search until it is available.",
|
|
21
22
|
};
|
|
22
23
|
// Code-review finding: COMPOSITION_INVALID covers several unrelated causes
|
|
23
24
|
// (a rejected with:, a multi-job source, a composition cycle/depth/size
|
|
@@ -82,8 +83,10 @@ const USAGE_HINTS = {
|
|
|
82
83
|
const TRANSIENT_HINTS = {
|
|
83
84
|
RUN_LEASE_HELD: "Wait for the named engine invocation to finish or for the lease to expire, then retry. `akm workflow status <id>` shows the current lease.",
|
|
84
85
|
STATE_DB_CONTENDED: "Another akm process is writing state.db right now. Wait a few seconds and retry; commands that support --skip-if-locked can skip instead of failing.",
|
|
85
|
-
INDEX_DB_CONTENDED: "Another akm process is writing index.db
|
|
86
|
-
|
|
86
|
+
INDEX_DB_CONTENDED: "Another akm process is writing index.db right now. Wait a few seconds and retry — index runs take no rebuild " +
|
|
87
|
+
"lock, so this clears quickly; a scheduled run left alone will simply run again next time.",
|
|
88
|
+
MAINTENANCE_BARRIER_BUSY: "Another akm process is registering a lock or lease right now. Retry shortly, or pass --skip-if-locked on " +
|
|
89
|
+
"scheduled improve runs to skip gracefully instead — workflow run does not treat this code as skippable.",
|
|
87
90
|
IMPROVE_LOCK_HELD: "Another akm improve run holds the whole-run lock right now. Wait for it to finish and retry, or pass --skip-if-locked on scheduled runs.",
|
|
88
91
|
};
|
|
89
92
|
/** Default hint for each NotFoundError code. */
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* Content addressing for text handed to an embedding provider.
|
|
6
|
+
*
|
|
7
|
+
* `hashEmbeddableText` is the ONE hash function every writer of and lookup
|
|
8
|
+
* against provider-bound text must agree on — a stable, deterministic sha256
|
|
9
|
+
* over the exact bytes sent, so a single-byte content change is never
|
|
10
|
+
* confused with unchanged text. Originally lived in
|
|
11
|
+
* `embedding-salvage-repository.ts` (#955); moved here so a pure
|
|
12
|
+
* text-processing module (`src/indexer/units/unit.ts`, #index-units) is not
|
|
13
|
+
* coupled to a storage repository just to reuse it.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
export function hashEmbeddableText(text) {
|
|
17
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
18
|
+
}
|
|
@@ -27,16 +27,18 @@ const heldBarrierContext = new AsyncLocalStorage();
|
|
|
27
27
|
const MAINTENANCE_BARRIER_STALE_AFTER_MS = 5 * 60 * 1000;
|
|
28
28
|
/**
|
|
29
29
|
* The barrier normally holds for one lock-file write — sub-millisecond on
|
|
30
|
-
* any real filesystem. Two akm processes racing to register a lock
|
|
31
|
-
* very same instant (e.g. two `akm index` runs a scheduler
|
|
32
|
-
* back
|
|
33
|
-
* case instead of failing a
|
|
30
|
+
* any real filesystem. Two akm processes racing to register a lock/lease/
|
|
31
|
+
* activity in the very same instant (e.g. two `akm index` runs a scheduler
|
|
32
|
+
* launched back to back, both opening canonical state.db) can still collide
|
|
33
|
+
* on it; retrying briefly resolves that ordinary case instead of failing a
|
|
34
|
+
* legitimate concurrent invocation outright
|
|
34
35
|
* (field follow-up to #956, G1). Bounded short so a genuinely wedged holder
|
|
35
36
|
* still surfaces the busy error promptly rather than making a losing
|
|
36
37
|
* process hang — comfortably above the barrier's normal hold time, well
|
|
37
38
|
* below a length that would make this feel like the blocking lock #872
|
|
38
|
-
* removed. Never applies to
|
|
39
|
-
*
|
|
39
|
+
* removed. Never applies to whatever lock/lease/activity is registered
|
|
40
|
+
* after the barrier releases — that thing's own held/skip/throw/wait
|
|
41
|
+
* policy belongs to its caller, not to the barrier (#872).
|
|
40
42
|
*/
|
|
41
43
|
const MAINTENANCE_BARRIER_BUSY_RETRY_BOUND_MS = 1_500;
|
|
42
44
|
let busyRetryBoundMsForTests;
|
package/dist/core/paths.js
CHANGED
|
@@ -234,17 +234,6 @@ export function getDbPath(env = process.env) {
|
|
|
234
234
|
export function getIndexWriterLockPath() {
|
|
235
235
|
return path.join(getDataDir(), "index.db.write.lock");
|
|
236
236
|
}
|
|
237
|
-
/**
|
|
238
|
-
* Path to the opt-in, PID-liveness-only rebuild lock an explicit `akm index`
|
|
239
|
-
* run acquires (#956). Distinct from {@link getIndexWriterLockPath}'s
|
|
240
|
-
* `index.db.write.lock`, which is the asset-mutation lease and unrelated to
|
|
241
|
-
* indexing since #872 — this lock never blocks and is never required, it
|
|
242
|
-
* only lets a scheduled/opportunistic `akm index --skip-if-locked` step
|
|
243
|
-
* aside instead of contending with a run already in progress.
|
|
244
|
-
*/
|
|
245
|
-
export function getIndexRebuildLockPath() {
|
|
246
|
-
return path.join(getDataDir(), "index.rebuild.lock");
|
|
247
|
-
}
|
|
248
237
|
export function getMaintenanceBarrierPath() {
|
|
249
238
|
return path.join(getDataDir(), "maintenance.barrier.lock");
|
|
250
239
|
}
|
package/dist/core/run-lock.js
CHANGED
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
4
|
/**
|
|
5
5
|
* PID-liveness-only run lock — the shared mechanics behind `akm improve`'s
|
|
6
|
-
* whole-run lock (`commands/improve/locks.ts`)
|
|
7
|
-
* (`indexer/index-rebuild-lock.ts`, #956)
|
|
6
|
+
* whole-run lock (`commands/improve/locks.ts`). Originally also backed the
|
|
7
|
+
* index rebuild lock (`indexer/index-rebuild-lock.ts`, #956); index-redesign
|
|
8
|
+
* deleted that consumer (every index write is now a short, idempotent,
|
|
9
|
+
* content-addressed transaction, so there is no rebuild to serialize), and
|
|
10
|
+
* `improve`'s lock is this module's only caller today.
|
|
8
11
|
*
|
|
9
12
|
* No `staleAfterMs`: only a verifiably dead holder is ever reclaimed. This is
|
|
10
13
|
* the #872 lesson encoded at the mechanics layer so every future caller gets
|
|
@@ -1200,12 +1200,33 @@ export function getStateMigrationSafety(migrationId) {
|
|
|
1200
1200
|
* Delegates to the shared SQLite migration engine; state.db has no
|
|
1201
1201
|
* pre-versioning bootstrap step, so no `bootstrap` hook is passed.
|
|
1202
1202
|
*
|
|
1203
|
+
* `freshDatabase` is the one exception to "single transaction per migration":
|
|
1204
|
+
* the entire registry is locked through the final migration and applied as
|
|
1205
|
+
* ONE transaction (mirroring the existing-unversioned prefix lock below).
|
|
1206
|
+
* Two akm processes can race to create the same brand-new state.db
|
|
1207
|
+
* (`openStateDatabase`'s file-reservation only decides who creates the file,
|
|
1208
|
+
* not who finishes initializing it first); with per-migration commits, the
|
|
1209
|
+
* loser could open its own connection between two of the winner's commits,
|
|
1210
|
+
* see a real-but-incomplete ledger, and — correctly believing this is an
|
|
1211
|
+
* ordinary existing database needing to catch up on pending migrations —
|
|
1212
|
+
* refuse historical-destructive migration 018 with the same message a
|
|
1213
|
+
* genuine legacy database gets. Applying every migration for a fresh
|
|
1214
|
+
* database atomically removes that partially-migrated state from view
|
|
1215
|
+
* entirely: a concurrent opener now only ever observes "nothing committed
|
|
1216
|
+
* yet" (treated as fresh) or "fully current" (nothing left to apply), never
|
|
1217
|
+
* the in-between. Safe to lose on a crash mid-bootstrap: a fresh database
|
|
1218
|
+
* has no prior data to preserve, so rolling back to nothing and letting the
|
|
1219
|
+
* next open retry from scratch is strictly fine.
|
|
1220
|
+
*
|
|
1203
1221
|
* Called automatically by `openStateDatabase()`.
|
|
1204
1222
|
*/
|
|
1205
1223
|
export function runMigrations(db, options) {
|
|
1206
1224
|
const initialMigration = STATE_MIGRATIONS[0];
|
|
1207
1225
|
if (!initialMigration)
|
|
1208
1226
|
throw new Error("State migration registry has no initial migration.");
|
|
1227
|
+
const finalMigration = STATE_MIGRATIONS[STATE_MIGRATIONS.length - 1];
|
|
1228
|
+
if (!finalMigration)
|
|
1229
|
+
throw new Error("State migration registry has no final migration.");
|
|
1209
1230
|
let existingUnversionedSnapshotPrepared = false;
|
|
1210
1231
|
const prepareExistingUnversionedState = (lockedDb) => {
|
|
1211
1232
|
if (existingUnversionedSnapshotPrepared)
|
|
@@ -1228,7 +1249,11 @@ export function runMigrations(db, options) {
|
|
|
1228
1249
|
existingUnversionedSnapshotPrepared = true;
|
|
1229
1250
|
};
|
|
1230
1251
|
runSqliteMigrations(db, STATE_MIGRATIONS, {
|
|
1231
|
-
lockInitialMigrationPrefixThrough: options?.existingUnversionedDatabase
|
|
1252
|
+
lockInitialMigrationPrefixThrough: options?.existingUnversionedDatabase
|
|
1253
|
+
? "002-task-history-per-run"
|
|
1254
|
+
: options?.freshDatabase
|
|
1255
|
+
? finalMigration.id
|
|
1256
|
+
: undefined,
|
|
1232
1257
|
beforeLedgerInitializationLocked(lockedDb) {
|
|
1233
1258
|
if (options?.freshDatabase)
|
|
1234
1259
|
return;
|
package/dist/core/state-db.js
CHANGED
|
@@ -442,10 +442,24 @@ export function openStateDatabase(dbPath, options) {
|
|
|
442
442
|
});
|
|
443
443
|
try {
|
|
444
444
|
preflight.exec("PRAGMA busy_timeout = 30000");
|
|
445
|
-
|
|
445
|
+
// Both reads below must observe ONE WAL snapshot. Each is its own
|
|
446
|
+
// statement, and without an explicit transaction SQLite auto-commits
|
|
447
|
+
// each separately, so they can see two different snapshots. A sibling
|
|
448
|
+
// process's fresh bootstrap is now one all-or-nothing transaction, so
|
|
449
|
+
// a commit landing between the two reads showed an empty ledger to the
|
|
450
|
+
// first and the sibling's freshly created tables to the second — the
|
|
451
|
+
// signature of a genuine legacy unversioned database, which this
|
|
452
|
+
// preflight then refused. Pinned to one snapshot, either nothing the
|
|
453
|
+
// sibling did is visible (empty ledger AND no tables, correctly fresh)
|
|
454
|
+
// or all of it is (a current ledger, so the refusal is never reached).
|
|
455
|
+
// A real legacy database, racing no one, reads exactly as before.
|
|
456
|
+
const { ledger, hasNoOtherTables } = preflight.transaction(() => ({
|
|
457
|
+
ledger: assertMigrationLedger(preflight, STATE_MIGRATIONS),
|
|
458
|
+
hasNoOtherTables: unversionedDatabaseHasNoTables(preflight),
|
|
459
|
+
}))();
|
|
446
460
|
warnNewerStateLedger(ledger);
|
|
447
461
|
existingUnversionedDatabase = ledger.migrationIds.length === 0;
|
|
448
|
-
if (existingUnversionedDatabase &&
|
|
462
|
+
if (existingUnversionedDatabase && hasNoOtherTables) {
|
|
449
463
|
existingUnversionedDatabase = false;
|
|
450
464
|
treatUnversionedAsFresh = true;
|
|
451
465
|
}
|
|
@@ -539,7 +553,21 @@ export function openStateDatabase(dbPath, options) {
|
|
|
539
553
|
if (freshReservation)
|
|
540
554
|
closeFileIdentity(freshReservation);
|
|
541
555
|
releaseActivity?.();
|
|
542
|
-
|
|
556
|
+
// The migration engine's own writer-lock retry (sqlite-migrations.ts's
|
|
557
|
+
// `withImmediateWriteLock`, used by every migration this open can run,
|
|
558
|
+
// including a from-empty first open) throws the raw driver error after
|
|
559
|
+
// its own retry budget, not a `TransientError` — only
|
|
560
|
+
// `beginImmediateTransaction` below does that reclassification, and nothing
|
|
561
|
+
// upstream of it re-wraps a raw SQLITE_BUSY/LOCKED that surfaces from
|
|
562
|
+
// deeper in the open/migrate sequence (field follow-up: a from-empty
|
|
563
|
+
// first open racing a concurrent opener can still hit this under load).
|
|
564
|
+
// Reclassify uniformly here so the contract this function promises --
|
|
565
|
+
// contention is reported as `STATE_DB_CONTENDED` (exit 75), never a raw
|
|
566
|
+
// driver message (exit 70) -- holds regardless of which internal step
|
|
567
|
+
// the contention was observed at. Anything not contention-shaped
|
|
568
|
+
// (a genuine legacy-database refusal, corruption, a real schema error)
|
|
569
|
+
// rethrows exactly as raised.
|
|
570
|
+
throwBeginFailure(error, "state");
|
|
543
571
|
}
|
|
544
572
|
}
|
|
545
573
|
/**
|
|
@@ -690,35 +718,43 @@ function sleepSyncMs(ms) {
|
|
|
690
718
|
return;
|
|
691
719
|
sleepSync(ms);
|
|
692
720
|
}
|
|
693
|
-
/**
|
|
694
|
-
* Open, but deliberately do not finish, an immediate transaction.
|
|
695
|
-
*
|
|
696
|
-
* This is the split-phase counterpart to {@link withImmediateTransaction} for
|
|
697
|
-
* the source-update coordinator: index finalization must mutate state.db in a
|
|
698
|
-
* transaction that remains pending until content, lockfile, and index
|
|
699
|
-
* publication have all succeeded. The caller that asked for this split phase
|
|
700
|
-
* owns the matching COMMIT/ROLLBACK.
|
|
701
|
-
*/
|
|
702
721
|
/**
|
|
703
722
|
* Reclassify an exhausted-retry BEGIN failure that is still contention-shaped
|
|
704
|
-
* (#948) into a `TransientError
|
|
705
|
-
*
|
|
706
|
-
*
|
|
707
|
-
*
|
|
708
|
-
*
|
|
709
|
-
*
|
|
710
|
-
*
|
|
711
|
-
*
|
|
723
|
+
* (#948) into a `TransientError`, mirroring the RUN_LEASE_HELD precedent
|
|
724
|
+
* (`WorkflowRunsRepository.acquireEngineLease`): the driver text is accurate
|
|
725
|
+
* but unhelpful (`{"ok":false,"error":"database is locked"}`, exit
|
|
726
|
+
* 70/INTERNAL) — this instead reads as a retryable-shortly signal (exit 75,
|
|
727
|
+
* sysexits EX_TEMPFAIL — #948 addendum) with the original error preserved as
|
|
728
|
+
* `cause` for `--verbose`/debugging. A genuinely unrelated error (not
|
|
729
|
+
* contention-shaped) is rethrown exactly as raised, never reclassified.
|
|
730
|
+
*
|
|
731
|
+
* `dbKind` (field follow-up to #956) picks the reported identity: `"state"`
|
|
732
|
+
* (default, unchanged text) yields `STATE_DB_CONTENDED`; `"index"` yields
|
|
733
|
+
* `INDEX_DB_CONTENDED` with index.db's own message, mirroring
|
|
734
|
+
* `reclassifyIndexDbContention`'s text (`src/indexer/indexer.ts`) so a caller
|
|
735
|
+
* that reaches this helper directly and one that only reclassifies a raw
|
|
736
|
+
* driver error report the same thing.
|
|
712
737
|
*/
|
|
713
|
-
function throwBeginFailure(err) {
|
|
738
|
+
function throwBeginFailure(err, dbKind) {
|
|
714
739
|
if (isSqliteContentionError(err)) {
|
|
715
|
-
const contended =
|
|
740
|
+
const contended = dbKind === "index"
|
|
741
|
+
? new TransientError("akm's index database is busy (another akm process is writing it); retry shortly.", "INDEX_DB_CONTENDED")
|
|
742
|
+
: new TransientError("akm's state database is busy (another akm process is writing it); retry shortly.", "STATE_DB_CONTENDED");
|
|
716
743
|
contended.cause = err;
|
|
717
744
|
throw contended;
|
|
718
745
|
}
|
|
719
746
|
throw err;
|
|
720
747
|
}
|
|
721
|
-
|
|
748
|
+
/**
|
|
749
|
+
* Open, but deliberately do not finish, an immediate transaction.
|
|
750
|
+
*
|
|
751
|
+
* This is the split-phase counterpart to {@link withImmediateTransaction} for
|
|
752
|
+
* the source-update coordinator: index finalization must mutate state.db in a
|
|
753
|
+
* transaction that remains pending until content, lockfile, and index
|
|
754
|
+
* publication have all succeeded. The caller that asked for this split phase
|
|
755
|
+
* owns the matching COMMIT/ROLLBACK.
|
|
756
|
+
*/
|
|
757
|
+
export function beginImmediateTransaction(db, dbKind = "state") {
|
|
722
758
|
if (db.inTransaction) {
|
|
723
759
|
throw new Error("beginImmediateTransaction requires a connection with no active transaction");
|
|
724
760
|
}
|
|
@@ -745,12 +781,12 @@ export function beginImmediateTransaction(db) {
|
|
|
745
781
|
sleepSyncMs(2 ** (attempt - 1));
|
|
746
782
|
continue;
|
|
747
783
|
}
|
|
748
|
-
throwBeginFailure(err);
|
|
784
|
+
throwBeginFailure(err, dbKind);
|
|
749
785
|
}
|
|
750
786
|
}
|
|
751
|
-
throwBeginFailure(lastBeginErr);
|
|
787
|
+
throwBeginFailure(lastBeginErr, dbKind);
|
|
752
788
|
}
|
|
753
|
-
export function withImmediateTransaction(db, fn) {
|
|
789
|
+
export function withImmediateTransaction(db, fn, dbKind = "state") {
|
|
754
790
|
// Re-entrancy guard (issue #686): if a transaction is already open on this
|
|
755
791
|
// connection (e.g. a nested withImmediateTransaction call inside an outer
|
|
756
792
|
// frame's fn), join it — run fn directly with no BEGIN/COMMIT/ROLLBACK of
|
|
@@ -761,7 +797,7 @@ export function withImmediateTransaction(db, fn) {
|
|
|
761
797
|
if (db.inTransaction) {
|
|
762
798
|
return fn();
|
|
763
799
|
}
|
|
764
|
-
beginImmediateTransaction(db);
|
|
800
|
+
beginImmediateTransaction(db, dbKind);
|
|
765
801
|
try {
|
|
766
802
|
const result = fn();
|
|
767
803
|
if (!db.inTransaction) {
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
import { getConfigPath } from "../core/paths.js";
|
|
5
|
+
import { isVerbose } from "../core/warn.js";
|
|
6
|
+
import { embedBatch } from "../llm/embedder.js";
|
|
7
|
+
import { probeProviderLimits } from "../llm/embedders/provider-limits.js";
|
|
8
|
+
import { describeEmbeddingCredential, hasRemoteEndpoint, normalizeEmbeddingEndpoint, } from "../llm/embedders/remote.js";
|
|
9
|
+
import { getMeta, setMeta } from "../storage/repositories/index-meta-repository.js";
|
|
10
|
+
import { SQLITE_CHUNK_SIZE } from "../storage/repositories/index-sql.js";
|
|
11
|
+
import { isVecAvailable } from "../storage/repositories/index-vec-repository.js";
|
|
12
|
+
import { dropOtherIdentities, listMissingHashes, upsertUnitVectors } from "../storage/repositories/units-repository.js";
|
|
13
|
+
import { deriveObservedEmbeddingIdentity } from "./embedding-identity.js";
|
|
14
|
+
/**
|
|
15
|
+
* Failure threshold, within the recent window below, that stops dispatching
|
|
16
|
+
* further provider batches. Mirrors materialize-embeddings.ts's own
|
|
17
|
+
* (unexported) `CIRCUIT_BREAKER_THRESHOLD`, #954 — reimplemented here at the
|
|
18
|
+
* same value rather than imported, since that file is private and slated for
|
|
19
|
+
* deletion by B5; the underlying stop-dispatch MECHANISM (`onSkip` returning
|
|
20
|
+
* `false`) is still the real `RemoteEmbedder`'s, reused unmodified. Two
|
|
21
|
+
* independent streaks share it: single-document failures (a multi-document
|
|
22
|
+
* timeout is not yet evidence of a dead endpoint — `RemoteEmbedder` retries
|
|
23
|
+
* and splits it smaller before ever reporting it this small), or network
|
|
24
|
+
* errors at ANY size (never retried, trusted immediately). Storage-write
|
|
25
|
+
* failures (E5b — `upsertUnitVectors`'s own per-row result) feed the SAME two
|
|
26
|
+
* streaks: a sustained STORAGE failure (contention, permissions, a full
|
|
27
|
+
* disk) must stop paying for provider requests just as surely as a
|
|
28
|
+
* sustained PROVIDER failure, even while the provider itself keeps
|
|
29
|
+
* succeeding.
|
|
30
|
+
*/
|
|
31
|
+
const CIRCUIT_BREAKER_THRESHOLD = 3;
|
|
32
|
+
/**
|
|
33
|
+
* Recent-history window (in settled batch-starts) the two streaks above are
|
|
34
|
+
* evaluated over, in place of a plain "reset to zero on any success" counter
|
|
35
|
+
* (round-2 field finding): with concurrent dispatch (default 2, up to 16)
|
|
36
|
+
* outcomes settle out of dispatch order, so a degraded endpoint failing MOST
|
|
37
|
+
* requests never tripped the breaker as long as occasional successes
|
|
38
|
+
* interleaved — reproduced with a 67% failure rate dispatching the entire
|
|
39
|
+
* pending set. `CIRCUIT_BREAKER_THRESHOLD` failures within the last
|
|
40
|
+
* `CIRCUIT_BREAKER_WINDOW` settled batch-starts of a streak's own kind (see
|
|
41
|
+
* {@link pushBreakerOutcome}) trips it: a genuinely dead endpoint (no
|
|
42
|
+
* successes at all) still trips in exactly `CIRCUIT_BREAKER_THRESHOLD`
|
|
43
|
+
* batches, same as before; a single success now only AGES a failure out of
|
|
44
|
+
* the window over time rather than erasing the whole run's evidence at once.
|
|
45
|
+
*/
|
|
46
|
+
const CIRCUIT_BREAKER_WINDOW = CIRCUIT_BREAKER_THRESHOLD * 2;
|
|
47
|
+
/** Record one settled batch-start's outcome into a breaker streak's window, capped at {@link CIRCUIT_BREAKER_WINDOW}. */
|
|
48
|
+
function pushBreakerOutcome(window, isFailure) {
|
|
49
|
+
window.push(isFailure);
|
|
50
|
+
if (window.length > CIRCUIT_BREAKER_WINDOW)
|
|
51
|
+
window.shift();
|
|
52
|
+
}
|
|
53
|
+
/** Failures currently recorded in a breaker streak's window. */
|
|
54
|
+
function breakerFailureCount(window) {
|
|
55
|
+
return window.reduce((n, isFailure) => n + (isFailure ? 1 : 0), 0);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Prefix of the per-committed-batch progress line (`"${DRAIN_BATCH_PROGRESS_PREFIX}N: …"`,
|
|
59
|
+
* emitted once per provider batch this call commits). Exported so a caller
|
|
60
|
+
* juggling several `onProgress` sources (`stash-cli.ts`'s `akm index`) can
|
|
61
|
+
* recognize — and, outside `--verbose`, suppress — this specific
|
|
62
|
+
* high-frequency line by prefix rather than re-deriving its own copy of the
|
|
63
|
+
* pattern (#954).
|
|
64
|
+
*/
|
|
65
|
+
export const DRAIN_BATCH_PROGRESS_PREFIX = "[drain] batch ";
|
|
66
|
+
function throwIfAborted(signal) {
|
|
67
|
+
if (signal?.aborted) {
|
|
68
|
+
throw signal.reason instanceof Error ? signal.reason : new Error("drain interrupted");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Every distinct unit hash the index currently knows about, regardless of identity. */
|
|
72
|
+
function selectAllUnitHashes(db) {
|
|
73
|
+
return db.prepare("SELECT unit_hash FROM unit_texts ORDER BY unit_hash").all().map((row) => row.unit_hash);
|
|
74
|
+
}
|
|
75
|
+
/** `unit_hash -> text` for exactly `hashes`, chunked to respect SQLite's bound-parameter limit. */
|
|
76
|
+
function fetchUnitTexts(db, hashes) {
|
|
77
|
+
const texts = new Map();
|
|
78
|
+
for (let offset = 0; offset < hashes.length; offset += SQLITE_CHUNK_SIZE) {
|
|
79
|
+
const chunk = hashes.slice(offset, offset + SQLITE_CHUNK_SIZE);
|
|
80
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
81
|
+
const rows = db
|
|
82
|
+
.prepare(`SELECT unit_hash, text FROM unit_texts WHERE unit_hash IN (${placeholders})`)
|
|
83
|
+
.all(...chunk);
|
|
84
|
+
for (const row of rows)
|
|
85
|
+
texts.set(row.unit_hash, row.text);
|
|
86
|
+
}
|
|
87
|
+
return texts;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Effective embedding config and request packing for this drain (index
|
|
91
|
+
* redesign, B5 — replaces the retired `embedding.maxTokens`/`batchSize`/
|
|
92
|
+
* `contextLength` config keys): `concurrency` (in-flight requests) defaults
|
|
93
|
+
* to the provider's OWN observed slot count when `embedding.concurrency`
|
|
94
|
+
* itself leaves it unset (`probeProviderLimits` already applies that same
|
|
95
|
+
* override to `slots`). The request TOKEN WINDOW, chars-per-token ratio, and
|
|
96
|
+
* Ollama `num_ctx` are no longer config fields at all — they are threaded
|
|
97
|
+
* into `RemoteEmbedder.embedBatch` as `packing`, sourced straight from the
|
|
98
|
+
* same probe: `windowTokens` for the per-request budget, `charsPerToken` for
|
|
99
|
+
* the calibrated per-text token estimate, and `windowTokens` again for
|
|
100
|
+
* Ollama's `num_ctx` when `source === "ollama"`. `windowIsKnown`
|
|
101
|
+
* (`source !== "default"`) gates `RemoteEmbedder`'s same-run adaptive
|
|
102
|
+
* shrink: a provider that reports nothing about its own context size still
|
|
103
|
+
* gets that corrective, but a probed, authoritative window does not need it
|
|
104
|
+
* second-guessed.
|
|
105
|
+
*/
|
|
106
|
+
async function resolveEmbeddingPacking(config, signal) {
|
|
107
|
+
const base = config.embedding ?? {};
|
|
108
|
+
const limits = await probeProviderLimits(base, { signal });
|
|
109
|
+
return {
|
|
110
|
+
embeddingConfig: { ...base, concurrency: base.concurrency ?? limits.slots },
|
|
111
|
+
packing: {
|
|
112
|
+
tokenBudget: limits.windowTokens,
|
|
113
|
+
charsPerToken: limits.charsPerToken,
|
|
114
|
+
windowIsKnown: limits.source !== "default",
|
|
115
|
+
ollamaNumCtx: limits.source === "ollama" ? limits.windowTokens : undefined,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* #953 field gap, ported from the deleted `materialize-embeddings.ts`
|
|
121
|
+
* (`git show fc711fd6^:src/indexer/materialize-embeddings.ts`): a keyless
|
|
122
|
+
* request against a remote embedding endpoint could not be reproduced in the
|
|
123
|
+
* lab — every `RemoteEmbedder` path already resolves `secret://` through one
|
|
124
|
+
* boundary, so a keyless request can only mean `embedding.apiKey` was absent
|
|
125
|
+
* from the config THIS run loaded. The actionable outcome is a
|
|
126
|
+
* self-diagnosing run, not a fix: one default-level line, emitted once
|
|
127
|
+
* before the first provider request this call makes, naming the endpoint,
|
|
128
|
+
* model, and credential SOURCE (never the value) so a field run can compare
|
|
129
|
+
* it against what the gateway actually saw. A local (non-remote) endpoint,
|
|
130
|
+
* or a call with nothing pending, has nothing to diagnose and stays silent.
|
|
131
|
+
*/
|
|
132
|
+
function emitCredentialDiagnostic(config, onProgress) {
|
|
133
|
+
if (!onProgress || !hasRemoteEndpoint(config.embedding ?? {}))
|
|
134
|
+
return;
|
|
135
|
+
const endpoint = normalizeEmbeddingEndpoint(config.embedding?.endpoint ?? "");
|
|
136
|
+
const credential = describeEmbeddingCredential(config.embedding?.apiKey);
|
|
137
|
+
const configFileSuffix = isVerbose() ? `; config: ${getConfigPath()}` : "";
|
|
138
|
+
onProgress(`[embed] endpoint ${endpoint}, model ${config.embedding?.model ?? "unknown"}; credential: ${credential}${configFileSuffix}`);
|
|
139
|
+
}
|
|
140
|
+
function formatDoneLine(counts) {
|
|
141
|
+
return (`[drain] done: ${counts.pending} pending, ${counts.embedded} embedded, ${counts.failed} failed, ` +
|
|
142
|
+
`${counts.skipped} skipped (identity: ${counts.identity ?? "unknown"})`);
|
|
143
|
+
}
|
|
144
|
+
export async function drainEmbeddingQueue(db, config, opts = {}) {
|
|
145
|
+
throwIfAborted(opts.signal);
|
|
146
|
+
let identity = getMeta(db, "embeddingIdentity") ?? null;
|
|
147
|
+
if (config.semanticSearchMode === "off") {
|
|
148
|
+
return { pending: 0, embedded: 0, failed: 0, skipped: 0, identity };
|
|
149
|
+
}
|
|
150
|
+
const candidateHashes = opts.onlyHashes ? [...new Set(opts.onlyHashes)] : selectAllUnitHashes(db);
|
|
151
|
+
const missingHashes = identity ? listMissingHashes(db, candidateHashes, identity) : candidateHashes;
|
|
152
|
+
const pending = missingHashes.length;
|
|
153
|
+
const emitDone = (counts) => {
|
|
154
|
+
opts.onProgress?.(formatDoneLine(counts));
|
|
155
|
+
return counts;
|
|
156
|
+
};
|
|
157
|
+
// upsertUnitVectors is a no-op without sqlite-vec (units-repository.ts), so
|
|
158
|
+
// embedding the pending set here would just throw every vector away and
|
|
159
|
+
// leave it "missing" again for the next call — pure wasted provider
|
|
160
|
+
// traffic. `akmIndex`'s verification reports the missing extension as
|
|
161
|
+
// blocked; this stays silent. `pending` is computed above so the done line and `akm
|
|
162
|
+
// index status` stay truthful even though nothing was attempted.
|
|
163
|
+
if (!isVecAvailable(db)) {
|
|
164
|
+
return emitDone({ pending, embedded: 0, failed: 0, skipped: pending, identity });
|
|
165
|
+
}
|
|
166
|
+
if (pending === 0) {
|
|
167
|
+
return emitDone({ pending: 0, embedded: 0, failed: 0, skipped: 0, identity });
|
|
168
|
+
}
|
|
169
|
+
const boundedHashes = opts.limit !== undefined ? missingHashes.slice(0, opts.limit) : missingHashes;
|
|
170
|
+
const textByHash = fetchUnitTexts(db, boundedHashes);
|
|
171
|
+
// A hash in `unit_texts` should always resolve to a row (it was just read
|
|
172
|
+
// from that same table above), but a missing row is dropped rather than
|
|
173
|
+
// sent to the provider as `undefined` text.
|
|
174
|
+
const orderedHashes = boundedHashes.filter((hash) => textByHash.has(hash));
|
|
175
|
+
const texts = orderedHashes.map((hash) => textByHash.get(hash));
|
|
176
|
+
if (texts.length === 0) {
|
|
177
|
+
return emitDone({ pending, embedded: 0, failed: 0, skipped: 0, identity });
|
|
178
|
+
}
|
|
179
|
+
emitCredentialDiagnostic(config, opts.onProgress);
|
|
180
|
+
const { embeddingConfig, packing } = await resolveEmbeddingPacking(config, opts.signal);
|
|
181
|
+
let embedded = 0;
|
|
182
|
+
let failed = 0;
|
|
183
|
+
let batchNumber = 0;
|
|
184
|
+
// Two independent circuit-breaker streaks (single-document failures,
|
|
185
|
+
// network errors at any size) — see CIRCUIT_BREAKER_WINDOW above.
|
|
186
|
+
const singleDocFailureWindow = [];
|
|
187
|
+
const networkErrorFailureWindow = [];
|
|
188
|
+
// Whether this CALL has already decided the identity its first committed
|
|
189
|
+
// row observed (E1) — adoption happens at most once per call; see the
|
|
190
|
+
// module doc comment and the identity block in `onBatch` below.
|
|
191
|
+
let identityDecidedThisCall = false;
|
|
192
|
+
const onSkip = (skip) => {
|
|
193
|
+
failed++;
|
|
194
|
+
if (!skip.batchStart)
|
|
195
|
+
return undefined;
|
|
196
|
+
if (skip.reason === "context-window-exceeded") {
|
|
197
|
+
// Proves the provider IS reachable; not evidence of a dead endpoint.
|
|
198
|
+
singleDocFailureWindow.length = 0;
|
|
199
|
+
networkErrorFailureWindow.length = 0;
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
pushBreakerOutcome(singleDocFailureWindow, skip.batchSize === 1);
|
|
203
|
+
pushBreakerOutcome(networkErrorFailureWindow, skip.failureKind === "network-error");
|
|
204
|
+
if (breakerFailureCount(singleDocFailureWindow) >= CIRCUIT_BREAKER_THRESHOLD ||
|
|
205
|
+
breakerFailureCount(networkErrorFailureWindow) >= CIRCUIT_BREAKER_THRESHOLD) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
return undefined;
|
|
209
|
+
};
|
|
210
|
+
const onBatch = (indices, embeddings, model, outcome) => {
|
|
211
|
+
// "retrying"/"budget-lowered" are in-flight notices for a batch that has
|
|
212
|
+
// not settled yet (see EmbeddingBatchOutcome) — nothing to commit or
|
|
213
|
+
// count, and not a distinct "batch" for the one-line-per-batch contract.
|
|
214
|
+
if (outcome?.outcome === "retrying" || outcome?.outcome === "budget-lowered")
|
|
215
|
+
return;
|
|
216
|
+
const rows = [];
|
|
217
|
+
for (let k = 0; k < indices.length; k++) {
|
|
218
|
+
const embedding = embeddings[k];
|
|
219
|
+
if (!embedding)
|
|
220
|
+
continue;
|
|
221
|
+
const learned = deriveObservedEmbeddingIdentity(config.embedding, model, embedding.length);
|
|
222
|
+
if (!identityDecidedThisCall) {
|
|
223
|
+
// This call's FIRST committed row decides the identity it adopts
|
|
224
|
+
// (E1) — learned once, not re-derived per batch: a provider whose
|
|
225
|
+
// responses alternate between models WITHIN one call (a
|
|
226
|
+
// load-balanced gateway, a blue/green rollout behind one endpoint)
|
|
227
|
+
// must not thrash the store between them (embed, delete, re-embed,
|
|
228
|
+
// never converging). A genuine model change is still caught, just
|
|
229
|
+
// not until the NEXT call's own first batch observes it and purges
|
|
230
|
+
// whatever this call left behind.
|
|
231
|
+
identityDecidedThisCall = true;
|
|
232
|
+
if (learned && learned !== identity) {
|
|
233
|
+
identity = learned;
|
|
234
|
+
setMeta(db, "embeddingIdentity", identity);
|
|
235
|
+
dropOtherIdentities(db, identity, embedding.length);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const currentIdentity = identity;
|
|
239
|
+
if (currentIdentity === null || learned !== currentIdentity) {
|
|
240
|
+
// Either nothing has ever been learned, or a LATER batch this same
|
|
241
|
+
// call reported an identity different from the one already adopted
|
|
242
|
+
// — left missing rather than switched to; it becomes "missing"
|
|
243
|
+
// again under whatever identity this call is using, and a later
|
|
244
|
+
// call, whose own first batch observes it, picks it up. Counted in
|
|
245
|
+
// `skipped` below (attempted minus embedded minus failed), not
|
|
246
|
+
// `embedded`.
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const hash = orderedHashes[indices[k]];
|
|
250
|
+
if (hash)
|
|
251
|
+
rows.push({ hash, identity: currentIdentity, vector: embedding });
|
|
252
|
+
}
|
|
253
|
+
let storageBreakerTripped = false;
|
|
254
|
+
if (rows.length > 0) {
|
|
255
|
+
// upsertUnitVectors commits each row in its own transaction — this IS
|
|
256
|
+
// "each provider batch commits durably" (a wrapping db.transaction()
|
|
257
|
+
// here would only nest as an unobservable SAVEPOINT inside it, per the
|
|
258
|
+
// ambient-transaction hazard materialize-embeddings.ts's own drift
|
|
259
|
+
// guard documents), now made even finer-grained so one malformed
|
|
260
|
+
// vector in a batch (e.g. a width mismatch) can't roll back the rest
|
|
261
|
+
// of an otherwise-good response.
|
|
262
|
+
const result = upsertUnitVectors(db, rows);
|
|
263
|
+
embedded += result.inserted;
|
|
264
|
+
failed += result.failed;
|
|
265
|
+
if (result.failed > 0) {
|
|
266
|
+
// E5b: a write failure is just as much evidence of a broken run as
|
|
267
|
+
// a provider failure — a sustained STORAGE failure (contention,
|
|
268
|
+
// permissions, a full disk) must not keep dispatching every
|
|
269
|
+
// remaining batch to a perfectly healthy provider at full cost
|
|
270
|
+
// while every write silently fails. One event per committed batch
|
|
271
|
+
// (the "count batch starts, not documents" rule onSkip already
|
|
272
|
+
// applies to provider failures), fed into the SAME two streaks.
|
|
273
|
+
pushBreakerOutcome(singleDocFailureWindow, true);
|
|
274
|
+
pushBreakerOutcome(networkErrorFailureWindow, true);
|
|
275
|
+
if (breakerFailureCount(singleDocFailureWindow) >= CIRCUIT_BREAKER_THRESHOLD ||
|
|
276
|
+
breakerFailureCount(networkErrorFailureWindow) >= CIRCUIT_BREAKER_THRESHOLD) {
|
|
277
|
+
storageBreakerTripped = true;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (embeddings.some((embedding) => embedding !== undefined)) {
|
|
282
|
+
pushBreakerOutcome(singleDocFailureWindow, false);
|
|
283
|
+
pushBreakerOutcome(networkErrorFailureWindow, false);
|
|
284
|
+
}
|
|
285
|
+
batchNumber++;
|
|
286
|
+
if (opts.onProgress) {
|
|
287
|
+
const docCount = outcome?.docCount ?? indices.length;
|
|
288
|
+
const label = outcome && outcome.outcome !== "stored" ? `failed: ${outcome.reason ?? "unknown"}` : `${rows.length} stored`;
|
|
289
|
+
opts.onProgress(`${DRAIN_BATCH_PROGRESS_PREFIX}${batchNumber}: ${docCount} docs → ${label}`);
|
|
290
|
+
}
|
|
291
|
+
if (storageBreakerTripped) {
|
|
292
|
+
// onBatch has no `false`-return stop-dispatch contract the way onSkip
|
|
293
|
+
// does (a storage failure can trip this even when the provider itself
|
|
294
|
+
// keeps succeeding, so onSkip is never called at all) — this reuses
|
|
295
|
+
// RemoteEmbedder's own documented mechanism instead: a throw from
|
|
296
|
+
// onBatch stops the pool from dispatching any further provider
|
|
297
|
+
// request, and is rethrown once every in-flight batch has settled.
|
|
298
|
+
throw new Error(`Circuit breaker: ${CIRCUIT_BREAKER_THRESHOLD} storage write failures while embedding; stopping further provider requests this call.`);
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
await embedBatch(texts, embeddingConfig, opts.signal, onSkip, onBatch, packing);
|
|
302
|
+
throwIfAborted(opts.signal);
|
|
303
|
+
const attempted = texts.length;
|
|
304
|
+
const skipped = Math.max(0, attempted - embedded - failed);
|
|
305
|
+
return emitDone({ pending, embedded, failed, skipped, identity });
|
|
306
|
+
}
|