Release
Release
Announcing Toise 0.22.0 — no more answers it would not give
Every fix in this release has one shape: Toise knew something and the surface did not carry it. An impossible cadence recited as a bound. A provenance field that meant two different things. Forty-seven thousand lost edges with no trace. A join key offered for twice the cases it covers.
None of them was found by reading the code. Each came from someone measuring: an operator searching logs, a consumer running a fixture, a producer reading its own emitter. In four cases the measurement refuted something the maintainer had asserted. Several prereleases were cut for this release, and more than one was refused on the test bench; the refusals are summarised at the end, because they are the reason the stable is trustworthy.
One answer changes shape for consumers.
A read at a past instant (as_of) no longer fills the live
provenance fields from the present. Who asserts an entity, and how often
it is observed, are live state; replaying the log does not rebuild
them. Those fields are now withheld on a past-instant read, with a
sentence saying what that costs and how to get the answer. Nothing
changes on a live read, and no field is removed; the tool contract gains
one optional field. A 0.21 deployment upgrades in place.
An impossible promise is refused, and the refusal shows
A liveness interval is not an observed fact. It is a promise to speak again, and a producer can promise something impossible. One did: a malformed interval gave a host a 153-year bound, and that host sat in a production graph for eight weeks, immortal, while the read surface quoted the figure back as a legitimate cadence. Nothing marked it as a defect.
The promise is now refused; the entity is kept, because an interval is
not a fact and ADR 0022 stands. "No cadence" has two causes that must not
read alike, nobody promised one or somebody promised an impossible one,
so observation_interval now distinguishes unknown
from none. And the refusal survives a restart: the first cut
guarded live observations only, so a snapshot holding the impossible
interval restored it intact, which is exactly how that host survived
restarts for 56 days. The stored absolute deadline falls with the promise
too; refusing the interval alone would have left a deadline armed in the
year 2177.
A counter now records every refused promise, by producer and entity type, with the largest refused value kept as a gauge. The magnitude is the diagnosis: a few times the bound is a misconfiguration, a thousand times over is a unit error.
A past-instant read no longer borrows the present
The withholding described in the notice above had been designed,
documented in four schema texts, and never wired. Both guards tested for a
condition that is never true on an as_of read, so the
contract stated the intent and the code answered from the present: a
past-instant answer named the producers asserting the entity today beside
its state then. That is a false attribution, not an approximation, and it
is the worst shape of the defect this release corrects. Every other one
was an absence that failed to declare itself, and an absence at least
leaves a reader looking. A false value stops them.
Two null fields and no explanation cannot tell withheld from
unanswerable, so a withheld field carries a sentence naming what it costs
and how to get the answer. GraphQL keeps the defect for now, by decision,
and its schema says so: a field resolver receives only its object and
cannot know the query named an instant. No known consumer selects those
fields on an asOf query today.
Lost edges are counted
An edge whose endpoints have not arrived parks until they do, and a parked edge can be discarded: two expiries, and a third path that needs no clock at all, eviction of the oldest entry when the buffer is full. Forty-seven thousand edges had been lost on one deployment with no trace beyond a warning line in a flood of them. A counter now records discarded edges, labelled by relation type and by the type at each end. The three labels are what makes the figure diagnose itself: one value named a producer's shape and a missing emitter at once, where the relation type alone would only have said that an edge was lost.
The counter covered the two expiries and missed the eviction, so it read zero under the one condition that loses edges fastest: a burst, which is also when an operator is least able to pick a warning out of the log. Found because the bench asked what input makes an edge drop and could not make the counter move. A later prerelease found that both new counters were installed after boot had opened every persisted tenant, so only a tenant created after boot ever counted; both are now installed before the receiver accepts an observation.
Two entity types, two join keys
host.service is a unit the system's supervisor manages: a
systemd unit on Linux, a Windows service on Windows. One type, because it
is one concept. Its identity is the host and the full name the
supervisor gives, since nginx.service and
nginx.socket are two different units and a bare
nginx fuses them into one entity whose attributes oscillate.
Nothing upstream covers this ground yet, so the spelling is an extension
of the OpenTelemetry host.* namespace and it is
proposed, not settled: it may change when an upstream
experience report lands. Choosing an upstream-owned namespace over a
private one is deliberate; a private prefix has no collision risk and
guarantees a re-key the day the vocabularies converge.
k8s.node is the cluster's view of a machine, keyed on the
node UID and carrying the role in the cluster rather than facts about the
machine. A node cordoned while its host is perfectly healthy is what makes
the two distinct.
host.service.name joins a service to its telemetry.
service.endpoint does too, but conditionally:
only a listener that an active check targets has a series carrying it,
and offering it to every listener would be a partial false promise, one
that works often enough to be trusted and then returns an empty filter.
The graph holds the fact that decides, so telemetry_keys
withholds the key where it cannot work instead of offering it everywhere
with a caveat to read.
Also in this release
- An embedded edge now inherits its speaker's interval and deadline on the source. It used to carry a deadline that never lapses, which was sound while an entity's death was the only way its last asserter could vanish, and broken once a shared entity stopped dying when one speaker withdrew. Measured: an edge held by a producer that had explicitly declared its source gone, for as long as the endpoints lived. It now lives exactly as long as that speaker's claim on the source, and the interval is what bounds the survival to one cycle.
-
An empty
asserted_bynow says when something asserts the entity anonymously. A producer that sends no identity is deliberately not named, so the list read empty while the entity was actively refreshed, with nobody to ask. A production host sat in that state for eight weeks, reading as an entity nobody wanted. - The GraphQL schema consumers actually read was stale: the served copy is the one embedded in generated code, and documentation fixes already merged had reached nobody. Descriptions only, no field and no behaviour. One of them had creation described as "the entity did not exist before this event", the inverse of the contract: the graph is exactly as complete as its producers, so absence is never evidence of absence.
-
Documentation: a relation whose two halves have different emitters is
now specified, with the three relations in that situation named in a
table;
rekeyedenters the delete reasons for a migration where nothing happened to the thing and only its spelling changed. The reason says why the entity disappeared; it does not link the old entity to the new one, and nothing in the graph does. And a page explains how to tell a real commit from a restore artefact.
Upgrading
Nothing to do: the data directory is read in place and the log sequence
continues. The change to past-instant reads is additive for every
consumer, since no field is removed. If you consume
telemetry_keys for listeners, expect
service.endpoint only where it can work.
Known, open, and stated rather than omitted
- An entity nobody asserts cannot be removed by anyone (#511): the read surface only annotates, and the only destructive command drops a whole tenant.
-
An empty
asserted_bystill means two different things (#507); the sentence above flags the anonymous case, and the list itself is the same in both. - A speaker's explicit delete of a shared entity does not release its references on the relations it declared at once (#504); the inherited deadline bounds it to one interval.
-
FromandToin the relation registry are read by nothing (#494),commit_seqdoes not survive a restore (#496), and a replica that receives only a shipped log has a complete projection, empty provenance and no liveness, with no answer saying so (#493).
What the prereleases refused
The stable supersedes several betas, and each was replaced because the qualification itself found a gap: a cadence refused live but restored intact; an edge held for ever by a speaker that had declared its source gone; a counter that could not move under the condition that loses edges fastest; a past-instant read answering from the present; and finally two counters that could not fire on any tenant that existed at boot. None of that was found by reading a diff. A test bench that can make an instrument move, or fail to, is what turned these into fixes rather than release notes.
Get it
go install github.com/toise-dev/toise/cmd/toise-server@v0.22.0
Binaries for linux and darwin (amd64 / arm64) are on the release, each with a checksum, and the multi-architecture container image is on GHCR. See the 0.22.0 docs and the changelog.