Skip to content

fix: dispatch capacity refusals, KV sorted index ordering, array sync results, vector fixes - #416

Merged
farhan-syah merged 11 commits into
mainfrom
fix/dispatch-capacity
Oct 12, 2026
Merged

farhan-syah merged 11 commits into
mainfrom
fix/dispatch-capacity

Conversation

@farhan-syah

Copy link
Copy Markdown
Member

Closes #371
Closes #352

What changed

Dispatch capacity (#371, #352)

  • nodedb-bridge, nodedb/src/control: a full dispatch queue returns Error::DispatchCapacity. It maps to 57P03 / NDB-1403.
  • HTTP answers a refusal with 503 and Retry-After.
  • DDL system dispatch and the shuffle hooks keep the refusal retryable. They no longer turn it into XX000.
  • New metric nodedb_dispatch_capacity_busy_total{scope} counts refusals at the dispatcher.
  • New metric nodedb_server_overload_responses_total{protocol} counts overload answers sent to clients. It has a label per protocol: pgwire, native, http, resp.
  • The per-refusal log line is trace!, so a saturated queue no longer floods the log.
  • bug(cluster): a full per-tenant dispatch queue is treated as a terminal failure — startup rebuild spins into an error storm #352: the dispatcher behaviour is already correct on main. This PR adds tests that pin it:
    • dispatcher_refuses_over_cap_then_admits_once_a_slot_frees
    • rebuild_against_a_saturated_tenant_defers_then_catches_up

KV sorted index

  • kv/sorted_index/number.rs: integers and floats share one exact, order-preserving 10-byte encoding. 11 sorts above 10.5.
  • kv/sorted_index/value.rs: each value carries a type tag.
    • Strings sort by content, not length.
    • An ISO timestamp never collides with a number.
  • Backfill and the write path use one encoder.
  • An update removes the stale index entry.
  • WINDOW CUSTOM bounds accept ISO timestamps or milliseconds. A bad bound returns 42601.
  • Mixed-case column names resolve.
  • Window timestamps decode from the sort key.
  • Native KvSortedIndexRange takes typed bounds (Value), not raw bytes.

Array sync

  • ArrayRejectReason lists every reason. class() tells a client whether to retry, resend the schema, re-authenticate, catch up, or give up.
  • ArrayOpResults (0x98) returns one result per op for batches and snapshots. Before this, only the first reject reached the client.
  • ArrayAdvisoryRefused (0x99) answers a refused ack or catch-up request. It is separate from ArrayReject.
  • A poisoned snapshot lock returns the transient SnapshotBufferLost and records a capture.

Vector

  • Node vectors are read through the backing store.
  • Distance is computed by probe.
  • Search handles nodes that have no vector.
  • An HNSW checkpoint keeps all 8 metrics. Before this it kept only metrics 0–2.
  • A corrupt snapshot is rejected when it loads.
  • Search allocates far less (allocations per search):
Search Before After
F32 2289 13
F32 filtered 3134 13
F32 ef16 / ef256 998 / 3764 11 / 15
F16 NaviX 1793 50
F16 brute force 24 5

Client and CI

  • nodedb-client: the search-hit decoder compiles only when a client feature is enabled.
  • CI runs clippy on the nodedb-client feature subsets: none, native, remote.

Docs (nodedb-docs, already on main)

  • Error codes: SERVER_OVERLOAD and the overload counters.
  • KV sorted index: ordering and WINDOW.
  • Graph query reference.
  • New array sync page in the CRDT sync section.

Lite follow-ups

This PR changes shared-crate APIs. Lite must handle each change below:

  • ArrayRejectReason lists every reason:
    • Values: SchemaTooNew=1, ArrayUnknown=2, ShapeInvalid=3, EngineUnavailable=4, RetentionFloor=5, EngineRefused=6, PermissionDenied=7, Unauthenticated=8, SnapshotCapacity=9, SnapshotBufferLost=10.
    • Removed: EngineRejected, QuotaExceeded, WireVersionMismatch.
    • Added: ALL and class().
  • New ArrayRejectClass with values Transient, SchemaRequired, Reauthenticate, CatchUp, Permanent. ArrayRejectMsg::class() returns it.
  • New ArrayOpResults (0x98) with ArrayOpResultsMsg, ArrayOpResult and ArrayOpOutcome.
    • Origin answers every ArrayDeltaBatch with an ArrayOpResults.
    • An undecodable array frame returns ArrayReject{ShapeInvalid}.
    • An unauthenticated session gets Unauthenticated.
  • New ArrayAdvisoryRefused (0x99) with ArrayAdvisoryKind {Ack=1, CatchupRequest=2} and ArrayAdvisoryRefusedMsg.
    • Origin answers a refused ack or catch-up with this message, never with ArrayReject.
    • Lite acts on reason.class() and leaves the push window unchanged.
  • TextFields::score_min / score_max change from Option<Vec<u8>> to Option<Value>. Lite does not read these fields.
  • Three Lite comments quote the old dist_to_node error text. Update them:
    • restore/hnsw.rs:106
    • engine/vector/durable/mod.rs:18
    • search/lazy_load.rs:95
  • Two items carry over from Bound cluster waits by deadline and close the Calvin gaps from #414 #415:
    • Remove the MetaOp::CalvinFlush arm.
    • Rename hilbert_range to shard_bucket.

How to check

  • cargo nextest run --workspace --exclude nodedb-cluster-tests --all-features --retries 0: 20901 of 20901 passed.
  • cargo nextest run -p nodedb-cluster-tests --all-features --retries 0: 502 of 502 passed.
  • Clippy is clean in three configs:
    • workspace with all features
    • workspace with no features
    • nodedb-client with no features, with native, and with remote
  • Every scripts/ci/check_* gate passes, including --self-test.

Every dispatcher capacity refusal is now built in one place that
increments nodedb_dispatch_capacity_busy_total{scope}. The counter is
shared with the node's metrics, so /metrics reads it without taking the
dispatcher lock.

A capacity refusal keeps its type through the system dispatch door and
every protocol surface, so clients see the retryable SERVER_OVERLOAD
class (SQLSTATE 57P03) instead of an internal error. Calvin catch-up
and deferred dispatch hold a refused transaction and re-send it once
capacity frees.
…ered search

After a graph-only checkpoint restore, nodes hold no local vector and
the vector lives in the attached backing. Neighbor pruning and the
diversity heuristic read node storage directly, so every distance
against a restored node errored and new nodes gained no incoming
edges.

Add HnswIndex::node_bytes, which resolves local storage first and then
the backing, encoding to the index dtype when needed. Route insert,
prune, and the f32 distance helper through it. dist_to_node_f32 now
takes the metric, and the adaptive filter, ACORN, and NaviX traversals
use it instead of get_vector, so restored and non-F32 indexes rank
correctly under a caller-supplied metric.
nodedb_dispatch_capacity_busy_total measures dispatch pressure,
including refusals the server waits out itself. Add
nodedb_server_overload_responses_total{protocol} to measure client
impact: each protocol counts at the point it writes the overload
answer (pgwire 57P03, native 57P03 frame, HTTP 503 with Retry-After,
RESP BUSY). A gap between the two is pressure the server absorbs.
search_hit serves only the native and remote clients. Gate it on
those features so a build with neither feature has no dead code.
Add decoder tests for wrong cell types and a per-source score column.
Windowed rank, top-k, range and count now read each entry's timestamp
from the timestamp column of its sort key instead of a separate
lookup. Integer, float and ISO 8601 string columns decode to a
timestamp. An entry with no decodable timestamp is hidden.

WindowConfig::bounds returns a half-open [start, end) range, so a
custom window admits nothing outside its range. Backfill extracts
bool, null and composite values with the same bytes as the write
path.
…ckpoint metric

Distance evaluation reads node vectors through a probe, graph nodes may
carry no vector, and checkpoint load checks the stored metric and
structure before accepting an index.
…e bounds

Encode sorted index values and numbers so byte order matches value
order, parse WINDOW clauses in index DDL, and carry typed range bounds
through the native sorted read path.
Add reject reasons and classes, per-op results on batch responses,
advisory refusals, and a snapshot assembly lock with a diagnostic when
its buffer is poisoned.
@farhan-syah farhan-syah added the run-ci Opt this PR into the full test suite; re-add to force a re-run label Oct 12, 2026
@farhan-syah
farhan-syah merged commit b7208ba into main Oct 12, 2026
13 checks passed
@farhan-syah
farhan-syah deleted the fix/dispatch-capacity branch October 12, 2026 19:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-ci Opt this PR into the full test suite; re-add to force a re-run

Projects

None yet

1 participant