Skip to content

Backend fault classification, list-size caps, and search stats parity - #117

Merged
alex-feel merged 13 commits into
mainfrom
alex-feel-dev
Jul 14, 2026
Merged

Backend fault classification, list-size caps, and search stats parity#117
alex-feel merged 13 commits into
mainfrom
alex-feel-dev

Conversation

@alex-feel

Copy link
Copy Markdown
Owner

Summary

Thirteen commits hardening fault classification, input bounds, and response-shape parity across both storage backends.

  • Retry SQLite lock/busy contention uncharged on the circuit breaker, with a shared result-code classifier and tool-layer retry coverage.
  • Classify PostgreSQL pool-acquire timeouts by elapsed wait so an unreachable database charges the breaker while saturation stays uncharged, and re-raise setup-callback timeouts distinguishably.
  • Skip the dead exponential-backoff sleep after the final failed write attempt on both backends.
  • Keep ControlFlowError out of the failed-query read metric on both backends.
  • Classify a malformed DSN as a configuration error (exit 78) in the pgvector pre-check, and make IPv6 host bracketing idempotent.
  • Cap tags filters and metadata in/not_in value lists at 100 members with structured validation errors before any SQL.
  • Chunk ID-list binds in retrieval and criteria batch deletes; enforce the documented 100-item batch ID caps.
  • Report not-found when an atomic batch update loses its row to a concurrent delete mid-write.
  • Complete search stats parity on degraded and validation-error responses (fusion keys, backend key, semantic stats block).
  • Validate metadata index field-name length and case-fold uniqueness; strict-mode mismatch now exits 78.
  • Migrate CLI: validate the settings-fallback embedding dimension before DDL and screen content_hash for unstorable strings.
  • Documentation corrections for circuit breaker scope and search stats examples.

Test plan

  • Fast suite: 3723 passed.
  • pre-commit --all-files: all hooks green.
  • Integration suite (SQLite + PostgreSQL via Docker): 118 passed.
  • Live deploy stacks rebuilt on :8001/:8002; full 16-tool end-to-end plus new-cap probes passed on both backends.

alex-feel added 13 commits July 14, 2026 22:14
…aker

Cross-process SQLITE_BUSY and SQLITE_LOCKED contention surfaced through begin_transaction charged the circuit breaker and was never retried, so routine multi-process write contention could open the breaker and reject every caller's healthy requests.
A shared is_sqlite_locked_error predicate classifies the locked family by SQLite result code with a message fallback, begin_transaction re-raises it uncharged after rollback, and the tool-layer retry classifier now retries it with backoff on all store and update paths.
The write-queue retry loop also breaks straight to the exhaustion raise when no attempt remains, removing a dead backoff sleep that only delayed the single exhaustion breaker charge.
The retry classifier docstring no longer claims write-queue coverage it does not have.
A pool-acquire TimeoutError carries two indistinguishable signals: genuine pool saturation that waits out the full acquire budget, and asyncpg's inner connection-establishment timeout that fires clearly below it when the database is unreachable.
Treating both as saturation left every acquire timeout uncharged, so a blackholed database could never open the circuit breaker and every request repeated the full stall.
The acquire paths in execute_write, get_connection, and begin_transaction now discriminate by elapsed wait: saturation stays uncharged while an establishment timeout charges the breaker.
The pool setup callback re-raises its own TimeoutError as ConnectionDoesNotExistError so a dead pooled connection stays distinguishable from saturation and retries on the charged arm.
The write retry arms also skip the exponential backoff sleep after the final failed attempt, which only added dead latency before the single exhaustion breaker charge.
execute_read on both backends counted ControlFlowError in failed_queries, so repeated client-input validation rejections raised inside read callables inflated the operator-facing failure metric.
The write path already exempts ControlFlowError from failure accounting because it is normal control flow rather than a database fault.
Both read paths now re-raise it before the counter arm so failed_queries reflects database health only.
…r pre-check

asyncpg raises a plain ValueError for a malformed DSN authority before any network I/O, and the pgvector pre-check's catch-all wrapped it as a retryable DependencyError with exit code 69.
A supervisor then restart-looped on a permanent client-side misconfiguration instead of stopping.
The pre-check now classifies ValueError as ConfigurationError with exit code 78, matching the initialize path's classification of the same error.
The IPv6 bracketing in the built connection string keyed on the presence of a colon, so a host configured in bracket form was wrapped again into a double-bracketed authority.
asyncpg rejects that DSN at parse time with a plain ValueError that never names the bracket cause.
Bracketing is now idempotent: a host already in bracket form is used as-is.
…sponses

