Skip to content

HyperbyteDB 0.9.1-beta - #116

Merged
austin-barrington merged 233 commits into
mainfrom
release/v0.9.1-beta
Oct 17, 2026
Merged

austin-barrington merged 233 commits into
mainfrom
release/v0.9.1-beta

Conversation

@austin-barrington

Copy link
Copy Markdown
Member

First public 0.9 beta. It replaces 0.9.0-beta, which was tagged locally but never published. Release notes are in docs/user-guide/release-notes-0.9.1-beta.md. Use it with operator v0.3.0 (hyperbyte-cloud/hyperbytedb-operator#16).

Changes

  • Sharding: writes scale with the number of nodes (1.82x from 3 to 6 nodes), and scale-up and scale-down keep every point.
  • Materialized views work on sharded clusters.
  • Auth:
    • clusters with auth on work (cluster token, bootstrap admin);
    • GRANT READ | WRITE | ALL;
    • grants are kept across restarts and syncs.
  • Runtime settings: SET <setting> = <value> | DEFAULT and SHOW SETTINGS.
  • Grafana and InfluxDB compatibility:
    • gzip responses;
    • SHOW TAG VALUES WHERE;
    • aggregate time stamps match InfluxDB;
    • PERCENTILE and MEDIAN on sharded data.
  • Regions: a new measurement starts as one region, small regions merge, and region copies keep every row.
  • Python client: in clients/python.

Upgrading from 0.8.x

  • Config is strict: unknown keys stop the server.
  • Turn off the sharding add/remove peer proposals during a rolling upgrade.

Tests: cargo fmt --check, cargo test --lib, cargo test --test '*' and cargo test -p hyperbytedb-cli all pass locally. The last commit fixes two stale tests; neither was a server bug.

After merge: tag v0.9.1-beta; the release workflow builds and publishes it.

Bootstrap now takes RF = min(configured, live members), so n=1 gets peers=[self] even when configured RF is 3.
A joiner's map_version must match the cluster's before any peer-set change, so staging cannot run against a lagging epoch.
A joining node became a member but never received data for measurements
that already existed: `try_split` clones the parent peer set and
`try_rebalance` only moves a primary among peers already in the region,
so an existing region's peer set never grew to include a new process.

Add `ShardMapOp::AddPeer` and a scheduler placement step that stages the
region onto a live Active member before committing it as a peer. Staging
first is the contract here — the heal path's commit-then-stage ordering
would publish a peer that holds no rows. Apply refuses `AddPeer` on a
region carrying outstanding transfer debt, on a duplicate peer, and on a
stale epoch.

`ShardRehomeRequest.stage` lets the scheduler ask a remote primary to run
the same staging push when the leader is not the region's primary; the
rehome handler skips `complete_region_transfer` for a staging push since
ownership does not change.
A joiner that received region data was still only a replica: nothing moved
a primary onto it, so writes kept landing on the original owner and the
new process added HA but no write capacity.

Add two placement steps to the scheduler tick, both stage-then-commit so
an empty node never owns a range:

- `try_place_primary` hands a region's primary to its newest peer while
  that peer carries strictly fewer primaries than the current one. The
  "newest peer" bias is what makes it terminate — once the joiner is
  primary the rule no longer applies, so ownership cannot oscillate back,
  and the count guard stops every region stampeding onto one node.
- `try_place_idle_member` covers 3->4, where no region is under RF and
  `AddPeer` correctly declines. An existing replica steps aside for the
  newest member via `MovePeer`, which appends it and so makes it the next
  primary-placement candidate.

`try_rebalance` scored a peer with no heartbeat row as 0 bytes, which made
any freshly staged replica look infinitely lighter than the primary and
handed it ownership on the strength of missing telemetry. Unmeasured peers
are now skipped rather than treated as empty.

Integration coverage asserts the contract rather than the midpoint: a
write aimed at the replica-only joiner forwards and leaves its WAL empty,
and the same write applies locally once the primary has moved.
Both placement steps ran against the region as it looked when the tick
snapshotted the map. A placement earlier in the same tick invalidates that:
`AddPeer` bumps the epoch and appends a peer, so the next step would stage
an entire region copy and only then have its proposal rejected by the epoch
CAS — wasted transfer, repeated every tick until the snapshots happened to
line up.

Re-read the region from a fresh snapshot inside each step, and skip it if
the region has since been split, merged, or dropped.
Seven findings from reviewing the branch, four of which could corrupt data
or wedge a cluster.

**Rollup destinations were eligible for placement.** `apply_transfer_push`
has no idempotence guard, so a region staged onto a joiner whose map op
then fails is staged again next tick. `ReplacingMergeTree` measurements
collapse the redelivery; `SummingMergeTree` rollup destinations sum it and
are corrupted permanently. `enqueue_reconciliation` already excluded these
spaces for exactly this reason — all three placement paths now share that
guard, failing closed when the lock is contended.

**`AddPeer` had no upgrade gate.** `ClusterRequest` is serialised into the
Raft log, so a committed op an un-upgraded voter cannot deserialise wedges
it. `ClearVerified` set the precedent; `AddPeer` now has the same gate and
defaults **off** for the release that introduces it.

**`sync_shard_map` could move the map backwards.** The sync peer is
whichever Active node came first out of a HashMap — not the leader, not
necessarily the most-applied node. Installing an older snapshot dropped ops
Raft never redelivers, because `replace_map` bypasses the state machine and
leaves `last_applied` untouched; every later op then failed StaleEpoch and
the node silently stopped owning regions it held data for. Install only a
strictly newer map.

**A missing shard map aborted the whole join.** `/internal/shard/map` only
exists on peers with sharding enabled, so a 404 from an older or
unsharded peer failed `join_and_sync` *before* WAL catch-up; after the
retries the node went Active having synced nothing. It is best-effort now:
with no map there are no regions to move, and the leader's own catch-up
check still gates placement onto this node.

Also: `try_place_live_member` never re-read its region, so it staged a full
copy before failing the epoch CAS (the sibling fix missed it); and the
steps after a placement ran on the region as it looked *before* that
placement committed — `try_rebalance` would spend an entire region copy on
a stale peer list. A placement that commits now yields the region.

Placement was reachable only from `tick()` and no test called it, so
deleting those calls left the suite green. Adds a harness with a mock peer
that drives a real tick, covering both the placement and the upgrade gate.
Placement could stop cluster-wide, permanently and quietly.
`joiner_map_caught_up` required exact `map_version` equality and
`live_replica_candidate` always returned the lowest-id non-peer, so a single
node that never matched was chosen first, failed the probe, and was chosen
again the next tick. No other candidate was ever tried, for any region, and
the only trace was a `debug!` line.

- Catch-up accepts a candidate that is *ahead*. The cluster version comes
  from a snapshot read just before the probe, so a node that applied an op
  in between is legitimately ahead and was being rejected for it. Behind is
  still refused — that is what the gate is for.
- `live_replica_candidates` returns every eligible member in id order and
  placement takes the first that passes its probe, instead of stopping at a
  candidate chosen before any probe ran.
- A region that goes `PLACEMENT_STALL_WARN_TICKS` ticks with candidates but
  none caught up now warns and increments a counter. A stall that only shows
  up at debug level is a stall nobody finds.
- `/internal/shard/map` serialises every space and region to answer one
  `u64`, and it was probed once per region per tick — O(regions x map size)
  bytes on every tick of a join. Probes are cached per peer for the tick.

Also corrects the `handle_shard_transfer` staging comment, which still
claimed a local-measurement guard that P1.3 deliberately removed.

H.6 (whether the gate should compare the Raft applied index instead) is
answered in the plan, no code change: the index is already exposed at
/internal/raft/metrics, but `replace_map` installs a map without advancing
it, so an applied-index gate would reject exactly the joiner it should
admit. Fix belongs with Phase 2's snapshot-based map recovery.
`try_place_idle_member` was documented as giving "a member that joined an
already-replicated cluster" a replica slot, which reads as join-triggered.
It is not. The target is the active member with the greatest `joined_at` —
whoever joined last, with no recency window, so "last" may mean months ago.
The rule fires whenever that member is under-loaded against some region's
non-primary peer, which means a stable-but-unbalanced cluster starts moving
replicas on the first tick after an upgrade rather than on any join.

The behaviour is deliberately unchanged. It is bounded and convergent, and
every copy is staged and verified before the map changes, so this is a
scheduling surprise rather than a correctness risk — acceptable while
sharding is beta. Gating it was considered and rejected for now; the doc
says so, and says to revisit before sharding graduates.

Renames `newcomer` to `latest_member` throughout, so the identifier stops
implying recency too. No logic change.
Shipped it off, which was the wrong call. The gate is an opt-out for a
rolling upgrade, not an opt-in for the feature — `transfer_clear_proposals_
enabled` guards the identical hazard and defaults on, and defaulting off
leaves region placement inert for everyone who enables sharding and never
finds the flag.

The hazard is bounded: a follower on a build that does not know `AddPeer`
rejects the whole append RPC (axum fails the body before the handler runs),
so it stops replicating and the leader can lose commit quorum for the
duration. It resolves as soon as every node is upgraded, and ingest does not
go through Raft. Operators disable the gate for the rollout window.
Placement must agree across every node, including nodes on different
builds mid-rollout, so this cannot use DefaultHasher -- std's hasher is
explicitly not stable across releases. SplitMix64 constants live in our
own source and are frozen; the avalanche test guards against anyone
retuning them.
Add before Promote before Remove, one step per call. The ordering is
load-bearing: adding before removing is what keeps RF from dipping while
converging, and promoting before removing keeps the primary off the node
being evicted -- which is also what gives drain correct behaviour without
a rule of its own.

Carries forward the transfer_outstanding guard both deleted candidate
functions had. AddPeer apply rejects while debt is outstanding, so
proposing anyway spends a full region copy on a guaranteed rejection
every tick, uncounted against the movement budget.

repeated_steps_converge_to_a_fixed_point is the convergence argument
itself: with a pure target function, termination needs no cooldown and no
recency bias.
different_starts_spread_across_nodes is the point of the whole phase: a
measurement's regions must not all land on the same RF nodes. Today
try_split clones the parent peer set, so 48 regions use three nodes; with
this they use most of twelve.

Rendezvous hashing is chosen for its movement property, which the other
two tests pin: adding a node moves a minimal share rather than
reshuffling, and removing one moves only the regions that node held.

Ranking by raw hash is the weighted score w/-ln(h/MAX) under uniform
weights, since that is a monotonic transform. A weighted variant must
switch to the full score.
The mirror of AddPeer, and what makes drain and rebalancing possible at
all: until now a region's peer set could only grow. Refuses to remove the
primary or the last peer so a region is never left ownerless, and carries
AddPeer's stale-epoch and transfer-debt guards.

No production match on ShardMapOp swallows the new variant -- checked,
since a catch-all would have made this compile while doing nothing.
Both default to the safe reading: RemovePeer proposals on, matching
AddPeer, and a movement budget of 0 meaning unlimited so the throttle
never engages until someone asks for it.
Pure refactors with no behaviour change, so convergence can reuse both
rather than duplicating a two-branch staging path and a probe loop that
exists to stop one lagging node blocking every region.

first_caught_up keeps the original accounting exactly: an empty candidate
list clears the stall rather than recording one, since having no
candidates is not the same as being stalled on a lagging peer.
Generates its own load rather than delegating to sharding-perf-ingest.sh,
which hardcodes 100k series into measurement "metrics" and honours only
API and DB. Driving that with SERIES=5000000 would have ingested 100k
series into roughly one region at every node count and reported a flat
curve -- a false negative on the whole design, arriving as a verdict.

Warns when regions < nodes, since a flat curve there is the cardinality
floor rather than a placement defect. The region-count probe is checked
against the real ShardMapJson shape: the leading quote in "region_id"
keeps it from also matching next_region_id.
NodeState answers three independent questions -- can it serve, should it
hold regions, must its data be evacuated -- and the code tested == Active
for all three. That is what produced the same placement defect twice. The
questions are now three named predicates, and no ordering of a single
state has to satisfy all of them: a draining node serves nothing yet
keeps its regions, a disconnected one keeps them too, only a departing
one loses them.

transition() refuses moves out of a terminal departing state. set_state
overwrites unconditionally, so the preStop hook's /internal/drain could
put a decommissioning node back into Draining mid-evacuation, re-admitting
it as a placement candidate while its regions were still moving off.

Draining now recovers to Active on a ready self-report. It previously sat
in the never-promoted list, so a node that restarted after a preStop drain
stayed Draining in its peers' views forever -- broken today, and
load-bearing once drain keeps its seat. operator_states_are_never_promoted
encoded the old contract; it now covers Joining, Decommissioning and
Leaving, and the new behaviour has its own test rather than a weakened
assertion.

The compiler found exactly one exhaustive match. write.rs had a `_ => {}`
that would have silently admitted client writes to a node being removed;
its arms are now explicit so the next variant cannot slip through the
same way.
One branch carries the spec, the phase-1 plan, the sharding test plan and
the code, so the design and what was built from it stay reviewable
together.
Variant order is the sort order and that is the point: durability must
never queue behind balance. RF violations and evacuation are exempt from
the movement budget; only convergence yields.

A region at full peer count with a dead peer is under-replicated in
substance even though peers.len() still reads RF, so move_tier takes the
unhealthy set rather than trusting the count.

The middle tier is Evacuation, not Drain -- under the new membership
model drain is a restart that moves nothing, and only decommission
evacuates.

Lives in domain/sharding/placement.rs beside the rest of the pure policy
rather than in the application-layer scheduler.
Placement is now a pure function of membership, so a demotion moves data.
One dropped probe must not reshuffle a node's regions. Demotion needs
cluster.heartbeat_miss_threshold consecutive misses -- a knob that already
existed, defaulted to 5, was already in the CRD, and until now was read
only by flush_service while the prober demoted on the first failure.

The counter lives on NodeInfo, not in probe_peers. probe_peers is
stateless and called fresh each tick, so a map local to it would reset
every tick and pin the count at 1 -- hysteresis that compiles, passes its
unit tests, and demotes on the first miss exactly as before.

A success decrements rather than resets. Hard-reset never demotes a node
alternating four misses and one success: unreachable 80% of the time yet
permanently Active, which is worse than the rule it replaced.

The Unreachable arm previously bypassed decide_transition with its own
inline check; both arms now go through it, so the hysteresis cannot be
sidestepped.

unreachable_peer_is_disconnected encoded the pre-hysteresis contract and
is rewritten to walk to the threshold rather than widened with arguments
that would have quietly inverted it.
A measurement's first region starts at 0, so bootstrap placement is
exactly target_placement(key, 0, members, rf). Computing it any other way
means a freshly bootstrapped region is immediately misplaced and the
scheduler moves it on the first tick.

The previous ordering used DefaultHasher, which std does not guarantee
stable across releases. Placement is recomputed continuously on every
node, so two builds disagreeing would scatter regions during a rolling
upgrade -- the same hazard the frozen hash exists to avoid.
The tick now moves each region one step toward target_placement's output
and the three recency-keyed rules are gone. Their difficulty was proving
termination without a cooldown, which is why primary placement keyed on
the newest peer and idle placement fired on max(joined_at) rather than on
an actual join. With a pure target function that argument disappears:
each step strictly reduces the difference and next_placement_step returns
None at the fixed point.

Add probes every target member the region is missing, not just the one
next_placement_step names. PR #115's placement_skips_a_lagging_candidate
test caught this: committing to a single peer before probing let one
lagging node block that region forever -- the bug review findings #9/#10
fixed, which I had reintroduced. All missing members belong in the region,
so any may be staged first.

Chunks C3 and C5 merged because -D warnings will not accept a method
reachable only from cfg(test): convergence could not land unwired.
Drain and decommission shared one code path while meaning opposite
things. shard_handoff proposed TransferPrimary AND MovePeer and passed
drop_source: true, so every restart evicted the node from every region it
led and deleted its local copy -- and the preStop hook runs that on every
pod. Now only the crown moves: the seat and the data stay, so a restarted
node resumes with nothing to re-stage.

Decommission sets Decommissioning, hands off primaries, flushes, and
returns. It never declares Leaving; the evacuation loop is the only thing
that may, once a replacement peer set is actually established.

Lifecycle changes go through transition(), which refuses to downgrade a
terminal state. Without it the preStop hook's /internal/drain would
clobber a decommissioning node back to Draining and re-admit it as a
placement candidate mid-evacuation. handle_leave's self-leave arm had the
same hazard and is fixed with it.

The shutdown path can no longer read "already drained" off membership,
since a successful drain now ends at Draining rather than Leaving, which
is indistinguishable from never having drained. DrainService tracks it
directly.
This is the defect the phase exists for. try_split cloned the parent's
peer set, so every descendant region of a measurement lived on the same RF
nodes: splitting a hot measurement 128 ways still used three. The right
child is now placed by the hash, keyed on start rather than region_id
because Split allocates the child's id at apply time and a proposer cannot
hash on an id that does not exist yet.

The left child keeps the parent's start, so its placement is identical by
construction and its rows never move -- an eager split costs only the
right half.

The right child is published holding its primary ALONE, not the full
target set. try_split stages to right.primary only, so committing the
whole target would publish RF-1 peers with zero rows; scatter falls back
to replicas and that reads as silently empty results. It would also be
self-sealing, since a region whose peers already equal its target is read
as converged and never backfilled. Convergence grows it instead, in the
unthrottled RfViolation tier.
The allowance is sized from the scheduler's real tick cadence, captured
in run(), not from split_merge_interval_secs. The latter is an unrelated
split/merge cooldown defaulting to 3600 while the scheduler ticks on
heartbeat_interval_secs, default 10 -- sizing from it would hand every
tick an hour's allowance and leave the throttle 360x too loose.

The gate sits after the converged check, never before. Gating first would
skip the None branch for already-converged regions once a tick's budget
was spent, leaving their stall counters uncleared and reporting healthy
regions as stalled under exactly the load stall detection exists for.

RF violations and evacuation are exempt: safety never queues behind
balance. A region at full peer count with a dead peer counts as an RF
violation, since peers.len() alone would call it merely misplaced.

tick_moved_bytes is a bare AtomicU64 -- the scheduler is already shared
behind an Arc -- and Relaxed is correct, since only the scheduler task
touches it and it orders no other memory.
decommission_complete is the only thing that may write Leaving for a
decommissioning node. drain reaches its terminal state after a 90s-capped
replication wait, which says nothing about whether any region moved -- and
the operator is meant to wait for Leaving before deleting the node's PVC,
so resolving it on a timer would race evacuation against volume deletion
for exactly the nodes holding the most data.

The sweep runs after the region pass so a node that finishes evacuating
during a tick is retired in that same tick, and goes through transition()
so it cannot move a node that is no longer decommissioning.
A dead peer is dropped first rather than swapped. There is no live source
to stage from, so preserving its membership only delays RF restoration --
the deliberate asymmetry with drain, which has a live source and therefore
restores before it removes.

This also drops the hand-rolled replacement selection. Picking the
least-loaded active member could choose a node the placement function
disagrees with, which convergence would then move again; removing the dead
peer instead leaves the region below effective RF, where convergence
classifies it RfViolation -- top tier, exempt from the budget -- and stages
whoever the hash actually wants, before committing rather than after.

RemovePeer refuses the primary, so a dead primary is left to
try_failover_unhealthy_primary, which runs immediately after this in the
tick; the peer is removed on a later pass once it no longer owns the
region.
The six free functions the old rules used were dead in production after
the switchover, but three of PR #115's integration tests still called them
directly -- computing what the retired policy would decide and applying
the op by hand, never going through tick(). They were green while
exercising code nothing called, which is worse than red.

Two of them needed a measurement rename rather than a weakened assertion.
Under hashing p14db/cpu targets node 1 first, while the test body is built
around node 2 becoming primary; p14db/disk targets [2, 1], so it is a
genuine P1.4 case again. Same for the 3->4 test, where p16db/disk targets
[4, 3, 2].

Two assertions changed meaning, deliberately. "RF is satisfied so AddPeer
must not fire" is now the opposite: a fourth node needs no idle-member
rule, it simply enters the targets its hash wins. And "the primary is
never displaced" -- which the old rule guaranteed outright -- became a
walk of the steps asserting no Remove ever names the sitting primary,
since Promote is ordered before Remove. The new policy may displace a
primary, but never while it still holds the role.

329 lines of policy deleted; the behaviour it implemented is now a pure
function and a diff.
Two bugs, both mine, both invisible to 850 in-process tests and both
caught by a real 6→5 scale-down. First run lost 1196 of 5000 points,
second 625; this passes with 5000 intact and replication factor restored.

is_active_peer tests `== Active`, so a Decommissioning node read as dead
and the drop-first heal path removed it without staging its rows
anywhere -- deleting the map's only reference to that copy. C8 argued
drop-first is right because a dead node has no live source to copy from;
that is true of a disconnected node and exactly wrong for a departing
one, which is alive and still holding data. Departing peers are now
excluded from dead-peer detection: convergence owns them, and it stages
before it removes.

The deeper one was a trap I built both halves of. C4 published a split's
right child holding a single peer, and split children carry transfer
debt, and the transfer_outstanding guard makes convergence skip
debt-carrying regions -- so the child was stuck below replication factor
with its rows still on the parent's primary, unreadable and unrepairable.
With regionSplitSeries at 25 against 5000 series that was the steady
state, not an edge case. The child now keeps the parent's peer set, whose
nodes replicated the parent's whole range and therefore already hold its
rows; only the primary moves eagerly, and only to a peer that has the
data. Convergence walks the child to its hash target afterwards, staging
each peer before committing it.

The e2e launcher also waited on pod readiness rather than job completion,
so it copied the report before the test had written one and reported a
failure that had not happened.
Two ways the launcher could call a run green without evidence.

It waited on pod readiness rather than job completion, so it copied the
report seconds after the container started -- before the test had written
one -- and reported a failure that had not happened. That was the first
"failure" of this branch's e2e.

Worse in the other direction: kubectl cp failures are swallowed, and the
launcher's own exit trap writes a report with an empty goals table saying
"Overall: PASS". So a failed copy left a stale or vacuous report in place
and the PASS grep read it. A run whose report never arrived would be
reported as passing.

The launcher now waits for the pod to reach a terminal phase, clears its
own goals so it cannot emit a verdict it did not earn, removes any prior
report before copying, and refuses to call a run passing unless the copied
report actually contains gate rows.
Found collapsing UAT's regions: every copy of Telegraf's cpu regions was
refused with 413 and retried forever, so their learners never promoted.

- A copy paged through chDB on series_id alone, re-reading the boundary
  series from its start. A series with more rows than one chunk (50,000)
  never advanced, and the rest of it was skipped with a warning -- rows
  lost on every move of a series older than ~6 days at 10 s. Paging is now
  on the table order, (series_id, time, ingest_seq), strictly after the last
  row read, so a series of any length is copied whole, and two versions of
  one point split across chunks both go.
- Chunks are capped in points and the receiver's limit is in bytes; a wide
  measurement (cpu: 10 fields and tags) exceeded it. A 413 now halves the
  chunk for the rest of the copy (down to 100 points) and sends the same
  rows again.
Standard library only, Python 3.9+. Batched, gzipped writes with
backoff that honours Retry-After, 413 split, typed errors; InfluxQL
queries with bound params. Unit tests against a fake server and
integration tests against a real one (HYPERBYTEDB_URL).
0.9.0-beta was tagged but never published; 0.9.1-beta replaces it.
Release notes renamed and updated (operator v0.3.0, Python client, two
known issues).
The compat test still expected epoch 0 after 8130d40. The gzip test
failed in a workspace build: the CLI enables reqwest's gzip feature, so
the test client decompressed and dropped Content-Encoding.
@cursor

cursor Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

PR Summary

High Risk
Beta release touching sharded data placement, scale-down/evacuation, auth/cluster tokens, and strict config—areas where regressions affect correctness or cluster availability.

Overview
HyperbyteDB 0.9.1-beta bumps the server, CLI, and proxy (and chdb-rust 2.0.0) and packages the first public 0.9 beta with sharding, auth, and client/ops work called out in the release notes.

Sharding and operations: The release targets linear write scaling and safe scale-up/scale-down (decommission/evacuation), with extensive kind-based e2e scripts (lifecycle, continuous writes during topology changes, splits under load, rolling restart) plus new cluster manifests (1/3/6-node sharded profiles, 12-worker linearity layout) and RBAC for in-cluster test jobs. Docker/kind setup gains optional CARGO_BUILD_JOBS, profiling build args, and sharded profile flags (--scaleup-1, --linearity, etc.).

Server-facing capabilities (per release scope): materialized views on sharded clusters, cluster auth (cluster_token, bootstrap admin, GRANT persistence), runtime SET / SHOW SETTINGS, Grafana/Influx-compat fixes (gzip, tag values, aggregates, percentiles on sharded data), and region lifecycle improvements. Builds can enable optional OpenTelemetry OTLP and profiling (pprof endpoint); config rejects unknown keys on upgrade.

New Python client (clients/python): stdlib-only Influx v1 /write and /query with batching, gzip, retries, and tests.

Docs add linear-scaling design, sharding real-world test plan, scale-down runbook, and user-guide updates (runtime settings, MV/CQ column naming, operating sharded clusters). CLI adds bench modules; .gitignore and example auth config comments are adjusted.

Reviewed by Cursor Bugbot for commit 728374f. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 728374f. Configure here.

A cluster token that is not a valid header value (a Secret written with
echo ends in a newline) was silently left off every peer request, so
every internal route answered 401 and Raft never formed. Config
validation now refuses it at startup, without showing the value.

A scheduler task that panicked or was cancelled kept its region claimed
until restart, and a panic in a promotion or merge batch left its busy
flag set, stopping all later ones. Region claims are now a guard that
releases on drop, and the busy flags clear on drop.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Not approved. Cursor Bugbot reported three unresolved medium-severity findings that need human review, and no reviewers were assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver

A rehome status that is not a u16 is a 502, not a truncated code. The
forward lane comment says why its channel needs no bound. The rehome
progress header moves to the domain's wire types and the map-version
probe goes through PeerClient, so the scheduler no longer imports the
HTTP handlers or the sync client.
pending_forwards defaulted to 0 (safe to retire), set_setting to a
no-op and flushed_margin_start to "keep nothing". Every adapter
overrode them, but a new one that forgot would compile and lose writes
or settings. They are required now; the single-node ingestion service
says 0 itself.
PeerIngestionService now holds a ForwardQueuePort, and ForwardEntry is a
domain type, so the application no longer imports the adapter.

The adapter runs its RocksDB calls on the blocking pool, as the WAL
does. claim and the per-write lag check walked every entry under a lock
every write takes; ready, delayed and by-age indexes make both O(log n)
with the same order. complete now takes the retry and writes both in one
batch: the queue never holds a batch and its retry at once, even across
a crash. A failed complete releases the entries instead of holding them
until restart.
application/runtime::serve built the services, started the tasks and
built both HTTP routers: top-level wiring, now in src/server.rs next to
bootstrap. application/runtime keeps the control runtime, region write
stats and the region heartbeat reporter.
The token lived in a process-wide static that every peer client read
when built and the auth layer read on each request. ClusterAuth is now
built once at startup from auth.cluster_token (refusing one that cannot
be a header, or an HTTP stack that cannot start) and handed to
PeerClient, the sync client, the Raft network, AppState and the query
service. The application builds peer clients through a PeerHttp port,
so it no longer imports cluster_auth; a client that fails to build is
logged, not silently swapped for one without the token.

Raft proposals for shard map ops reuse the node's pooled peer client
instead of building a client per call. Network::default, which had no
credential, is gone.
effective() replaced whatever limits a caller passed with the SET
override, including the unlimited ones region transfers, sharded
replicas and sync pass for data that already exists. After SET
max_tag_values_per_measurement = N, moving or replicating a measurement
over N was refused.

IngestCardinalityLimits is now a shared handle: the client-facing
limits built at startup get the SET through appliers, every service
holds a clone of them, and unlimited() limits are never touched. The
single-node ingestion service takes the shared limits instead of
building its own. Reading them is two atomic loads instead of a
registry lock and lookup.
The SET registry was a process-wide static. Nodes run in one process
(the sharded test harness) shared it, and register_applier replaced the
previous node's applier, so a SET resized only the last node's pools.

RuntimeSettings is now built in build_services and handed to AppState,
the query service, flush service, scheduler, routing context, Raft
store and sync client. The query service refuses SET and SHOW SETTINGS
without settings rather than changing nothing; elsewhere a SET without
them is stored and applies at the next start (logged). The log filter
is per process, so init_logging returns a LogLevel that serve registers
with the node's settings.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Not approved. Cursor Bugbot reported three unresolved medium-severity findings that need human review, and no reviewers were assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver

The steady writer now writes 195-303M points per scaling step, and a
count over that outlasts the 30 s query timeout; the read-back read the
timeout as zero and reported every steady point lost. It now raises
query_timeout_secs for the read-back, reports a failed count as
unreadable, and logs every layout poll (with staging learners and node
count) to show why a layout does not settle.
Each chunk was cut with ORDER BY (series_id, time, ingest_seq), which is
not the fact table's sort key, so ClickHouse sorted every remaining row
of the region to return one chunk: 3.7 s a chunk on a 280M-row region on
n3, and copies ran at 0.3-0.5 MB/s on 2.4 cores. Chunks now order by the
sort key with LIMIT ... WITH TIES (every version of a point stays in one
chunk) and resume after (series_id, time) spelled out so the index
narrows it: 0.13 s a chunk.

Pushes carried the line protocol inside JSON, one number per byte: 3-4x
the bytes to write, send and parse. They now go to
/internal/shard/transfer/raw with the body as is and the rest of the
payload in a header; a node without the route answers 404 and gets the
JSON push.
Export, push and the receiver's apply ran in turn, so the sender sat
idle through every apply. The next chunk is now read while the current
one is pushed; a chunk refused as too large discards the read-ahead and
re-reads that range in smaller chunks.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Not approved. Cursor Bugbot still has three unresolved medium-severity findings that need human review, and no reviewers were assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver

- Python client: health() returns a 503 (draining, syncing) at once; it
  was retried with backoff, about 15 s before returning the same answer.
- Python client: a newline in a string field value is refused, as in
  names and tags; written raw it split the line.
- Rolling-restart e2e: an unreadable RemovePeer counter fails R7 instead
  of reading as 0, which passed after a mass eviction.
- Release notes: copies 20-30x faster; known issues found on n3
  (series-only splits, replicas diverging after a scale-down under
  writes, the 30 s request timeout capping queries).

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. Cursor Bugbot’s three findings are resolved, and the present CI checks completed successfully. No reviewers were assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver

@austin-barrington
austin-barrington merged commit b25f8b1 into main Oct 17, 2026
5 checks passed
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