Deployment¶
pace-kv ships the pieces needed to run it beyond a trusted LAN: authentication, TLS, a Prometheus metrics endpoint, a health probe, and a container image. This page shows how they fit together.
systemd¶
The .deb and .rpm packages install a unit that runs the daemon as a
dedicated pace-kv system user and starts it on boot. See
Install for the package-level workflow;
this section covers what the unit does and how to adapt it.
The unit runs the daemon in the foreground under Type=simple and does
not pass --daemon. That is deliberate: the POSIX daemonize path
double-forks and redirects stdout/stderr to /dev/null, so journald would
capture nothing, and its pidfile is written by the grandchild only after
both parents exit, which races Type=forking with PIDFile=. Running in
the foreground avoids both and gives journald the daemon's stderr directly.
Two things about --daemon changed in the release that fixed
#784, and neither
affects a machine installed from the packages, since no shipped unit passes the
flag:
- A relative
--pidfilenow resolves against the directory the command was run in. It used to resolve against/, because the host changed directory before writing it and only root can write there. An absolute--pidfile— which is what every documented invocation uses — is unaffected. pace-kv --daemonstill changes directory to/; it executes nothing, so that costs it nothing but a busy mount point.pace-node --daemonnow changes to a directory of its own instead, because it spawns a compiler and a worker running in/records rewritten paths in every object it produces.
Leave log_timestamps off — journald timestamps every line already, and off
is the default on Linux.
A start the daemon refuses — a flag it does not know, a configuration file
it read that does not load, a combination of settings a rule forbids, and for
pace-node also a credential file it read and found empty, or an
identity key in its state directory it read and cannot use — exits 78
(EX_CONFIG; systemctl status shows status=78/CONFIG) and is not
restarted: the unit says RestartPreventExitStatus=78, because the next start
would refuse the same configuration the same way. A start that finds a defect in
the binary's own build — a component it was built without — exits 78 as well,
for the same reason: no configuration causes it, and every start meets it. The unit stays failed with
the reason in the journal until you fix it and start it. Every other failure —
a port still held by a process that was exiting, a store another process holds,
and any named file that could not be read, whatever the reason: a
configuration file, a credential or a key that is not there, or
that the service account may not read, or that lives on a share or a mount
that is not back yet — exits 1 and is restarted after a second
(Restart=on-failure), within systemd's default start limit of five starts in
ten seconds. A file that could not be read says nothing about its contents, and
the next start may read it; if it cannot — a path that is simply wrong — the
limit stops the retries.
Measured on systemd 259:
| What happened | What systemd did |
|---|---|
| a refusal, at once or after three seconds | ran it once; failed, status 78 |
| a failure, at once | five runs, then failed by the start limit |
| a failure after three seconds | restarted every time: nine restarts in forty seconds, still going |
systemctl restart by hand, eight times three seconds apart |
every one honoured |
systemctl restart by hand, eight times 0.2 seconds apart |
the fifth refused, "start of the service was attempted too often", and the unit left stopped |
The last row is the default start limit, which counts your starts as well as
systemd's: five starts inside ten seconds are honoured, and a sixth — the fifth
systemctl restart right after a start — is refused and leaves the service
down until systemctl reset-failed pace-kv && systemctl start pace-kv.
Only a start answers 1 or 78. A command that answers and exits —
--install-service, --uninstall-service, --migrate-storage and
--seed-config on either binary, and pace-node's
--print-surfaces, --print-identity, --migrate-cache, cluster,
enrollment and cordon commands — is not supervised, and answers what a
script calling it should do next:
| Exit | Meaning | When |
|---|---|---|
| 0 | done | it did what it was asked |
| 2 | a decision — do not retry | a command line that did not parse (wherever the command was typed on it), a configuration file that did not parse or names a setting that does not exist, an install the startup rules refuse, a service name the registration will not use, access denied, a service that already exists or is not installed, a store in a format it cannot convert or that another process holds, a refusal the scheduler or seed replied with — except the ones listed under 1 |
| 1 | transient — a retry may help | a scheduler, seed or node that could not be reached or broke off the exchange, a reply that decided nothing: another cluster change still committing, an endpoint or fleet at its capacity, a fleet with no worker registered for the toolchain yet (one that is still starting), a cluster with no leader right now, leaders that went on redirecting to each other, a machine that gave up waiting to be approved (run it again once an operator approves it), a file that is not there or could not be read or written (a named configuration file included, as at a start), a service-manager call that failed on its own, a conversion that stopped part-way (run it again to finish it) |
pace-kv --healthcheck is read by a container runtime, and Docker documents
0 and 1 and reserves 2, so it answers 0 for healthy and 1 for everything
else — unhealthy, a refused command line, or a configuration file that did
not load.
Reloading¶
systemctl reload pace-kv sends SIGHUP, which re-reads the config file in
use — /etc/pace/pace-kv.yaml for the packaged unit. Only part of the
configuration is reloadable:
| Reloadable | Requires a restart |
|---|---|
log_level, max_memory, requirepass, auth_username, notify_keyspace_events |
bind, port, listen, listen_tls, storage_path, storage_shards, storage_durability, storage_max_value, threads, active_expiry_interval, active_expiry_scan |
The right-hand column is live-wired at startup — listeners are bound and the storage backend is constructed once — so a reload that changes any of it is rejected in full, the previous configuration is kept, and the reason is logged. Reload therefore either applies everything or nothing.
Hardening¶
pace-kv never drops privileges on its own, so every restriction comes
from the unit: User=/Group=, StateDirectory=, and the usual
Protect*/Restrict* set. Check it with:
Two consequences worth knowing. ProtectSystem=strict and
ProtectHome=yes mean TLS certificates must live somewhere the unit can
still read — /etc/pace/ is the natural place. And binding a port
below 1024 needs capabilities the unit drops by default:
# systemctl edit pace-kv
[Service]
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
Persistent storage¶
StateDirectory=pace-kv provides /var/lib/pace-kv, owned by the
service user. Point storage_path at it to enable the on-disk tier:
The path's shape selects the layout: a path with no file extension (as
above) becomes a sharded directory, while one with an extension
(cache.cow) is a single file.
launchd¶
On macOS the .pkg registers a launchd job. The same registration is
available from the command line on any installation, and --service-scope
picks the domain:
pace-kv --install-service --service-scope=user # LaunchAgent
sudo pace-kv --install-service --service-scope=system # LaunchDaemon
The system scope needs the _pace-kv service account, which the .pkg
creates; installing from a tarball or a source build fails with a message
saying so rather than registering a job launchd cannot spawn.
The .pkg creates a second account, pace-node, for
pace-node. It is deliberately not shared with _pace-kv: a
worker runs a compiler on input that arrived over the network while pace-kv
owns the cache storage, so one account would let a compromised compile rewrite
every cached object. It comes from the package's Runtime component, so it is
present whichever launchd choice you made here, and the uninstaller removes both.
Every flag you pass on the command line alongside --install-service is
baked into the job's ProgramArguments, exactly as on Windows. Values read
from a --config file are not: they stay in the file, so editing it and
restarting the job keeps working.
sudo pace-kv --install-service --service-scope=system \
--config=/opt/pace/etc/pace-kv.yaml --threads=4
Prefer the config file for anything you might want to change later. A flag
on this command line outranks the same key in YAML for the life of the
registration, so --storage=… here would quietly pin the cache location and
make a later storage_path: edit a no-op.
--requirepass is the one flag that cannot be baked in, and the install is
refused rather than silently dropping it: ProgramArguments lands in a
world-readable plist, so the secret belongs in the config file passed with
--config. Passing both is refused too — nothing can tell from the command
line whether that file actually carries requirepass:, so accepting the
combination would be the silent drop under another name.
The package sets the system daemon's config to mode 0640, owned
root:_pace-kv, so it is readable by the account the daemon drops to and
by nobody else — which is the whole reason to keep the secret there rather
than in the plist. If you point the daemon at a config of your own, the
install checks that _pace-kv can read it and tells you how to fix it if
not; without that check an unreadable config shows up only as a job that
exits at every start, with the EACCES visible nowhere.
--service-scope=user must not run under sudo: the agent belongs to
the invoking account, and sudo would resolve that to root — registering an
agent under /var/root that your own login never starts and your own
--uninstall-service cannot see. The install refuses rather than guess.
user |
system |
|
|---|---|---|
| Plist | ~/Library/LaunchAgents/ |
/Library/LaunchDaemons/ |
| Runs as | the invoking user | _pace-kv |
| Starts at | login | boot |
| Privileges | none | root to install |
KeepAlive |
on crash only | on crash only |
The generated plist deliberately does not pass --daemon. launchd, like
systemd, supervises the process it started; a job that double-forks is
reaped immediately as exited and the service silently never runs.
The two scopes are alternatives — both bind the same address, and there is no unix-socket endpoint to separate them.
Both use KeepAlive={Crashed:true}: a process that crashed is restarted, no
more often than every thirty seconds (ThrottleInterval), and one that
exited — whatever its code — stays stopped. launchd cannot tell a refusal
(exit 78) from any other exit: launchd.plist(5)'s KeepAlive conditions ask
whether an exit was successful or a crash, never which code it had, and nothing
in it stops after N. So every shape that restarts a clean non-zero exit restarts
a refused start forever, once per ThrottleInterval, writing the same refusal
each time; the system daemon's KeepAlive=true did exactly that. Crash-only is
the least bad choice, and it costs two things the other supervisors do not:
- a failure is not retried either — a port still held by a process that was exiting, or a per-user agent that lost the race for the port to another user's, is one line in the log and a stopped job, where systemd and the Windows service manager would start it again;
- a process that crashes at every start is restarted forever, every thirty seconds.
Status, restart, logs:
launchctl print gui/$UID/software.lastrada.pace-kv
launchctl kickstart -k gui/$UID/software.lastrada.pace-kv
tail -f ~/Library/Logs/pace-kv/software.lastrada.pace-kv.err.log
The system daemon logs to /opt/pace/var/log/<label>/ instead — a
directory per job, because that directory is owned by the account the job runs
as and a machine may run both pace-kv and pace-node
system-wide:
Timestamps¶
Those are plain files, and launchd writes nothing into them itself — unlike journald, which stamps every line it captures, and unlike the Windows event log, where the time is part of the record. A log with no times is close to useless the one time it is read.
So log_timestamps defaults to on when running on macOS, in both binaries and
in every deployment, not only under launchd: a terminal run stamps too. Turn it off
with no_log_timestamps: true in either binary's configuration file, or with the
--no-log-timestamps flag, which exists for this — a bare --log-timestamps can
only ask for the default that is already in force.
Each key is its flag, so log_timestamps: false means "do not pass
--log-timestamps" and leaves the platform default — which on macOS is on. Say off
with the negative key, and on (for Linux) with log_timestamps: true. The command
line outranks the file, as everywhere else.
There is no reload equivalent to systemctl reload: send SIGHUP to the
pid launchctl print reports, or kickstart the job.
To remove it: pace-kv --uninstall-service --service-scope=<scope>, or
sudo /opt/pace/bin/pace-uninstall to remove the whole install.
Windows service¶
The MSI registers pace-kv as an auto-start service — a disabled one, left
stopped, when the pace-node feature is installed too, since both answer
on 6674 (the MSI's service table) — and seeds
C:\ProgramData\pace\pace-kv.yaml from the template it ships, unless
a config is already there. The registration passes no --config: the
service resolves that path itself at every start, so editing the file is all it
takes to reconfigure it, and a machine without one still starts on the built-in
defaults rather than failing.
The same registration is available from the command line on any installation:
Pass --config=C:\path\to\pace-kv.yaml only to point the service at a file
other than the default location.
When a start is refused¶
A configuration the service refuses at startup — a flag it does not know, a file that does not parse, a flag combination a startup rule rejects — is reported to the SCM as a stop with a service-specific code, and the reason is written to the Application event log under the service's name:
> sc.exe query pace-kv
STATE : 1 STOPPED
WIN32_EXIT_CODE : 1066 (0x42a)
SERVICE_EXIT_CODE : 78 (0x4e)
1066 is ERROR_SERVICE_SPECIFIC_ERROR: the reason is the process's own exit
code, 78 for a start either binary refused on its configuration (or on a defect in
its own build) and 1 for
one that failed for a reason the next start may not meet. It is not error 1053, did not respond to the start or
control request in a timely fashion — that is what a refusal used to produce,
because the process exited before connecting to the SCM.
--install-service is held to the same rules as a start, so a line every start
would refuse is refused while you are watching rather than registered.
--uninstall-service and --healthcheck are not: removing a service must not be
blocked by the mistake it was reached to undo, and a probe of a daemon that is
serving must not call it unhealthy over a rule that decides only whether one may
start.
The restart policy the registration sets is finite: after a failure the SCM
restarts the service after one second, again after one second, once more after
thirty seconds, and then leaves it stopped (sc qfailure lists the four
actions). Ten minutes without a failure starts the count over. It counts
failures rather than starts, so the SCM never refuses a start you make yourself.
A refused start is restarted here too, unlike under systemd, because the SCM
cannot be told apart from a failure without losing what sc query shows: it runs
its recovery actions for every stop whose WIN32_EXIT_CODE is not zero, and the
only refusal that would escape them is one reported as zero — with the 1066 and
the 78 above gone. So a configuration that stays wrong is refused four times,
each writing its reason to the event log, and then the service stays stopped
until you fix it and start it. systemd does not restart a refusal at all, and
launchd restarts a crash only (see launchd).
What it runs as¶
The service logs on as the virtual account NT SERVICE\pace-kv. The SCM
derives that from the service name and manages it itself, so there is no account
to create and no password to keep. Told nothing, CreateService would use
LocalSystem, which has unrestricted access to every local resource and is a
member of the local Administrators group — more than a cache daemon listening on
a socket has any use for.
It can still read C:\ProgramData\pace\pace-kv.yaml, because seeding
grants NT AUTHORITY\SERVICE read on that file and a service logon is a member
of it. The file does not inherit the directory's BUILTIN\Users read, so a
requirepass: written there is not readable by every local account. It
deliberately cannot write there either: a service that cannot rewrite its own
configuration cannot be talked into loading a different one.
If you set storage_path, the account needs access to it. --install-service
hands over whatever storage_path is configured at the time it runs, so seeding
the config first and installing second needs nothing extra.
One exception: a storage_path that names a single cache file — an existing
file, or a path with a file extension such as D:\pace\cache.cow — is only
created by the installer if it is already there. Creating cache.cow as a
directory would make the very next start treat it as a directory of shards and
fan out inside it, so the installer refuses and says so. An upgrade therefore needs
nothing extra (the file exists, and is handed over); a first install against a
not-yet-created cache file prints that refusal as a warning: on the install line
and needs the icacls command below, run against the directory that will hold it.
If you add or move storage_path afterwards, grant it from an elevated prompt:
Re-running --install-service from an elevated prompt repairs it too: registering
a service that already exists re-applies the registration — start type, command
line, account, restart policy — and runs the handover again for the
storage_path that install resolves. It does not restart a running service, so a
changed command line takes effect at the next start.
A daemon that cannot open its storage says so at startup and prints this command
with your own paths and service name filled in; without storage_path it is
memory-only and needs no directory at all. The advice appears only when the
daemon is running as the service (--daemon) — a foreground run is not the
virtual account, so naming it there would send you after an identity that is not
the one being refused.
Renaming the service with --service-name renames the account with it, since the
SCM derives one from the other.
A storage path belongs to one daemon. The store is claimed exclusively while
it is open, so a second daemon started against the same storage_path refuses
rather than sharing it:
failed to open storage 'D:\pace\cache': StorageError(code=InUse system=0 context=FilePageStore::Open)
Another process already has this store open. A storage path belongs to one daemon:
stop the other one, or give this daemon a path of its own.
Running two daemons on one machine is fine — give each its own path. Nothing is written to the store to enforce this, so its files stay readable by any build.
InUse is one of four codes an open failure can carry, and they call for
different things. UnsupportedFormatVersion means a healthy store of another
vintage — convert it, never delete it. Corrupt means the
bytes really are damaged: When a store reports Corrupt says
whether the process starts, what deleting costs, and what to keep first.
--install-service records the flags it was given on the command line into
the service's command line (values from a --config file stay in the file)
and makes path arguments absolute — a service starts with its
working directory set to C:\Windows\System32, so a relative path captured
at install time would resolve elsewhere at boot. It creates the service
already set to auto-start but leaves it stopped, so the first start is
explicit; --service-start=manual registers it to wait for somebody to start
it instead. The start mode belongs to the registration, not to the command line
the service runs with.
What it opens in the firewall¶
Registering also opens the Windows Firewall for every cache listener that binds
beyond loopback and, with --metrics, for a metrics endpoint that does — nothing
at all for the default 127.0.0.1:6674. Each gets one inbound rule, admitting
pace-kv.exe running as this service, on every network profile, in the group
Pace: pace-kv; the install line lists them. --firewall-allow=<address[/prefix]>
(IPv4 or IPv6, repeatable) limits which remote addresses they admit, and /0 is
refused: leave the flag out to allow any address.
Running --install-service again replaces the group rather than adding to it, and
--uninstall-service removes it once Windows accepts the delete — at once, even for
a service that had not stopped within a minute and is only marked for deletion until
it exits. A firewall that refuses
never fails either command: it is reported as a warning, and an uninstall whose
delete was refused leaves the rules in place, since the service may still be
running. A listener bound to the name localhost is given a rule too, and the
install says why — a name is whatever the resolver answers; bind 127.0.0.1 or
::1 to need none.
Container¶
A multi-stage Dockerfile builds a Release binary with TLS
enabled and copies it onto a slim Debian runtime:
docker build -t pace-kv .
docker run --rm -p 6674:6674 -p 9259:9259 pace-kv \
--bind=0.0.0.0 --metrics --metrics-bind=0.0.0.0 --requirepass=secret
The image's default CMD binds 0.0.0.0 (so the cache is reachable from
outside the container) and enables the metrics endpoint. Binding 0.0.0.0
without --requirepass exposes the cache to anyone who can reach the port —
that is exactly the deployment this bundle exists to secure, so pair it with
--requirepass and, across untrusted networks, --tls.
Health check¶
The image's HEALTHCHECK runs pace-kv --healthcheck, which probes
http://127.0.0.1:<metrics-port>/healthz and exits 0 (healthy) or 1. It is
self-contained — no curl/wget in the image — but requires the daemon to run
with --metrics.
/healthz answers 503 once any bind's accept loop has ended while the daemon was not
stopping, naming the bind and what its last accept answered: such a port still listens and
refuses every client, so a probe that only saw the process alive would call it healthy.
Only a closed listener ends a loop; a client that reset its queued connection is logged at
Warn and accepted past.
Authentication¶
Set a shared secret to require clients to authenticate before any data command:
pace-kv --requirepass=secret # redis AUTH + memcached binary SASL PLAIN
pace-kv --requirepass=secret --auth-username=alice
- Redis:
AUTH secretorAUTH alice secret→+OK; data commands before auth get-NOAUTH. - memcached binary: SASL
PLAINagainst the same secret. - memcached text: has no auth handshake, so it rejects data commands while a secret is configured — use the binary or RESP protocol with auth.
The secret is compared in constant time and never logged.
TLS¶
TLS is compiled in with -DPACE_ENABLE_TLS=ON (the image enables it) and
turned on at runtime:
pace-kv --tls --tls-cert=/etc/pace/server.crt --tls-key=/etc/pace/server.key
# clients:
redis-cli --tls --insecure -a secret ping
--tls on a build without TLS support exits with a clear error. Client
certificate (mutual TLS) auth is not yet implemented.
Metrics¶
pace-kv --metrics --metrics-bind=0.0.0.0 --metrics-port=9259
curl http://host:9259/metrics # Prometheus text exposition
curl http://host:9259/healthz # 200 OK; 503 naming any bind that stopped accepting
If the endpoint cannot be bound the daemon carries on serving the cache without
it, and says so at Warn. That is deliberate: the admin surface is a scrape and
probe convenience rather than the service. pace-node answers the same
failure by refusing to start, because an operator who asked a worker for an
endpoint is almost always wiring a probe to it.
The series pace-kv exports¶
Every counter in the build is exported by every process that serves this endpoint,
without exception — a per-counter "does this apply?" predicate is exactly the
mechanism that once left seven of nine live counters unexported. So a pace-kv
scrape also carries the pace_worker_*, pace_node_* and
pace_scheduler_* series, and on a daemon they are flat at zero: nothing
in this binary moves them. They belong to
pace-node, which documents
them. Read a zero there as this process produces no such events, never as this
process has no such subsystem — a cumulative counter cannot tell you which, which
is why the things that genuinely can be absent (pace_items and the whole
storage block on a process with no cache, a tier a cache does not have) are
omitted from the exposition instead of reported as 0.
The same reading applies inside the daemon's own namespace: the
pace_dispatch_* block below is the fleet scheduler's, and the scheduler is
pace-node. The pace_ prefix on it is
historical.
Command traffic¶
Present only when the process has a cache, which for pace-kv is always.
| Series | Says |
|---|---|
pace_cmd_get_total |
GET commands processed. |
pace_cmd_set_total |
SET-family commands processed. |
pace_cmd_touch_total |
TOUCH commands processed. |
pace_cmd_flush_total |
FLUSH commands processed. |
pace_get_hits_total |
GETs that found a live entry. With the misses below, this is the hit ratio. |
pace_get_misses_total |
GETs that found nothing. |
pace_delete_hits_total |
DELETEs that removed an entry. |
pace_delete_misses_total |
DELETEs with no matching key. |
pace_incr_hits_total |
INCRs against a present key. |
pace_incr_misses_total |
INCRs with no matching key. |
pace_decr_hits_total |
DECRs against a present key. |
pace_decr_misses_total |
DECRs with no matching key. |
pace_touch_hits_total |
TOUCHes against a present key. |
pace_touch_misses_total |
TOUCHes with no matching key. |
pace_cas_hits_total |
CAS requests that matched and stored. |
pace_cas_misses_total |
CAS requests with no matching key. |
pace_cas_badval_total |
CAS requests refused on a token mismatch — a real write conflict, not a missing key. |
Capacity and what the cache is holding¶
| Series | Says |
|---|---|
pace_items |
Live entries currently stored. A gauge. |
pace_bytes_used |
Bytes currently stored. A gauge. |
pace_bytes_limit |
The configured byte budget; 0 means unbounded. A gauge. |
pace_evictions_total |
Entries dropped to stay inside that budget. Sustained evictions with a falling hit ratio mean the working set no longer fits. |
pace_evicted_unfetched_total |
Entries evicted before ever being read — capacity spent on values nobody wanted. |
pace_expired_unfetched_total |
Entries that lapsed before ever being read. |
pace_expirations_total |
Entries removed because their TTL lapsed, whichever path removed them: a lookup or a write that met one, or the expiry cycle. The unfetched series above is the part of this nobody read, and pace_expiry_keys_reclaimed_total is the cycle's share alone. |
pace_write_errors_total |
Value writes that failed to persist: a full disk, an I/O error, a read-only mount, a damaged store. This one is about the disk. See When a store reports Corrupt. |
Per tier¶
Emitted once per tier the cache actually has, each sample carrying a
tier="memory" or tier="disk" label. A tier this cache does not have contributes
no line at all rather than a zero: pace_tier_items{tier="disk"} 0 says a
disk tier is standing empty, and a memory-only daemon has no such tier to be empty.
Nothing here is a total waiting to be summed. The memory tier mirrors what it reads out of the disk tier, so adding the item counts double-counts; the disk tier is consulted only when memory missed, so the hit/miss split is deliberately not published per tier and stays on the unlabelled series above.
| Series | Says |
|---|---|
pace_tier_items |
Live entries this tier holds. |
pace_tier_bytes_used |
What this tier's budget counts: stored (compressed) value bytes for memory, the on-disk footprint (pages in use × page size) for disk. |
pace_tier_bytes_limit |
This tier's own byte budget; 0 means unbounded. |
pace_tier_file_bytes |
Length of the file backing a disk tier, free pages included; runs ahead of bytes_used by its free pages until a commit cuts them off the end or later writes reuse them. Absent for a tier with no file. |
pace_tier_evictions_total |
Entries this tier dropped to stay within its budget. |
pace_tier_index_bytes |
Resident memory this tier spends on its key index — always RAM, even for a disk tier. |
Expiry¶
| Series | Says |
|---|---|
pace_expiry_cycles_total |
Sweeps the active expiry cycle has run. Flat on a daemon serving traffic means the cycle is disabled (--expiry-interval=0s) or wedged, which otherwise looks exactly like nothing having expired. |
pace_expiry_keys_reclaimed_total |
Entries that cycle reclaimed — keys that lapsed and that nothing would have touched again, so no other path would ever have freed them. |
pace_keyspace_reclaim_events_dropped_total |
Reclaimed keys whose expired/evicted keyspace event was never published, because one call reclaimed more at once than the notification buffer holds. Any rise means a subscriber's view is incomplete. |
Connections¶
| Series | Says |
|---|---|
pace_connections_total |
Connections accepted since start. |
pace_connections_rejected_total |
Connections refused by admission control (--max-connections). |
pace_connections_total_tls |
The subset of pace_connections_total that arrived on a TLS-flagged bind. Plaintext traffic is the difference between the two. |
pace_connections_rejected_tls |
The TLS subset of the rejections. |
The compile-cache (0xFC) surface¶
What pace-cc and pace-node talk to this daemon with. These are
the daemon's own counters, and they are not the node's identically-shaped
pace_node_cache_* series: a mixed fleet moves both, and watching only the
node's reads a fleet mid-upgrade as one that has converged.
| Series | Says |
|---|---|
pace_cache_stores_refused_foreign_generation_total |
A STORE whose value names a canonicalization generation this build does not implement. A rise names a rolling upgrade in progress; it stopping names one that finished. Flat at zero unless the fleet spans a CompileValueVersion bump, so any rise is a real event. The node's twin is pace_node_cache_requests_refused_foreign_generation_total — watch both. |
pace_cache_stores_refused_not_a_compile_value_total |
A STORE whose value is not a compile value at all. Distinct from pace_cache_malformed_values_total, which is the memcached and Redis path's answer for a set or stream contradicting its own flags. |
pace_cache_stores_failed_total |
A STORE that reached the engine and could not be written: a full disk, a read-only mount, a backend refusing writes. The only arm of this surface that is about this machine rather than its clients. |
pace_cache_malformed_values_total |
A stored value that did not decode as the type its flags claim. Not a disk signal — the store is intact and every record still verifies. |
pace_cache_frames_refused_unsupported_version_total |
A frame naming a protocol version this build does not serve. During a rollout this tracks the rollout; afterwards it names a machine nobody upgraded. |
pace_cache_frames_refused_unknown_opcode_total |
A frame naming an opcode this build has no row for. A scanner, or a client speaking something else entirely at this port. |
pace_cache_frames_refused_payload_too_large_total |
A frame refused before its payload was read, for declaring more than the session cap or the verb's own ceiling allows. The cheapest probe there is. |
pace_cache_frames_refused_malformed_payload_total |
A payload that did not decode as the verb it named — two ends that agree on the framing and disagree about what goes inside it. |
pace_cache_frames_refused_unauthenticated_total |
A verb attempted on a connection that never authenticated: a client that has not learned it needs a credential, rather than one presenting a wrong credential. |
pace_cache_frames_refused_malformed_credential_total |
An AUTH payload that did not decode. Counted apart from ordinary malformed payloads because garbage aimed at the credential verb is what a scanner produces. |
pace_cache_credentials_rejected_total |
A credential presented and refused. Any sustained rise names somebody guessing. |
Fleet dispatch¶
The fleet scheduler's series. pace-kv runs no scheduler, so on a daemon these
are flat at zero; they move on a pace-node that serves the scheduler, and
Distributed compilation
is where they are explained one by one.
| Series | Says |
|---|---|
pace_dispatch_leases_granted_total |
Work is being distributed. |
pace_dispatch_leases_released_total |
Clients are reporting their jobs done. |
pace_dispatch_leases_no_worker_total |
The fleet is misconfigured — workers are up but nobody matches. |
pace_dispatch_leases_no_capacity_total |
The fleet is too small -- or, where a worker reads limited-by: compile-budget, its compiles hold its --compile-inflight-max instead: raise that on it. |
pace_dispatch_leases_withdrawn_total |
The fleet is unavailable — slots free on paper, machines busy elsewhere. |
pace_dispatch_leases_all_excluded_total |
The fleet is unreachable from its clients — every matching worker was on the asking client's exclusion list. A network problem, not a missing toolchain. |
pace_dispatch_leases_duplicate_total |
Duplicate-work suppression is doing its job. |
pace_dispatch_leases_reclaimed_total |
A machine went away mid-job and the keys it was building were freed. |
pace_dispatch_leases_unauthorized_total |
A lease token this cluster never signed was handed back. |
pace_dispatch_leases_released_late_total |
A compile outran the lease timeout and reported back too late. Read as a fraction of released_total; a steady fraction means the lease bound is too short for this site's slowest translation unit. |
pace_dispatch_leases_malformed_total |
A client asked for a lease naming its key, toolchain or toolchain label in bytes that are not UTF-8, and was refused. |
pace_dispatch_leases_field_too_long_total |
A client asked for a lease with a key, toolchain fingerprint or toolchain label longer than a scheduler records, and was refused. |
pace_dispatch_worker_registrations_total |
Workers registering. A steady rise means heartbeats are not arriving. |
pace_dispatch_worker_registrations_malformed_total |
A peer named its toolchain, endpoint or version in bytes that are not UTF-8 and was refused. |
pace_dispatch_worker_registrations_field_too_long_total |
A peer registered with a fingerprint, endpoint, version, toolchain label, display name or codec list longer than a scheduler records, and was refused. |
pace_dispatch_node_announcements_field_too_long_total |
A machine announced itself with an endpoint or version longer than a scheduler records, and was refused. An overlong condition field is refused earlier, when the frame is decoded, and is not counted here. |
pace_dispatch_worker_endpoint_mismatch_total |
A worker was admitted while advertising an endpoint whose host is not the address it connected from. |
pace_dispatch_workers_expired_total |
A machine stopped heartbeating and was dropped. |
pace_dispatch_workers_withdrawn_total |
A machine re-surveyed, found it no longer serves a toolchain, and retired that registration itself. |
pace_dispatch_frames_refused_unsupported_version_total |
A peer built against another release of the wire. |
pace_dispatch_frames_refused_unknown_opcode_total |
A frame naming a verb no build has. |
pace_dispatch_frames_refused_not_permitted_total |
A verb that exists and is served on a different port. |
pace_dispatch_frames_refused_truncated_total |
A frame whose header disagreed with what arrived. Never sum it with a malformed-payload series. |
Process¶
| Series | Says |
|---|---|
pace_uptime_seconds |
Seconds since this process started. A gauge; a reset is a restart. |
pace_build_info |
The build this process runs, as pace_build_info{version="0.4.1"} 1. The value is always 1 and the version is the label, so a query joins on it rather than reading a number. |
pace_metrics_surface_absent |
How many counters this build's catalogue names that belong to surfaces this process does not serve, so nothing here can ever write them. A gauge, and a nonzero reading is the healthy state of any binary that serves only some surfaces. Those series are omitted from the scrape rather than rendered as a zero. Do not alert on this; it is the row above that is a fault. It moves with the configuration: every catalogue row is attributed to a surface, and pace-node derives its surfaces from the components it was told to run -- so a node started --slots=0 reports the worker's refusal counters here rather than as zeroes claiming nothing was ever refused, and one with no --listen-raft does the same for the Raft peer wire. A reading that CHANGES after a flag change is therefore expected, and the daemon reports 0 because it claims every surface. |
pace_metrics_catalogue_skew |
How many counters this build's metrics catalogue names that its sink has no slot for. A gauge, and zero on any correctly built process -- a nonzero reading means the catalogue and the sink were compiled against different versions of the counter enum, so the binary is inconsistent and every counter at the affected ordinals is missing from this scrape rather than reading zero. Alert on > 0. |
This table is checked against the exposition the daemon actually renders — see
MetricsDocumentation_test.cpp, which fails in both directions.
Kubernetes¶
A minimal Deployment with probes and a Prometheus scrape annotation:
apiVersion: apps/v1
kind: Deployment
metadata:
name: pace-kv
spec:
replicas: 1
selector:
matchLabels: { app: pace-kv }
template:
metadata:
labels: { app: pace-kv }
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "9259"
prometheus.io/path: "/metrics"
spec:
containers:
- name: pace-kv
image: pace-kv:latest
args: ["--bind=0.0.0.0", "--metrics", "--metrics-bind=0.0.0.0", "--requirepass=$(CACHE_SECRET)"]
env:
- name: CACHE_SECRET
valueFrom: { secretKeyRef: { name: pace-kv, key: requirepass } }
ports:
- { containerPort: 6674, name: cache }
- { containerPort: 9259, name: metrics }
livenessProbe:
httpGet: { path: /healthz, port: metrics }
periodSeconds: 30
readinessProbe:
httpGet: { path: /healthz, port: metrics }
periodSeconds: 10
sccache¶
The original use case still works — point sccache at the cache port:
export SCCACHE_REDIS=redis://:secret@host:6674 # with auth
export SCCACHE_MEMCACHED=tcp://host:6674 # binary protocol, no auth
On MSVC and clang-cl, one sccache cache must not be shared between checkouts
sccache preprocesses MSVC and clang-cl with /EP, which emits no line
markers, so the text it hashes to find a cache hit carries no paths at
all — while the /showIncludes stream it replays on that hit carries the
absolute paths of the checkout that stored it. Two checkouts sharing one
cache therefore record dependencies pointing into each other, after which
editing a header in the checkout you are building rebuilds nothing: the build
stays green and the objects are stale.
Measured on this project — a second worktree recorded 1097 dependency
edges into the first and none into itself — and reproducible in two
compiles: with sccache 0.14.0 and MSVC 14.51, a second directory took a cache
hit from the first, and its /showIncludes named the first directory's
header.
What is exposed is narrower than "sharing a cache". It is an incremental build across checkouts at different absolute paths. A clean build has no dependency graph to corrupt, and checkouts that all sit at the same path replay paths that are correct — a CI fleet is normally both, and this is not a reason to take the cache away from one. GCC and Clang are not exposed at all: their preprocessed output carries the paths, so two checkouts never share an entry to begin with — the same two compiles under g++ 14 were 0 hits and 2 misses.
A pace-kv-backed sccache is definitionally one cache shared by every
checkout and every machine pointed at it, so the developer machines behind it
are exposed even where CI is not. On MSVC and clang-cl, use
pace-cc
instead: it rewrites a hit's paths into the consuming checkout before replaying
them, and refuses a hit whose replayed dependency is not there — which is why
it does not have this failure mode, and why its entries are portable across
checkout paths on purpose.