The all-modes-failed hybrid response omitted the always-present fusion_method, fts_count, and semantic_count keys, so a client reading the documented response shape crashed on the degraded path.
semantic_search_context returned no stats block at all when a metadata filter failed validation under explain_query, and the FTS and SQLite/PostgreSQL repository validation-error stats omitted the backend key the success path always carries.
A shared empty-stats builder now gives every validation-error response the same stats shape as the success path, with zeroed counters and the backend key.
The tags filter on the four search tools and grep_context, and the value lists of the metadata in and not_in operators, accepted unbounded client-supplied lists.
Each member binds one SQL placeholder in a single statement, so a schema-legal oversized list could overflow the backend's bind limit inside connection scope and charge the circuit breaker for purely invalid client input.
Both caps are advertised in the wire schema, re-checked in each tool body behind the schema, and rejected as structured validation errors before any SQL executes.
ContextRepository.get_by_ids and both criteria-based batch delete paths bound arbitrarily long client-controlled ID and thread lists into a single IN statement, overflowing SQLite's per-statement bind-variable ceiling on large inputs.
get_by_ids now fetches in bounded chunks and re-sorts the accumulated rows so the ordering contract is preserved across chunks, and the criteria deletes execute one statement per bounded chunk pair inside the same transaction.
get_context_by_ids and the delete_context_batch ID and thread lists now enforce the documented 100-item cap at the tool boundary.
…rite

When a concurrent delete removed a row between an atomic batch update's version capture and its compare-and-set write, the resulting conflict surfaced as a retry-the-request error even though retrying can never succeed.
The atomic path now re-probes the row on the open transaction connection when the compare-and-set matches nothing and reports the standard not-found error for a vanished row, re-raising the genuine version conflict otherwise.
…ailure

A configured metadata index field name longer than 50 characters produced a generated index identifier past PostgreSQL's 63-byte limit, where silent truncation breaks index reconciliation; names are now length-checked at settings construction.
Two field names differing only in case collide on SQLite's case-insensitive index namespace while PostgreSQL keeps both, so case-folded duplicates are now rejected identically on both backends.
A strict-mode index mismatch raised RuntimeError with exit code 1, restart-looping supervisors on a permanent misconfiguration; it now raises ConfigurationError with exit code 78, and the message states that strict mode requires pre-provisioned indexes.
…e pre-flight

The migration CLI's pgvector dimension pre-flight ran only when the source database reported a dimension, so a source whose embedding_metadata table exists but is empty fell back to the configured embedding dimension without validation.
An over-limit fallback then templated invalid vector DDL and crashed at index creation, leaving the target schema partially initialized.
Both the target initialization pre-flight and the migration-run guard now resolve the effective dimension including the settings fallback and refuse an over-limit value before any DDL, and a dry run reports that a real run would abort.
…check

The SQLite-to-PostgreSQL per-row pre-check screened every raw-bound text column except content_hash, so a NUL byte or lone surrogate there aborted the whole migration mid-transaction instead of skipping and reporting the row.
The pre-check candidate list now covers content_hash, and a dry run surfaces the affected row.
…tation

The environment-variables reference scoped the circuit breaker settings to SQLite only, but the PostgreSQL backend consumes all three settings as well.
The hybrid search docstring and reference omitted the always-present backend key in fts_stats and semantic_stats and the adaptive_fts_mode field, and the semantic search stats example omitted the rows_returned and backend keys the runtime always emits.
@github-actions

Copy link
Copy Markdown

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  app
  metadata_types.py
  settings.py
  app/backends
  postgresql_backend.py 578-579, 822-823, 1182-1183, 1233, 1349-1350, 1354-1355, 1570
  sqlite_backend.py 1027
  app/cli
  migrate.py
  app/migrations
  metadata.py
  app/repositories
  context_repository.py 962, 1276, 1972-1975, 1978, 2065-2069, 2076-2079, 2119-2123, 2130, 2135
  app/tools
  _shared.py
  batch.py 907
  context.py
  navigation.py
  search.py 801-811, 1031-1042, 1410, 1424
Project Total  

This report was generated by python-coverage-comment-action

@alex-feel
alex-feel merged commit 3c2d06c into main Jul 14, 2026
8 checks passed
@alex-feel
alex-feel deleted the alex-feel-dev branch July 14, 2026 19:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant