Repository navigation
v0.19.0
[0.19.0] - 2026-09-27
Fixed
-
The chaos lane reads labeled metrics by label and passes multi-shard
commit tokens one per parameter (issue #534). Thescripts/chaos/
parser now scans a sample's label set quote-aware, reads the value before
any timestamp, and takeslabel=valueselectors, and the mid-flush trigger
selectssignal="metrics". A comma-joinedx-ravel-commit-tokenheader is
split into its opaque tokens, and the header name is matched without the
gawk-onlyIGNORECASE. Both scenarios now exit 3 on a setup error:
scenario 1 used to exit 1 there, the same code as its strict-ack oracle
failure, and scenario 2's exit 2 stays the release-blocking verdict.
scripts/chaos/lib.test.shcovers the
helpers on every pull request, and the nightly job annotates each
scenario's exit code. -
OTLP and Remote Write ingest now take the in-flight permit and check the
tenant credential before the request body is read or decoded (issue
#1705).
Every ingest handler took the--max-inflight-ingest-requestspermit and
resolved the tenant as its first two statements, but on both transports
those statements ran too late to bound anything: on the HTTP surfaces the
handler's body argument is an extractor, which axum runs before the handler
itself, so the whole body was buffered (and a gzip body inflated) first,
and on the gRPC surfaces the handler runs only after tonic has read and
decoded the message. An unauthenticated caller could therefore make a
process read and decode a full request body no matter how far over the
ceiling it was. For OTLP metrics, logs and traces on both transports and
for Remote Write, both decisions now happen on the request head, through
middleware on the HTTP ingest routes and a tower layer on the gRPC
listener, so a refused request costs the bytes of one request head. The
OTAP stream is only partly covered: the gRPC layer checks its credential
on the stream's head, before any frame is read, but its permit is still
taken per batch after tonic has decoded that batch's protobuf frame, so an
authenticated OTAP client over the ceiling still costs one decoded frame
per shed batch. Because the permit is now taken before the body arrives,
the ceiling counts concurrent uploads as well as concurrent decodes, and
the body wait is bounded: a body that has not fully arrived 30 seconds
after admission is refused with 503 andRetry-Afteron HTTP or
DEADLINE_EXCEEDEDfor a unary gRPC export, and its permit is released, so
a trickled upload holds its slot for at most 30 seconds. The OTAP stream holds no permit while it
waits for a frame and is not bounded this way. The gRPC layer claims only
the ingest services the listener registers, so a process without OTLP
ingest (for example--mode query) still answersUNIMPLEMENTEDon those
paths. No HTTP/2 stream cap is derived from the ceiling, so the Flight SQL
and fragment listeners keep their previous stream limits. The ceiling, its
flag, and theravel_ingest_concurrency_shed_totalcounter are unchanged,
and a refusal keeps the status and message it had: 429 withRetry-After
and 401 for bad credentials on HTTP,RESOURCE_EXHAUSTEDand
UNAUTHENTICATED("invalid or missing tenant credentials") on gRPC. -
CI no longer pulls the AWS CLI image from ECR Public to create a test
bucket (issue #2036). ECR Public caps anonymous pulls per source IP, and
hitting that cap failedbench-smoke,quickstartand
object-store-contract. Those jobs and the MetricsBench nightly now create
their RustFS bucket with the runner's ownawsCLI through
scripts/ci-create-bucket.sh, which retries only the create call's own
transient failures. The CI quickstart applies a CI-only compose override,
deploy/docker-compose/ci-host-bucket.yml, that turnscreatebucketinto a
no-op on the locally built image; the documented quickstart command is
unchanged.demo/kill-and-recover.shnow restartsravel-serverwith
--no-deps, so replacing the server no longer re-runs the stack's one-shot
services. -
Retention no longer deletes logs or spans objects whose format version
this build cannot read (issue #530). The physical sweep's version hold
covered metrics only, so after a binary rollback across an RLOG or RSPAN
format bump, a tombstoned logs or spans bucket holding objects written at
the newer version was deleted once its protection horizon elapsed, even
though the newer build could still read them. The sweep now reads each
data object's trailer through the gate of the bucket's own format (one
16-byte ranged GET per object, the cost metrics already paid) and, if any
object is outside this build's reader window, deletes nothing in the
bucket, logs a warning, and counts the objects on the same in-process
held-object counter, exactly as it does for metrics. A corrupt trailer is still swept. ADR-0531 and ADR-0066
carry dated amendments recording the wider hold. -
A PromQL alert rule raises one alert per matching series (issue #117,
ADR-0117). A rule used to collapse its result vector into one alert with
the rule's labels; each series that satisfies the condition is now its own
alert, whose labels are the series labels without__name__overlaid by
the rule labels, and whose identity hashes those labels. The Alertmanager
sink'salertnamestays the rule id, or the rule's ownalertnamelabel
when it sets one; a series label namedalertnamenever replaces it and,
when the rule sets noalertname, is sent asexported_alertname(with
Prometheus'exported_exported_rule on a further conflict) so series
differing only in that label stay distinct Alertmanager alerts. A
series that stops matching resolves only its own alert, and
repeat_intervalapplies to each alert on its own. A rule matching more
than 1000 series fails the tick withTooManyAlerts, and two series
merging to one label set fail it withDuplicateAlertIdentity, whose
message names the label names and the collidingalert_idbut no label
value; neither writes a record, and both count in
ravel_alert_rules_failed_total. A scalar query and a
SQL rule keep their existing single alert and identity. Two rules in one
tenant may no longer share arule_id, even with different labels: such a
rules file now fails startup. Per-series rules over churning label sets
should wait for alert state pruning (#1438). A new
ravel_alert_undelivered_notificationsgauge reports how many
notifications wait for every sink to accept them; while a sink keeps
failing it grows by one for every alert identity that transitions. A rule
whose write fails partway through its alerts now counts the records
already written inravel_alert_records_written_total.Upgrade notes:
- Expect a notification burst on the first tick. A PromQL rule whose
matching series carry labels besides__name__that the rule labels do
not override gets a new identity for each series. If its single
rule-level alert is pending or firing at upgrade, that tick writes one
Resolved transition for it and one new transition per matching series.
The webhook sink is notified of every one of them, and the Alertmanager
sink of every one except a pending transition. - A rule that fires today can start failing every tick. It fails with
DuplicateAlertIdentitywhen two matching series merge to one label
set, for example a selector over several metric names
({__name__=~"a|b"}) whose series differ only in__name__, or a rule
label that overrides the series label that told them apart. It fails
withTooManyAlertswhen more than 1000 series match. On every tick it
fails, the rule writes no record, and its existing alerts keep their
state. - A rules file in which two rules of one tenant share a
rule_idnow
fails startup withrule id "<id>" is used by more than one rule in tenant "<tenant>". Give each rule its ownrule_idbefore upgrading. - Repeat notifications multiply by the firing series.
repeat_interval
now applies to each alert, so a rule with 500 firing series sends 500
repeat notifications per interval to every sink where it used to send
one. Raiserepeat_interval, or set it to0s, on rules that match many
series.
- Expect a notification burst on the first tick. A PromQL rule whose
Added
-
ravel-serverbuilds a read and a write CPU gate and reports their
queueing on/metrics(ADR-1702, issue #1702). A new crate,
ravel-cpu-gate, holds the gate: a job at or above its inline floor
(256 KiB, or 100,000 samples for a PromQL evaluation) waits for one of a
fixed number of permits, then runs on the tokio blocking pool and keeps the
permit until it returns; a smaller job runs on the calling thread.
--cpu-gate-read-permitsand--cpu-gate-write-permitssize the two gates.
Unset, they aremax(1, cores - 1)andmax(1, cores / 2), and0is
refused at startup./metricsrendersravel_cpu_gate_permits,
ravel_cpu_gate_running,ravel_cpu_gate_queued, the
ravel_cpu_gate_wait_secondsandravel_cpu_gate_run_secondssum and count
pairs andravel_cpu_gate_abandoned_totalpergate, plus
ravel_cpu_gate_jobs_totalandravel_cpu_gate_inline_totalpergateand
site, and the tokio runtime'sravel_runtime_workers,
ravel_runtime_alive_tasks,ravel_runtime_global_queue_depthand
ravel_runtime_worker_busy_seconds_total. No decode or encode path submits
work to the gates yet; the later ADR-1702 tasks move them. -
ravel-cli export --signal logswrites a tenant's stored logs back out to
a Parquet fileravel-cli loadreads in (ADR-1751, issue #1712). The
command takes the store and tenancy flags the other read commands take, plus
--tenant, an RFC 3339--start/--endwindow (Zor any numeric
offset, converted to UTC),--parquetfor the output path, and the same
--mappingTOML a load uses to decide which column each field lands in. It
resolves the catalog once, reads and decodes the RLOG objects that snapshot
names, and writes the rows sorted by event time. The
window is half-open, so exporting adjoining ranges writes no row twice and
drops none between them. Exclusion follows the query path rather than a
second copy of the rules: retention tombstones and compaction supersession
are already applied by the catalog resolve, and erasure predicates from that
same snapshot are handed to the segment fetcher and then matched against
each decoded record's merged resource, scope and record attributes by the
same function the SQL log scan calls, so a record a query cannot see,
including one whose erased subject is only a resource or scope attribute, is
a record the export does not write. The mapping's typed attribute
columns round-trip, and the optionalattrs_map_columnadds one map column
carrying every record attribute no typed column covers.--signalhas no
default and today accepts onlylogs;metricsandspansare refused
with the bulk-import follow-up each one waits on, because an exported file
no command can load back is not an export.--parquetis replaced only
once the export finishes, by renaming a temporary file written beside it,
so a failed export leaves an existing file untouched; a path under/dev
or an existing directory is refused before anything is read.
--max-ingest-lagpasses the deployment's ownravel-server --max-ingest-lagthrough when it differs from the 2h default, and refuses
zero as the server does. The whole window is decoded into memory
before the first row is written, so a wide range wants several narrower
exports. The ingest guide's bulk-export section covers that and the
round-trip caveats, includingts_unittruncation and the fact thatload
does not readattrs_map_columnback, and that no mapping key names a
record's observed time, flags, instrumentation scope name and version, or
scope attributes, so export drops them. Export costsravel-clia normal
dependency onravel-query, which is what sharing the query path's
exclusion rules rather than copying them is worth (ADR-1751 amendment,
2026-09-26). -
ravel-server --listen-health <addr>serves liveness and readiness from
its own thread (ADR-1702). The listener runs on a dedicated
single-threaded runtime and serves only/healthz,/readyz,/-/healthy
and/-/ready, so a node whose main-runtime workers are all busy still
answers its probes. A heartbeat task on the main runtime keeps it honest:
/healthzthere returns 503 once the heartbeat is older than 60 s, and
/readyzonce it is older than 30 s or any existing readiness condition
fails. The flag is unset by default, so nothing binds, and the routes on
--listen-httpare unchanged. Startup refuses a--listen-healthaddress
equal to any other listener's, and shutdown waits at most 5 s for open
health connections before dropping them, plus a 1 s join margin, so the
worst case from SIGTERM to exit grows by up to 6 s (32.5 s to 38.5 s). A
hand-written manifest that sets the flag needs a termination grace period
to match. -
The operator can point both probes at a dedicated health port with
spec.probes.dedicatedHealthPort(ADR-1702, issue #1702). Set to true,
every gateway, query, and maintain container gains
--listen-health 0.0.0.0:4316, a container port namedhealthon 4316,
and liveness and readiness probes on that port instead of 4318, with the
same paths, period, timeout, and failure threshold as before. The health
listener runs on its own thread, so a main runtime busy decoding segments
can no longer let a probe time out and have the kubelet restart the pod.
Those pods also getterminationGracePeriodSeconds51 instead of 45, to
cover the health listener's longer shutdown.
The same routes stay on 4318 as well, so anything already probing the HTTP
port is unaffected, and theravel-ingest-routerDeployment keeps its
probes on 8080. The field defaults to false in this release and flips to
true one release later; setting it needs aravel-serverimage from this
release or newer, since an older server rejects the unknown flag and
restart-loops. -
spans.linksdecodes span links into a structured, filterable column
(issue #1710). Symmetric toevents, it is built from the plain
attrs["_links_raw"]protobuf blob at scan time, on every RSPAN version,
since RSPAN is a frozen persistent format and promoting links into a
nested on-disk column would need an ADR and a version bump. NULL when a
span carries no link, or when its_links_rawvalue is malformed (bad
hex, bad framing, a link chunk missing a well-formedtrace_idor
span_id, a non-UTF-8trace_state, or atrace_id,span_idor
trace_statefield that is not a length-delimited value), never an empty
list or a fabricated field; one malformed link makes the span's whole
linksvalue NULL. Selectingeventsorlinksturns off the columnar
fast path. On a single node, a query that selects neither never builds
those columns; under distributed execution each worker still builds both
for every row it returns and the coordinator drops them. A bare single-node
SELECT count(*) FROM spansnow returns the row count, with or without a
pending erasure, instead of failing with "must either specify a row count or at least
one column". -
/metricsnow renders a per-shard ingest skew family (issue #1692).
ravel_ingest_shard_messages_enqueued_total,
ravel_ingest_shard_messages_processed_total,
ravel_ingest_shard_queue_depth,
ravel_ingest_shard_on_actor_seconds_total,
ravel_ingest_shard_flush_permit_wait_seconds_total, and
ravel_ingest_shard_off_actor_seconds_totalrender one series per
configured shard, labelledmode,signal, andshard, with idle shards
reading zero rather than being omitted. Zero-fill covers shards 0 to
--shards - 1; a tenant resharded above--shardsrenders its extra
shards once they record activity. The span pipeline now counts enqueued
messages inspan_router.rsand records on-actor and off-actor time in
span_shard.rs, matching the metrics and log pipelines, so all six series
carry live figures on all three signals.
Changed
- A transient object-store error on an audit-record PUT is retried before the
batch fails closed (issue #2035).write_audit_batchused to fail an
entire batch of queries on a single object-store timeout or throttle
response, even though the object store's own client-side retry never covers
a conditional PUT (PutOptions::create_if_absent(), the mode both the data
object and the commit record use, is never marked idempotent by the S3
client, so aTimeouton it skips the client's retry loop entirely). Each
PUT now retries up to two more times with a short jittered backoff when the
error is one already classified as transient, and only a non-retryable
error or one that keeps failing across every attempt still fails the batch
closed, exactly as before. A 30 s budget bounds each tenant group's
writes in a flush, both PUTs and every retry, so a hung store still fails
closed rather than stretching out for the full retry ladder. A flush
writes its tenant groups one after another, so against a hung store a
query can wait up to 30 s for each group ahead of its own. A throttle
response's retry-after hint is honoured when it fits the budget, and a
commit record found already stored on a retry counts as written when its
bytes match, since the earlier attempt landed and only its acknowledgement
was lost. Every retried attempt is now counted on/metricsas
ravel_audit_put_retries_total. - The default query request budget is now derived from the unsealed tail a
healthy catalog carries plus the fold-stall alert window (ADR-1306). At 4
shards and a 2 s flush cadence it gives 343,400 requests instead of 15,800,
so a wide query is not refused for fold lag before the fold-stall alert
pages. Each unsealed flush is budgeted at 8 requests, which covers flushes
above the fetcher's 512 KiB whole-object threshold: one fetch of an L0
segment now issues at most 4 page-range GETs, bridging the smallest gaps
between the page runs a query selects when there are more, so it costs at
most 7 GETs plus its commit-record GET. The bridged gap bytes are fetched,
reserved against the process fetch memory budget and charged to
max_bytes_scanned, so a selective query over large L0 flushes can read up
to about the object size per segment, the same order as the existing
whole-object fallback. The per-flush figure holds per selector: each
selector fetches the segment again, so an N-selector query can spend up to
1 + 7Nrequests per flush, which the derived budget does not scale for. An
explicit--max-s3-requestsis still used as given. The seal margin the
span is built from is the one the server's own catalog folds and resolves
with, read off theCatalogConfigitsbuild_catalogconstructs rather
than from a compiled-in reference. No flag moves that margin today; when
one does, the budget follows it with no hand recomputation. The
shippedRavelCatalogFoldStalledalert is now held to the same span by a
test: its threshold must be that seal margin and itsfor:the 600 s the
derivation budgets, so raising either in
deploy/prometheus/ravel.rules.yamlfails a gate rather than silently
moving the first refusal ahead of the page.ravel-serverlogs the resolved
budget once at startup with the span it is measured against
(max_s3_requests,source,covered_span_secs,seal_margin_secs),
derived or explicit alike, so an operator who pins--max-s3-requestscan
see which span their value undercuts. Scalar and histogram
pages are now fetched in one batch, so apage_fetchtracing span can
carrypage_kind="mixed", where it used to be onlyscalaror
histogram. - The scheduled catalog fold now runs only in
--mode maintainand
--mode all, and amaintainfleet partitions it across its replicas
(ADR-1693, issue #1693). Amaintainprocess folds only the
(tenant, signal)pairs it owns under the rendezvous hash and heartbeat
live set that already distribute maintenance units, and it tests ownership
before every per-tenant read, the pair's lifecyclet/<hash>/configrecord
and itsHEADpeek alike, so a non-owner issues no object-store request at
all for a pair it does not own. The only request a tick makes that no owned
pair accounts for is the single delimited listing oft/that discovers the
tenants. Scalingmaintainto N replicas therefore divides the fold's
request cost N ways instead of running N full copies of it, and a replica
leaving the fleet hands its pairs to the survivors within 570 s at the
defaults: the 180 s liveness window, plus the survivor's next 60 s heartbeat
tick, which is when it re-lists and recomputes the live set, plus its next
fold tick, 300 s with up to 10% jitter. That bounds the survivor's first
tick over a pair, not always its re-fold: a pair the departed replica
folded just before leaving keeps aHEADyounger than the 300 s fold
interval, a survivor tick before then skips it as fresh, and the next tick
is at most 330 s later, so the re-fold lands within 300 + 330 = 630 s of
departure. The liveness window alone bounds when the departure becomes
visible, not when the pairs are folded again.
--mode allcomputes its live set as itself alone and keeps folding
everything.gatewayandqueryprocesses no longer fold on a timer. A
queryprocess keeps the on-demandPOST /api/v1/admin/foldroute, which
is unchanged; agatewayprocess mounts no fold route at all, as before. A
deployment running neithermaintainnorallno longer folds on a timer
at all, and must add amaintainprocess.--disable-foldand
--fold-interval-secsare now refused at startup in--mode gatewayand
--mode queryinstead of being accepted and ignored, and the operator
renders them on the maintain Deployment:spec.maintain.foldreplaces
spec.gateway.fold, which is now refused with aDegradedcondition
(reasonGatewayFoldUnsupported) naming the field that replaces it.
ravel_catalog_fold_last_success_timestamp_secondsnow renders only in the
two modes that fold on a schedule, so no process publishes a permanently
stale gauge andRavelCatalogFoldStalledfires through itsabsent()arm
on a fleet with no scheduled fold;ravel_catalog_fold_cycles_totaland
ravel_catalog_fold_failures_totalrender wherever a fold can run, the
querymode's on-demand route included, so an on-demand fold's failures
stay visible. The fold also reads the injected maintain clock rather than
the system clock. Each signal's fold loop now runs under a supervisor: a
panic in a tick body is caught, counted on the new
ravel_catalog_fold_loop_restarts_total{signal}counter, logged at error
level, and the loop is respawned after a bounded backoff doubling from 1 s
to 60 s and resetting after a completed tick; the respawned loop ticks as
soon as its backoff ends, so a loop panicking on every tick restarts 20
times in its first 15 minutes and 15 times in every 15 minutes after at the
defaults. The supervision is not
optional under a partitioned fold: a replica whose loop dies keeps
heartbeating, so it stays in the live set, keeps its pairs, and leaves them
unfolded while its peers' fresh gauges holdRavelCatalogFoldStalled
(max by (signal)) under its threshold. The new
RavelCatalogFoldLoopCrashLoopingrule in
deploy/prometheus/ravel.rules.yamlfires on more than 5 restarts in 15m,
unaggregated, because the condition is about one replica. It carries
for: 15m: a loop panicking on every tick crosses the threshold 31 s after
its first panic and pages about 15.5 minutes after it, while a single
transient panic counts one restart. Supervision covers panics only: a tick
that hangs on a store call that never returns moves no restart counter, and
no shipped alert catches it on one replica of several, since only that
replica's ownravel_catalog_fold_last_success_timestamp_secondsstops
moving andRavelCatalogFoldStalledreads the fleet-wide maximum.
On upgrade, a
RavelClusterwithspec.gateway.foldset now fails the render before any
tier renders, so the whole cluster stops reconciling until the field moves
tospec.maintain.fold; a hand-written manifest passing--disable-foldor
--fold-interval-secsto a gateway or query container now fails at startup
instead of ignoring the flag; and during a rolling upgrade an old-version
maintain pod sits in the live set without folding, so the pairs the hash
gives it stay unfolded until the rollout completes. - A distributed-query worker resolves each pinned segment from that segment's
own commit record instead of re-resolving its catalog (issue #1721). Every
fragment request used to re-resolve the worker's catalog to map the
coordinator's pins back to segments, so a compaction committed between the
coordinator's resolve and the worker's fetch could invalidate the slice and
cost a full re-resolve and re-dispatch. The worker now rebuilds the key of
the pinned segment's own record from the identity the coordinator shipped
with the ADR-0010 key builders: the commit record for an L0 segment, and the
compaction record (or, when none exists, the erasure rewrite record) for a
compacted L1 segment. It GETs that one record, checks that the record's own
fields address the key it was read from, verifies the data-object key with
verify_object_key(or the L1 object-key reconstruction), and compares the
full 32-byte content hash, the object size, and for L1 the full input-set
hash andpart_indexagainst the identity. The segment ref it reads is
built from the verified record alone. A worker lists nothing and reads no
manifest on the intra-cluster fetch path; it issues one record GET per
pinned L0 segment, one per pinned L1 segment, and two for an L1 segment only
a rewrite record describes. Each record GET holds a permit of the worker's
process-wide GET limiter, the one its data-object GETs use. Record GETs are
not charged to the slice's accounting, so they count toward neither
max_bytes_scannednormax_s3_requestson the worker or the coordinator,
and a query that fits its budget locally also fits it distributed; they are
also not in the query's reported cost, which the slice summary carries as
one pooled figure. Off the budget is not unreported: a worker exports the
totals asravel_distrib_fragment_record_get_requests_totaland
ravel_distrib_fragment_record_get_bytes_total, process-wide counters
beside the otherravel_distrib_fragment_*series, carrying only the
modelabel. A record that is missing, unreadable, fails verification,
or disagrees with the identity fails the fragment withUNSUPPORTED, and the
coordinator runs the query locally; a malformed identity isBAD_DATA. A
throttled, timed-out or transient error on the record GET fails an inbound
fragment withUNAVAILABLE, and the coordinator re-dispatches that slice to
another worker. On a slice the coordinator runs itself there is no other
worker to re-dispatch to andUNAVAILABLEwould be terminal, so that same
failure fails the sliceSNAPSHOT_INVALIDATEDinstead and the coordinator
re-resolves and retries once, keeping the recovery the catalog re-resolve
had. That covers both local arms: a self-mapped or unroutable slice, which
is dispatched straight to local execution with no remote attempt at all, and
the fallback after a remote worker and its one re-dispatch both failed. Only
the resolve phase moves; a fetch-phaseUNAVAILABLEstill means the segment
reads themselves are failing and stays terminal.
Records and the objects they name are immutable, so the pinned read is
unaffected by whatever the catalog says by then. The queryfrag wire and
PROTOCOL_VERSIONare unchanged, and cross-cluster federation is unchanged:
a resolve-scope request still resolves the remote cluster's own snapshot. - A scrub tick's request count follows its budget, not the corpus size
(issue #1686, ADR-1686). Each content-tier tick used to LIST a shard's whole
commit prefix and GET every record in it before verifying its slice. It now
lists strictly after a start-after marker held in the per-shard cursor and
stops once the tick's budget is filled; the rotation rolls over when the
listing runs out. The budget is a pair of caps, one on listing entries and
one on requests, recomputed every tick from what the walk has actually
observed: every rotation opens with one LIST-only count of the shard's
entries, and every later tick recounts the last two ingest hours the previous
count met, plus anything after them, and adds their growth, so a commit from
any writer on the shard is counted. The per-tick entry cap is the share
needed to reach the rotation's deadline, floored at the rotation's sustained
rate and capped at four times that rate. The deadline is the smaller of
--scrub-periodand half the tenant's retention window, so a rotation that
keeps pace reaches each object by about half its retained life. When the entries per
tick the deadline needs exceed four times the sustained rate, the tick
incrementsravel_scrub_behind_total{signal}and logs the entries per tick
needed beside the number allowed, with the deadline and the period. Every
listing page the walk draws and every record GET attempt is charged against
the request cap; the LIST-only count passes are not. A unit where a record or
object GET failed with a retryable error (throttled, timeout, transient) does
not move the marker past it and counts nothing from it, so the next tick
retries the whole unit. That hold is capped at six consecutive held ticks on
one unit, six hours of tick cadence at the default one-hour tick (at most 6.6
with the jitter the loop adds to every sleep): the next tick that reaches the
unit moves past it and counts each record or object that still fails
retryably on
ravel_scrub_unreadable_total{reason="retry_exhausted"}, and the new gauge
ravel_scrub_marker_held_ticks{signal}reads the current held count of the
signal's worst shard. A record or object GET that fails with any other
error except not-found, and a record whose bytes do not decode, is counted
once on the newravel_scrub_unreadable_total{signal, level, reason}(with
reason="access_denied"orreason="permanent", at the level of the object
or record that failed), and the marker moves on, so one unreadable record
cannot pin the rotation. Neither is counted on
ravel_scrub_checksum_mismatch_total, which counts only bytes that were read
and did not match; a newRavelScrubUnreadablewarning alert fires on any
increase of the unreadable counter over an hour. A compaction record that
lands in an hour the marker has already passed is still judged against that
whole hour, and a tick whose cursor GET fails for any reason other thanNotFound
is skipped with the stored cursor left alone. The cursor gains serde-default
fields, so a cursor from an earlier release loads with defaults and its
progress restarts: the next tick starts a fresh rotation. During a
mixed-version rolling upgrade each version's cursor write drops the other's
new fields, so the rotation restarts each time a shard's ownership flips
between versions; nothing is corrupted and the cursor stays in object
storage. A retention window whose half fits in one tick gives every tick the
budget of a whole rotation, so every tick normally walks a full rotation and
pays the LIST-only count of the whole commit prefix that opens it, whose cost
is not charged against the budget.
ravel_scrub_cursor_positionis now a fraction of listing entries rather
than of data objects. Every object the walk reaches and can read is still
verified once per rotation, except that a retried unit's records and objects
are fetched again,
and the hour re-list that judges a late compaction record GETs that hour's
compaction and rewrite records already consumed a second time. A record committed behind the marker waits for
the next rotation. - PromQL fetches now reserve against the same process-wide memory budget
as SQL execution (issue #1255).ravel-serverhands the PromQL engine
and the startup cache warm pass the oneMemoryBudgetits SQL executor
already uses, and with--distributed-queryon it hands the same budget
to the fragment service that runs metrics slices, both for remote workers
and for the coordinator's own no-hop local path.
An RSEG or RLOG fetch for a PromQL query that needs more than the budget's
remainder fails withFetchMemoryExhausted(HTTP 503 on the PromQL API)
instead of running unbounded, and the next query is admitted as before. A
cache-warm fetch the budget refuses is skipped and logged rather than
failing startup.
The/metricsgauges now report real values:
ravel_memory_reserved_bytes{component="fetch"}is the bytes held by live
fetch reservations,component="sql"is the rest of the budget's reserved
total rather than all of it, andravel_memory_handoff_overlap_bytesis
the part of the fetch share whose bytes went through the read cache, hit
or miss, whether or not the cache kept them. The SQL path's own RSEG,
RLOG and RSPAN fetchers still reserve against private unlimited budgets. ravel-serverresolvescost-basedlogs fetching on every deployment
again, including a--store s3deployment against a loopback
--s3-endpoint(ADR-2023 decision 1, issue #2023). 0.18.0's loopback
byte-minimaldefault (ADR-2014) is withdrawn: under ten concurrent
queries on the ClickBench reference machine it cut throughput to about a
third ofcost-based's (0.123 against 0.400 queries per second).
--logs-fetch-policy byte-minimalremains available as
an explicit opt-in; only the unset default changes. The startup line's
policy_sourcenow reads onlyflagordefault; the
derived-loopback-endpointvalue is gone.ravel-server's catalog byte cache is sized independently of
--cache-max-bytes(ADR-2023, issue #2023).--cache-max-bytesnow
bounds the query fetcher cache only; the catalog byte cache derives its
own share of the process memory budget (CATALOG_CACHE_MEMORY_PERCENT,
5%) regardless of--cache-max-bytes, or takes a new
--catalog-cache-max-bytes <BYTES>flag explicitly. A deployment that
relied on--cache-max-bytesto grow the catalog byte cache above its
default now also needs--catalog-cache-max-bytesset explicitly to get
the same catalog cache size. The reverse also changes: a deployment that
set--cache-max-byteslow to constrain memory used to get a catalog
byte cache that small too, and now gets the catalog's own 5% share (about
1.5 GB on a 30 GiB host) unless--catalog-cache-max-bytesis also set.
Startup still refuses to start when the two caches' resolved ceilings
together reach or exceed the process memory budget, exempting
--disable-cacheas before. With--cache-dirset, each cache's disk tier
is bounded by that cache's own RAM ceiling, so the catalog disk tier no
longer follows--cache-max-bytes.ravel-serverderives a larger fetcher-cache share on a loopback S3
store (ADR-2023, issue #2023). Unset,--cache-max-bytesused to
always derive 25% of the process memory budget; now, a--store s3
deployment whose--s3-endpointis loopback (the predicate that also
gates a plaintext endpoint) derives 40% instead, so a fetch cache holding
the working set's whole objects serves repeated statements without going
back to the store's disk. An
explicit--cache-max-bytesalways wins, and every other deployment
keeps the 25% share. The resolved value's source (budget-carve-loopback)
is logged on theperformance default resolvedstartup line alongside
cache_max_bytes. With--cache-dirset, the fetcher cache's disk tier
grows with it, on the same disk the store reads from.
What's Changed
- docs: add ADR-1702 and ADR-1306, amending ADR-0075 by @pmoust in #2013
- test(query): measure cold requests per unsealed flush by phase by @pmoust in #2019
- test: move cache watchdog helpers into ravel-test-support by @pmoust in #2020
- feat(metrics): render per-shard ingest skew for every signal by @pmoust in #2021
- feat(sql): decode span links into a structured spans.links column by @pmoust in #2026
- feat(server): charge PromQL fetches to the process memory budget by @pmoust in #2025
- feat(scrub): resume from a start-after marker with a per-tick budget by @pmoust in #2030
- feat(query): resolve pinned segments from their own records on workers by @pmoust in #2029
- feat(server): fetch whole objects on loopback again, split cache flags by @pmoust in #2024
- feat(server): run the catalog fold on the maintain tier, partitioned by @pmoust in #2028
- feat(cli): add export --signal logs by @pmoust in #2022
- feat(server): add the isolated health listener and runtime heartbeat by @pmoust in #2027
- feat(query): size the request budget for the unsealed tail by @pmoust in #2033
- fix(ci): create RustFS buckets with the runner's aws CLI by @pmoust in #2039
- fix(maintain): hold logs and spans buckets on an unreadable version by @pmoust in #2042
- docs(adr): accept ADR-2023 with its acceptance figures by @pmoust in #2045
- feat(operator): probe a dedicated health port behind a CRD field by @pmoust in #2041
- fix(server): admit and authenticate ingest before reading the body by @pmoust in #2034
- fix(maintain): retry a transient audit PUT before failing the batch closed by @pmoust in #2037
- fix(chaos): make the chaos lane's helpers and exit codes hold by @pmoust in #2038
- feat(cpu-gate): add the CPU gate crate and its server metrics by @pmoust in #2047
- Narrow the postings-absent assertion to POSTINGS fields by @pmoust in #1941
- feat(server): size the request budget from the running seal margin by @pmoust in #2058
- feat(alerting): raise one alert per matching series by @pmoust in #2018
- chore(release): prepare 0.19.0 by @pmoust in #2061
Full Changelog: v0.18.0...v0.19.0
Downloads
The attached binaries were extracted unmodified from the signed container
images this release published, so each one is byte-identical to the file inside
the image you can pull. They are glibc-dynamic, built against Debian 12
(the runtime image is gcr.io/distroless/cc-debian12), so they need glibc 2.36
or newer. On an older distribution, run the container image instead:
docker pull ghcr.io/nofireai/ravel-server:0.19.0One binary per platform, named <binary>-<os>-<arch>:
ravel-server-linux-amd64,ravel-server-linux-arm64ravel-cli-linux-amd64,ravel-cli-linux-arm64ravel-operator-linux-amd64,ravel-operator-linux-arm64ravel-ingest-router-linux-amd64,ravel-ingest-router-linux-arm64
Verifying the downloads
SHA256SUMS covers every attached binary and symbol archive. Check the files
you downloaded, then verify SHA256SUMS itself against the signature and
certificate beside it. The signature is keyless, bound to this release
workflow's identity, so there is no key to obtain:
sha256sum --check --ignore-missing SHA256SUMS
cosign verify-blob \
--certificate SHA256SUMS.pem \
--signature SHA256SUMS.sig \
--certificate-identity 'https://github.com/NOFireAI/ravel/.github/workflows/publish-images.yml@refs/tags/v0.19.0' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
SHA256SUMSDebug symbols
The shipped binaries are stripped of debug info but keep their symbol table,
so a backtrace resolves function names with nothing extra installed. Line
numbers come from the matching symbols archive. Each binary records the
basename of its symbol file, so unpacking the archive next to the binary is
enough and no symbol-file step is needed:
tar -xzf ravel-debug-symbols-linux-amd64.tar.gz
gdb ./ravel-server-linux-amd64gdb looks for ravel-server.debug in the same directory (and in
.debug/), which is the name inside the archive. Do not rename the extracted
.debug files.