The operator release. A spool can now be reloaded, drained, credential-rotated and
validated without dropping a connection or guessing, and it will tell you which version of
itself is running. It also gains a commons — one optional shared scope per spool, relayed
but unreadable. Pre-1.0, so every interface below is still subject to change; the wire is
additive only, with no records or error codes removed or changed, so a 0.1.0 client talks to
a 0.2.0 spool unchanged.
Added
-
SIGHUPreloads the configuration — quotas, rate limits and credentials change without
restarting, so no client loses its connection to a settings change.SPOOL_RELOAD_FILEnames a
KEY=valuefile layered over the environment; the environment itself cannot be the source,
becauseSystem.getenv()is fixed at exec and a container's variables cannot change without
recreating it. What a reload can and cannot move, and when each takes effect, is in the README.
Naming an unreloadable value is logged and ignored rather than fatal, and an unreadable or
invalid file leaves the running configuration untouched. -
SPOOL_TOKEN_NEXT, a second accepted credential — rotation becomes add-new, migrate,
promote, retire instead of a cutover that locks out every client until it updates. Both tokens
are accepted for as long as both are set; anything that is neither is still refused. Compared in
constant time with no early exit, so the timing does not say which credential was presented. -
Drain mode, toggled by
SIGUSR1— new connections refused503with aRetry-Afterwhile
the live ones keep being served. There was nowhere to stand between "serving" and "stopped":
shutdown closes every session at once, so on a busy spool an upgrade sent every client back on
the same second. Now you drain, watch the connection count fall, and then stop.SIGUSR1rather than a secondSIGTERM, which is whatdocker stopsends before itSIGKILLs
— a two-phase TERM would drain and then be killed mid-drain by the ordinary stop path. It reuses
the same transport refusalSPOOL_MAX_CONNSalready uses, and for the same reason: §7.1 has no
close code that means "come back later"./healthzdeliberately keeps answering200— the container HEALTHCHECK, both CI pipelines and
compose'sservice_healthygate all probe it, and a drain that failed it would restart the
container mid-drain. Counted byknit_spool_drain_refused_total, kept apart from
knit_spool_conns_refused_totalso a planned drain never reads as a box out of room, and shown
asdraining=yeson the status line. -
knit-spool check, which validates the environment, prints every resolved value, and exits
0valid or1invalid — without binding a port, opening a store, or creating a directory.
Confirming a configuration previously meant starting a daemon and reading its logs, which is a
poor fit for provisioning that wants to know a config is good before a container starts, and
for testing a tier template in CI. Resolved config on stdout, warnings and errors on stderr, so
one can be parsed without filtering the other;SPOOL_DATA_DIRis checked for a writable parent
and deliberately not created. It checks configuration, not store state — a commons that will not
fit because the store already holdsSPOOL_MAX_SCOPESscopes still fails at boot. -
The effective configuration is logged at boot, one
k=vline carrying every resolved value —
defaults included, because the value an operator misremembers is always the one they never set,
and across a fleet that is a support call rather than a shrug.configFromEnvvalidated all of
it and then kept the answer to itself; the only startup line reported port, PoW bits, and whether
a token was set.Tokens are reported as
set/unsetand never printed, and the commons appears as a truncated id
for the same reasonhellonever carries it at all — publishing it would turn a room only invite
holders can find into one anybody who connects could subscribe to. -
A build stamp the daemon can report about itself, and
GET /sourceto serve it: the running
version, the commit it was cut from, and a corresponding-source URL. Nothing in the running
process knew any of that before — the jar carried no manifest attributes, and a fleet had no way
to answer "what is actually deployed".knit_spool_build_info{version,commit}is the labelled
gauge a dashboard joins against, and one startup log line says the same thing./sourceis unauthenticated on purpose. AGPL §13 obliges anyone running a modified version to
offer its source to the users whose clients connect, so an offer behindSPOOL_TOKENwould not
be one; a fork setsSPOOL_SOURCE_URLand the offer points at their repository instead of
upstream's. Both shipped proxy configs pass it through, and both now say why./healthzis
untouched.Version and commit arrive as
-PspoolVersion/-PspoolCommit, so neither is stored in the
tree and a build told neither honestly reportsunknown.Dockerfiletakes them as build args
—.dockerignoreexcludes.git, so there is no repository in the image context to ask. -
SPOOL_METRICS_TOKEN, a scrape credential separate from the connect credential (default
unset, which keeps today's behavior:SPOOL_TOKENgates/metricson a private spool). The two
answer to different people./metricscarries scope counts, live bytes and traffic shape — the
operator's business, not the client's — and until now the only credential that opened it was the
one every client already holds. It also ran the other way: a Prometheus scraping a fleet needed
each spool's connect secret in its scrape config.Setting it replaces
SPOOL_TOKENon/metricsrather than joining it, which is the point —
a client holding the connect token gets403. That does mean a scrape configured as
?k=$SPOOL_TOKENstops working the moment the new variable is set; nothing changes until it is.
A public spool can set it on its own to gate metrics without becoming private. -
A commons: one shared scope per spool (spec §7.4), off unless
SPOOL_COMMONS_IDis set. It
turns a spool from pure infrastructure into a place — everyone on it who holds the invite can talk
to everyone else, sealed end to end like any other scope.The operator mints an invite with
knit-spool commons-inviteand configures only
SHA-256("knit/spool/v1/commons" ‖ secret). The secret goes to members, so the spool relays a
room it cannot read, and this repo deliberately implements no content-key derivation at all —
the property is structural, not a promise.On the data path a commons is an ordinary scope: no new record types, no new error codes. What is
new is the policy that makes a shared scope survivable. Its bounds are operator-pinned, because
the store applies whatever the most recent subscriber declared and one member asking for
maxFrames = 1would otherwise evict the whole room's history. It is created at boot, so it is
never an unknown scope and the §6.4 PoW and new-scope gates never fire for a member joining. It is
pinned against the storage watermark, which may never shed it. And it carries a spool-wide push
budget (SPOOL_COMMONS_RATE_PUSHES, default 20/s) that the per-connection limit cannot bound —
200 members at 10/s each is 2,000 pushes/s into one scope — which throttles without striking the
connection, since congestion on a shared room is not evidence any one member misbehaved.helloadvertises the room's bounds and an optional name but never its scope id: the id comes
from the invite, and a spool that published it would turn a room only invite holders can find into
one anybody who connects could subscribe to and flood. Observability is
knit_spool_commons_subscribers,knit_spool_commons_pushes_total,
knit_spool_commons_rate_limited_total, andcommons=Nsub/Nfin the status line — all absent
entirely on a spool with no commons. The conformance suite gainscommons-advertisement,
commons-bounds-pinned, andcommons-fanout; the latter two need--commons-inviteand skip
without it. -
SPOOL_MAX_CONNS, a total-connection cap (default0, unlimited — the daemon had no global
connection limit before this, only per-IP). At the cap the WebSocket upgrade is refused503
with aRetry-Afterrather than accepted into a box that has no room for it. Deliberately not a
close code: §7.1 defines four, none of them means "come back later", and4003 abusewould tell
a client it misbehaved when it did not. A full spool is a property of the hardware, so it is
answered at the transport, where a multi-homing client already handles it as one more unreachable
spool. Counted byknit_spool_conns_refused_totalandrefused=+Nin the status line; the
configured ceiling is exported asknit_spool_max_connsand shown asconns=N/max.
Fixed
- The conformance runner's exit code
3is documented.Report.summary()has always returned
it for a run where no MUST check failed but one or more could not be judged — the transport broke,
or the tool itself hit a bug — and the README listed only0,1and2. A CI job written from
that list treats an inconclusive run as a passing one, which is the opposite of what the code
intends.
Changed
- Scope ids are truncated in the log. The watermark's shed warning carried a full 64-character
scope id; it now carries the first eight, and the whole id moved toDEBUG. A blinded spool
ships its lines to an aggregator that is not blinded — it retains them, indexes them, and
outlives the scope — so a full id atWARNwas the one identifier this daemon holds that
survived contact with the outside world. Eight hex characters still follow one scope across a
single log stream. The store's self-heal line already truncated, to twelve; both now go through
one helper so there is one convention rather than two. A malformedSPOOL_COMMONS_IDis no
longer echoed back in full either: one mistyped character in a real id would otherwise have put
a near-real scope id in anERRORline. deploy/docker-compose.tiny.ymlis sized against measurements rather than caution. A 1 GB
box holds ~2,400 concurrent clients before a container is OOM-killed — 41 KB of JVM heap and
~110 KB of Caddy per connection — so the overlay now targets ~2,000 of them: 4096 scopes (was
512), 8 GiB of payload (was 256 MB), 32 KiB blobs (was 16 KiB), 4 MiB of attachments per scope
(was 1 MiB), 256 connections per IP (was 64), 30 new scopes/min per IP (was 6), a 5-minute sweep
interval to keep the sweeper's now-longer store-thread stall rare, andSPOOL_MAX_CONNS=2000so
the ceiling is enforced rather than discovered.- The 1 GB overlay splits memory the other way. Caddy, not the daemon, is what runs out first:
at its old 128 MB ceiling it was OOM-killed at ~1,100 connections, taking every subscriber with
it. It now gets 288 MB and aGOMEMLIMIT, and the daemon 352 MB with a 192 MB heap. HOSTING.mddocuments what the tier actually holds, with the per-connection, sweeper, store
thread, transfer, and disk figures behind the numbers above.
Container image
Published to GHCR and Docker Hub for linux/amd64 and linux/arm64:
docker pull ghcr.io/getknit/knit-spool@sha256:4455834fe7f3a46a264f8f0954499176488a43873521f846106a860884dda363
docker pull docker.io/getknit/knit-spool@sha256:4455834fe7f3a46a264f8f0954499176488a43873521f846106a860884dda363Pin the digest in SPOOL_IMAGE rather than a tag — see deploy/.env.example. The GHCR
image carries a signed build provenance attestation:
gh attestation verify oci://ghcr.io/getknit/knit-spool:0.2.0 --repo getknit/knit-spoolStandalone archives
For running the daemon without Docker. SHA256SUMS covers every archive attached here.
Both need a JDK 21 runtime on the host; unpack and run bin/knit-spool.
Upgrading a live spool? HOSTING.md covers the operational side, and CHANGELOG.md calls
out anything that moves the wire, the on-disk store, or a configuration default.