feat(8.0): #18 coordinated migration LOCK — block-and-queue the 7.x→8.0 auto-upgrade

Replaces rc.8's no-freeze deference with an automatic, observable, coordinated
migration LOCK (David's reversal: "unknown/halfway states are more dangerous
than blocking"). While a native provider runs its one-time 7.x→8.0
rebuild-from-canonical (`isMigrating()===true`), brainy blocks/queues data-plane
reads AND writes so no operation ever touches a half-built index — closing the
three seam-map gaps (lost mid-rebuild writes, degraded reads, read-time rebuild).

Mechanism (one choke point):
- awaitMigrationLock() at ensureInitialized() covers all ~40 data-plane methods;
  a non-migrating brain pays one boolean check. Polls isMigrating() at 250ms;
  after migrationWaitTimeoutMs (default 30s) throws a retryable, exported
  MigrationInProgressError. The timeout bounds the CALLER'S WAIT, not the
  migration — the rebuild is unbounded and never interrupted.
- init() awaits the lock before VFS bootstrap + before serving ("not ready until
  upgraded"); a rebuild past the budget surfaces MigrationInProgressError at init
  (raise the budget, or run the offline migrator).

Observability (readiness-probe correct — never gated):
- getIndexStatus() gains `migrating` + `migration` (MigrationProgress); a probe
  maps migrating→HTTP 503+Retry-After, not 500. health()/checkHealth() lock-exempt
  (health() reports a `warn` migration check). stampBrainFormat()/close() are
  ungated so cor can clear the lock — no deadlock.
- Optional provider migrationStatus() is relayed verbatim for a live %.

verifyGraphAdjacencyLive() honors the lock (no self-rebuild mid-migration); the
stamp is still withheld while any provider migrates (cor stamps after verify).

Adversarially verified: fixed a critical init/VFS-bootstrap deadlock, a mixed-
provider busy-spin (dropped the event-driven signal → pure poll), a not-yet-
initialized getIndexStatus crash, and once-per-window log/clock resets. 10 lock
tests (incl. the init-during-migration regression). Gates: typecheck 0, build 0,
test:unit 1753/1753.
This commit is contained in:
David Snelling 2026-07-01 10:26:27 -07:00
parent ca9129a924
commit 67bbf69a5c
5 changed files with 534 additions and 31 deletions

View file

@ -12,6 +12,7 @@ export type BrainyErrorType =
| 'VALIDATION'
| 'FIELD_NOT_INDEXED'
| 'GRAPH_INDEX_NOT_READY'
| 'MIGRATION_IN_PROGRESS'
/**
* Custom error class for Brainy operations
@ -253,3 +254,46 @@ export class GraphIndexNotReadyError extends BrainyError {
}
}
}
/**
* Thrown when a data-plane read or write is issued against a brain that is
* running its one-time, automatic 7.x 8.0 on-disk upgrade the coordinated
* migration LOCK. While a native provider rebuilds all derived indexes from the
* canonical records, brainy blocks reads and writes so no operation touches a
* half-built index; the caller waits for the correct answer rather than getting
* a partial one. This error is raised ONLY when the wait exceeds the configured
* window ({@link BrainyConfig.migrationWaitTimeoutMs}, default 30 s) never
* instead of a partial/incorrect result.
*
* It is `retryable`: the upgrade continues in the background. Retry shortly,
* watch `brain.getIndexStatus().migration` for progress, or for a very large
* brain run the offline migrator. Data is safe; nothing is lost. Consumers can
* catch this (e.g. request middleware) and answer HTTP 503 + `Retry-After`.
*
* @example
* try {
* await brain.find({ query })
* } catch (e) {
* if (e instanceof MigrationInProgressError) {
* res.set('Retry-After', '5').status(503).json({ upgrading: true, percent: e.percent })
* return
* }
* throw e
* }
*/
export class MigrationInProgressError extends BrainyError {
/** Milliseconds the operation waited on the migration lock before timing out. */
public readonly elapsedMs: number
/** Latest observed migration progress (0100), when the provider reports it. */
public readonly percent?: number
constructor(message: string, elapsedMs: number, percent?: number, originalError?: Error) {
super(message, 'MIGRATION_IN_PROGRESS', true, originalError)
this.name = 'MigrationInProgressError'
this.elapsedMs = elapsedMs
this.percent = percent
if (Error.captureStackTrace) {
Error.captureStackTrace(this, MigrationInProgressError)
}
}
}