Operating Colca¶
Health¶
GET /healthz answers as soon as the node is up. Its storage.state turns
failing when the database cannot flush or compact, most often because the disk
is full; since and error say when and why. It returns to ok after the next
successful flush. The node logs the first such error at ERROR and repeats it at
most every 30 seconds with a repeats_suppressed count. GET /metrics serves
Prometheus metrics; scrape it over the local door so that no self-signed
certificate is involved.
Metrics describe progress as numbers, not connection states. The ones worth an alert first:
| Metric | Alert when |
|---|---|
time() - colca_uplink_last_success_timestamp_seconds |
the uplink has not succeeded for longer than you can tolerate (only on nodes with a parent; it stays 0 on a root) |
colca_cursor_lag_records |
a consumer falls behind |
colca_retention_pressure |
above 1: the pruner wants to remove more than a cursor allows, and the disk grows |
colca_stream_live_bytes |
a stream grows past what the disk holds |
colca_cursor_next_record_age_seconds |
age of the next retained record waiting for a consumer; unlike cursor inactivity, this is zero when caught up |
colca_rejected_publishes_total |
rises: clients send what the node refuses; reason says why |
colca_auth_rejections_total |
rises: unknown keys or bad tokens at a door |
colca_replication_integrity_failures_total |
above 0: a child pruned records before its parent received them |
colca_jwks_keys |
0 on a node with an auth: block |
colca_contracts_bundle_info |
source="builtin" where you expected a bundle |
Counters reset when a node restarts. Gauges are read from the database at scrape time and survive restarts.
Retention¶
The pruner removes the oldest records of a stream in one atomic step and never touches the current-state view, which only shrinks through empty payloads.
Every named cursor protects the stream, including the replication cursor that
forms a child's offline buffer. If the age or size limit wants to remove
records a cursor has not read, nothing happens: the stream stays as it is,
colca_retention_pressure goes above 1, and a warning names the cursor.
Disk keeps growing until someone acts.
A configured parent's uplink cursors are persisted during startup, before retention runs, even when the streams are empty or the parent has never been reachable. This protects the first accepted records through an initial outage and restart. Startup fails if this protection cannot be written. Reconnecting advances the cursors only after successful upload; ordinary retention can then reclaim the acknowledged history. This protection is not a disk-capacity limit: size storage for the expected outage, ingestion rate and other local consumers.
To let the pruner give up on a consumer that stopped, set
ignore_cursors_after for that stream. When a run removes records a cursor
had not read, it logs an error and writes one _StreamGap record into the
stream, which replicates up like anything else. Consumers see the gap in
/fetch.
Two things to know:
- A cursor that stays dead produces one
_StreamGapper pruner run that removes something, not a single one. _StreamGaprecords are not shown on the node's own MQTT bus. Read them from the stream or at an ancestor.
Moving a child node¶
A child that is re-parented or taken out of service is drained first: the
parent stops routing new commands to it and waits until every command already
queued has been delivered or has expired. Drains are visible in
colca_drains_active and colca_drains_completed_total.
Backups¶
A node's state is its data_dir. Stop the node, or snapshot the filesystem,
and copy the directory. A restored node that is behind its parent simply
receives newer definitions and commands again; its children push what the
restored node is missing, because their replication cursors never moved past
what was acknowledged.
Keep secrets_dir and the key file separate from data_dir. Losing a
service's own keyring makes its stored secrets unreadable.
Benchmarks¶
cmd/colca-bench runs five scenarios against a real edge and hub pair and
records the results:
make bench-scenarios # ingest, live latency, catch-up, cardinality, footprint
make bench-check # compares the newest results with bench/thresholds.json
make bench # store micro-benchmarks
Results are written to bench/results/<host>.jsonl, which is not committed.
Numbers from a laptop say little about an edge device with eMMC storage; run
the scenarios on the hardware you deploy to before setting thresholds.
Known limitations¶
- No clustering. A node is a single writer on local disk. There is no failover inside a level; availability comes from the level below buffering while a node is gone.
- Retained messages and the current-state view are unbounded. They shrink only when paths are retired, so the number of distinct paths is the bound.
- Without a schema bundle only minimal checks apply. Run nodes with the
bundle generated from
contracts/. - Node administration uses one token per node besides
admin:#grants. - A deadlock in mochi-mqtt 2.7.9 is avoided, not fixed. Colca pins a fork and stops its clients before closing listeners; the workaround goes once the fix is released upstream.
Per-signal metric replication¶
A _Signal accepts replication_policy with two values:
replicate_to_parents(default, including older signals without the field): newly accepted samples are eligible for upward replication.source_local_only: newly accepted samples remain at their source node.
The decision is stored with each sample, under the same storage lock as signal updates. Changing the policy does not cancel already queued uploads. Re-enabling replication does not backfill samples accepted while local-only. Restarts and later signal edits do not change these decisions. Local MQTT delivery, retained values and historian consumers continue to read the original samples.
Signal descriptors still replicate. The uplink sends compact offset ranges for intentionally omitted samples, without their topics, payloads or timestamps. The parent acknowledges those ranges durably without appending metric records. This preserves real offset-gap detection and replay deduplication; an entirely local-only page still exchanges progress with the parent. Reads are bounded by physical records scanned, including excluded samples.
Deploy receivers before enabling the policy at their children. Older receivers reject the progress entries and the child holds its cursor for retry instead of silently discarding data. Upgrade the contracts package and consumers together; older Python signal decoders do not recognize the new field. Downgrading a source to a binary that does not understand its persisted upload decisions is unsupported.
Permanent standalone handover¶
standalone: true is a permanent trust transition, distinct from an offline
parent. Remove parent in the same configuration and restart the node. Before
opening any door the node journals the transition, retires the old parent
cursors and existing externally enrolled identities, and disables the static
administration token. Local services, node identity and process history remain.
External machine clients must be enrolled again by the new local owner.
Commands accepted before handover are retired from HTTP fetch and MQTT replay, including pending local commands. Their stored history and acknowledgements are preserved; new commands remain executable. The retirement boundary survives restarts. Upgrading an existing standalone data directory from 0.2.0 records this boundary once at the first startup, retiring any commands pending at that time.
Human authentication stays closed until a trusted local identity controller has
preserved operators, disabled fleet accounts and transferred administration. The
controller then calls POST /standalone/complete on the local HTTP door.
Completion persists a token issuance cutoff; retries and restarts retain it.
GET /self exposes standalone_since (Unix seconds) and standalone_ready so
other local authentication services enforce the same boundary.
Pre-handover JWTs and personal access tokens are rejected. Only locally authored group definitions and newly created local PATs authorize subsequent access. Cached fleet definitions remain available as data, so an identity controller can copy the last applied operator grants into new, locally owned groups. It must not reuse the fleet group's identity for a competing local definition.
Removing the standalone flag or restoring an old parent configuration against this data directory refuses startup. Rejoining a fleet requires an explicit migration and enrollment plan, including a decision about retained history; a configuration rollback cannot export history or restore the former owner.
This broker transition does not administer an external identity provider, host VPN, SSH access or separate update agents. The deployment's handover controller must retire those connections and credentials before reporting the machine as handed over. Ordinary parent outages do not trigger any of these actions.