Skip to content

idle daemon tuning and misc fixes - #1102

Merged
cgalibern merged 62 commits into
opensvc:mainfrom
cvaroqui:resinfo-pubsub-signal
Sep 2, 2026
Merged

idle daemon tuning and misc fixes#1102
cgalibern merged 62 commits into
opensvc:mainfrom
cvaroqui:resinfo-pubsub-signal

Conversation

@cvaroqui

@cvaroqui cvaroqui commented Sep 1, 2026

Copy link
Copy Markdown
Member

No description provided.

cvaroqui and others added 30 commits September 1, 2026 09:31
Every node pushed the resource info of its own instances straight to the
collector, from the cli process running the push_resinfo schedule entry.
That bypassed the collector speaker, which exists so a single node talks
to the collector and can coalesce and rate-limit what it reports.

The resinfo.json cache keeps its own schedule, which is the reason the
info never moved into resource.Status. Only the reporting path changes: a
refresh now signals the local daemon, which publishes a small
InstanceResourceInfoUpdated carrying a checksum and no key-values, so
peers replicate a signal rather than a payload. The speaker subscribes,
and on its refresh ticker fetches the key-values from the instance node
with the GetInstanceResourceInfo api before posting them, deduplicating
on the checksum it persists under collector/resinfo_sent.

This is the post_feed_object_config pattern, which already feeds a toSend
map from a bus event and fetches the heavy payload from the holder node,
combined with the imon fetch_resource_files one, where the frequent event
is only a change signal. instance.Status is left alone: it rides
InstanceStatusUpdated, which is far too frequent to grow.

The push command disappears with the push path it drove: resource info
reports the cache, resource info -r refreshes it.
The info command only existed under the resource subsystem, where every
sibling query command is also mounted per driver group, and the push
parent it replaced still stood next to it with a single hidden child.

info becomes a group command, built like the list one it now sits next
to: it takes the group and a PATTERN positional, so disk info reports
the disk key-values and instance info reports them all. The object push
parent goes away with its last child, the hidden push resinfo.

A group selects a subset of the resources, which the refresh must not
mistake for the whole instance: refreshing a subset merges over the
cache instead of replacing it, so the rids left out keep their
key-values. A full refresh still drops what the configuration no
longer holds.

The scheduler entry and the api action both run instance info, the
canonical spelling for data the collector reports per instance, and the
action forwards its rid to it.
The file was named after the push action it implemented, which no longer
exists: it now refreshes, merges and loads the resource info cache, and
feeding the collector moved to the collector speaker.

It follows actor_status.go, named after the data rather than an action,
for the same reason.
The action was named after the push it used to do, which the collector
speaker took over: PostInstanceActionPushResourceInfo, at action/push/
resource/info, ran a command that no longer pushes anything.

It becomes PostInstanceActionInfo, at action/info, and moves up to where
the alphabetically ordered instance actions expect it. This reads like
its PostInstanceActionStatus neighbour, which runs instance status -r the
way this one runs instance info --refresh.

The rename breaks the generated clients, which nothing in production
calls yet. The api version takes a minor bump: this release also adds the
POST resource/info signal endpoint and the action rid parameter.
The keyword was named after the push the collector speaker took over,
and it drove a schedule entry, a statefile and a scheduler action all
still spelling that push.

resinfo_schedule stays as a keyword alias, so the configurations setting
it keep working. The rest is free to follow the command: the entry is
the info action, next to the status one, and runs instance info
--refresh.

The entry base changes with it, so the last run is recorded in
last_info rather than last_push_resinfo. An upgraded object has no last
run for the new name and refreshes its resource info once, which is what
the entry does hourly anyway.
The moved-commands list still sent readers to a resource info list and a
resource info push that no longer exist, one renamed and the other
removed with the push path it drove.
It described the push resinfo action, which no longer exists, and left
out the two traps of writing an info command: a value containing a colon
is dropped like any other invalid line, and a non-zero exit aborts the
refresh of the whole instance.
children and parents accept ./svc/svc2, resolved in the namespace of the
object declaring it, which is what keeps a set of relations intact when
the objects are cloned into another namespace. The keywords documented
the bare name and the fully qualified path, and not that one.

The children text also described what parents do, having been copied
from it.

hb.relay.password takes the same notation, resolved in the system
namespace rather than the object's, node configuration not being
namespaced.
It advertised json|flat|auto|tab and left out three renderers the
commands accept: yaml, jsonline and template. auto moves to the front,
being the default.

table and csv are not added: the renderer implements neither, tab having
replaced table, so passing them silently falls back to the human
rendering like any unknown value.

diff stays on the events flag alone, which is the only command that
implements it.
It pointed at the pg_cpu_shares text, so the keyword reference described
shares under quota: two different mechanisms, one arbitrating a
contended cpu and the other capping regardless of contention.

The accepted expressions were documented nowhere. They are 50%, 50%@ALL,
10%@2 and a raw microsecond quota, per the parser.
om node config eval failed on DEFAULT.id, a key the agent writes itself
in every node.conf, so the command was broken on a clean install. The
node keyword store declares id in the cluster section only, and eval
stops at the first key it can not resolve, so one key hid the two
hundred that evaluate fine.

The node and cluster stores are not the core objects store, so a keyword
has to be declared in both. Two were missing:

* DEFAULT.id for the node, mirroring the ccfg one. It goes to the
  private list, not the common one, because the ccfg store appends its
  own and a second declaration would shadow it with the wrong text.

* comment, for the node and the cluster. It has no section, like the
  core objects counterpart, so a comment is accepted in any section
  rather than in DEFAULT alone.

Declaring keywords does not make the command robust, though, as any
configuration can hold a key no keyword declares. So the two config
handlers now split on how the keys were selected. A keyword selection is
a user input, and asking for a key that does not exist stays a 400. The
whole configuration is not a user input, and each failure is now
reported on its own item, the listing continuing.

This covers every eval error there, not just the unknown keyword one: a
bad reference in one key had the same power to blank all the others, and
a caller can no more act on a 500 than on a 400 in that case.

The node handler was also still returning a 500 for an unknown keyword,
as ddfdbc9 only patched the object one.

The om and ox get commands grow an ERROR column, added to the default
output only when an item carries one.
The two config file handlers declared a redact-secrets query parameter,
plumbed it through the proxy call and the clients that reach them, then
redacted every response whatever its value.

So `om <path> config show` had no way back to the real values, and
neither had its callers. `om <path> config edit` on an object with no
local instance fetches the file through that handler and writes the
buffer back on exit, which would have replaced cluster.secret, node.uuid
and every sec or usr data key with the eight asterisks the editor was
shown. The collector feed would have reported the redacted configuration
as the object configuration.

The default path also returns to serving the file rather than reading it
whole into memory, as it did before.

Cluster config synchronization is unaffected: peers fetch through
GetInstanceConfigFile, a handler this feature does not touch.

Also drop the sectionName copy the redact package carries. Only the
core/object one has a caller.
The KeywordItem unstructured map is hand written, and I added the error
field to the schema without adding it there. So the ERROR column the get
commands ask for had nothing to read and printed a dash on the very rows
it was added for. The json output was correct, as it marshals the struct.
The keyword was openid_authority until ddbaa83 renamed it, in July
2025, with no alias left behind. A configuration written before that
does not merely fail to validate: getClusterConfig reads the new name
only, so the issuer resolved empty and the daemon stopped advertising
the openid auth method, silently, on clusters that had it configured.

The alias mechanism is resolved by mayDescope and descope, so declaring
the old name restores the value as well as the lookup. This cluster now
advertises the issuer it has been configured with all along.

The rename to openid_issuer was not the documented removal of
openid_well_known, which the changelog covers. That one stays removed.
github.com/cvaroqui/ini re-encodes every line it writes, so a document
that went through it came back reformatted whether or not anything was
modified. The formatting was steered by package level globals, which
xconfig set on each write.

util/ini keeps, for every element, the source bytes it was parsed from,
and writes those back verbatim for the elements nothing touched. Only a
modified element is encoded again, from a Format carried by the file
rather than by the package. A document nobody modified is reproduced byte
for byte, comments, blank lines, alignment and multiline layout included.

Its accepted syntax is the dialect the OpenSVC configuration files use:
the "=" and ":" delimiters, python multiline values, backslash
continuations, quoted and triple quoted values, inline comments requiring
a leading space, child sections and the DEFAULT section.

A document defining a key twice is normalised to its last definition,
which is what the previous library resolved it to, and duplicate sections
are merged under the same rule.

compat_test.go keeps the old library as a test only dependency and checks
the two read the same values out of the same documents, over a corpus of
the syntax corners plus a fuzz target. Two more fuzz targets cover the
byte identical write back and the value substitution.
A value is written to the configuration file as it is given, and read
back through the ini quoting rules, which strip a surrounding quote pair.
So `--set x="1 2"` stored `"1 2"` and read back `1 2`, while `--set x=1 2`
stored and read back the same thing: the quotes a shell user reaches for
to protect a space silently became part of the value on the way in and
were dropped on the way out.

Parse now strips the pair the way the parser does, on the same rule: only
when the quote appears nowhere else in the value, so a value holding a
quote keeps it.
RedactSecrets read the configuration file with three regexps, rebuilt
every line from the submatches, and wrote the whole document back. Two
of its readings leaked the secret it was called to hide:

* A key delimited with a colon did not match the key/value regexp, so the
  line was written out raw. xconfig accepts "=" and ":", so
  "secret:s3cr3t" was served whole.

* The regexp cut the value at the first "#" or ";" and kept the remainder
  as an inline comment, so "secret = a#b" was served as
  "secret = ********#b".

It also rewrote a CRLF document with LF endings, and appended a newline
to a document that ended without one.

The parser knows where a value starts and ends, so the redaction is now a
substitution in place: load, walk the sections, replace the value of the
keywords declared with RedactSecret and of every key of a sec or usr data
section, write. Everything else is written back verbatim, and the
substitution is refused if it would not parse back to the redacted value.

RedactSecrets returns an error rather than a document it could not parse
and therefore could not redact. The handlers answer 500 on it, the show
commands report it. Serving the configuration unredacted is not an option
worth having for this function.

util/redact held the line and section types the hand rolled reassembly
needed, and has no remaining caller.
The keyword doc rendered required, scopable, candidates, depends, default
and convert, and nothing else. Three things a reader needs were held in
the keyword and never printed:

* the aliases, 213 keywords having one. A configuration written with the
  name a keyword had before a rename validates and evaluates, but its
  doc gave no clue the name was accepted, so the reader could not tell a
  working configuration from a broken one.

* the deprecation, 13 keywords carrying one. config validate warns about
  them with the release and the replacement, the doc said nothing.

* the replacement of a deprecated keyword, and whether the value is a
  secret. Both were also dropped by the API the doc command reads its
  definitions from: ConvertKeywordStore never set them and the schema had
  no field to carry them, so plumbing them through was needed for the
  keyword to reach the renderer with them.

The deprecation is phrased as the config validate alert phrases it.

Also correct the text of three keywords:

* comment now serves the node and cluster configurations, where there is
  no object and no resource, which is all its text described. It says
  what it covers, and that it is accepted in any section, which the empty
  Section of its declaration means and nothing said.

* cluster.secret advised that the cluster *name* be unique site-wide,
  copied from the cluster.name text. The advice is about the secret, and
  now says what changing it later costs.

* listener.openid_issuer mentioned only the openid_well_known it
  replaced, not the openid_authority name it answers to.
Auditing all 693 keyword text references turned up the same class of bug
the pg_cpu_quota fix was about, in three places.

The whole non-blocking trigger block of the resource keywords pointed at
the post_provision text, so pre_start was documented as "a command to
execute after the resource provision action", and every trigger claimed
to run on leaders only, which is true of the provision ones alone. The
ten correct texts were on disk, unreferenced. restart_delay and subset
pointed there too, a duration and a resource grouping documented as a
trigger command. subset had no text at all and now has one.

pool.freenas.blocksize carried the insecure_tpc text, so a zvol block
size was documented as an xcopy authentication setting.

ressynczfs recursive carried the target text.

Four keywords had no text at all, NewText discarding the read error, so
they rendered an empty description: cluster.envs, node.max_key_size, and
task.host stop, plus pool.drbd.max_peers whose reference was missing the
node/ path component. resipsgcp_dnsalias target wrote its default text
reference with an underscore where the file has a dot.

34 texts told the reader to run pushasset, hidden and kept for
compatibility, rather than `om node push asset`.

The stonith keyword and the stonith section command keyword shared a text
explaining the fencing mechanism without saying what either keyword
holds. They now open with that, and the object one points at the section
holding the callout.

Left alone and reported: 19 text files no keyword references, among them
cluster.hb_secret, whose keyword was removed with the mechanism, and
resdiskhp3par method, which may be wanted by the array driver in
progress.
…gnal

The command help did not say what a killable pid is. The handler only
accepts the pids the daemon registered for its own spawned processes,
which is exactly the set `daemon ps` lists, and refuses any other with a
400. Nothing said so, leaving the reader to find the target list by
guessing. The long description now names `daemon ps` as the source of the
pids, and says a pid only makes sense on the node the process runs on, so
--node is what reaches a process listed on a peer.

The pids move from a --pid option to positional arguments, the command
requiring at least one. `om daemon kill 3924688` reads as kill(1) does,
and a bad argument is now reported as "invalid pid abc" before any
request is sent, where IntSliceVar reported a cobra parse error naming
the flag.

--signal is new. The handler sent SIGKILL and nothing else, so a task
that installed a signal handler could not be asked to wind down. The
signal travels as a name or a number in a new query parameter of the
DeleteDaemonProcess operation, and is parsed server side, where the
allowed pid check already lives: a number is refused unless it names a
signal, a name is accepted bare or SIG prefixed, in any case, the way
volsignal reads the resource signal routes. An absent or empty parameter
keeps SIGKILL, so the existing behavior and the older clients are
unchanged.

--pid is removed rather than deprecated.
The driver spelled the same directory two ways. objectNSPIDFile built
/var/run/netns/<object id>, the path handed to `ip netns add`, `ip netns
del` and netns.GetFromPath. purgeCNIVarFile stated /run/netns/<name> to
decide whether a namespace still exists before dropping its address from
the CNI cache. The first spelling dates from the original port, the
second from the ipv6 purge fix, and nothing ever reconciled them.

They resolve to the same inode wherever /var/run is the symlink to /run
that the merged layout installs, which is why the split never showed. It
still has to agree with iproute2's NETNS_RUN_DIR, since `ip netns add` is
what creates these files: were /var/run a real directory, the two call
sites would part ways and the purge would keep stale addresses forever,
silently.

Both now read a netnsRunDir constant holding /run/netns, the canonical
FHS path, and the one compiled into the iproute2 the driver calls.

resfsflag keeps its /var/run/opensvc, reached only on solaris, which has
no /run and where it is the correct volatile directory.
An object whose only resources are tasks or syncs came out with an avail
of undef. The aggregation skips those two driver groups, so nothing ever
called Avail.Add and the field kept its zero value, and the guard that
answered n/a only covered the object with no resources at all, which this
is not.

undef is not a status a reader can act on, and the daemon reads it as a
state to leave rather than a state that is fine. omon aggregates a
failover object of undef instances to down, because the n/a shortcut
wants every instance n/a. imon's isLocalStarted answers false on undef
and true on n/a, so an orchestrated task only object never settled: it
was told to start, had nothing to start, and was asked again.

The two aggregates are now settled once, at the end of the resource walk,
where the object with no resources and the object with no contributing
resource are the same case: an avail or an overall still undef there had
no contributor and is reported n/a. Doing it before the statusbus posts
also hands the corrected value to the action log status icon.

Optional keeps its own reading. It is never aggregated from the
resources, so widening it to every object would have started serving an
"optional" field that the omitempty of the undef zero value drops today.

Verified against a task only object, which now reports avail n/a, and
against a running and a stopped service, unchanged.
Profiling an idle three node cluster, the daemon allocated 1.54MB/s while
doing nothing but refreshing instance status, and spent 7.6% of its cpu
collecting that garbage. More than half of it came from here: compress
called zlib.NewWriter and decompress called zlib.NewReader once per
message, and a flate writer carries about 800KB of compressor state. The
heartbeat messages alone made flate.NewWriter the single largest source
of garbage in the daemon, ahead of every piece of real work.

Both codecs are documented as resettable for reuse, so they are now held
in a sync.Pool and reset onto each message. The compression itself is
untouched: the same level, the same Flush before Close, the same bytes on
the wire.

That last point is what the new package tests are mostly about. A reused
writer must emit the stream a fresh one emits, or a peer running another
release decodes something else, so the test asserts the output against a
copy of the previous implementation. The rest covers the round trip over
five payload sizes, concurrent use, and that a malformed message does not
poison the pooled reader for the message after it. The package had no
tests.

Measured on the profiled cluster, over a 180s idle window, against the
same window before the change:

* the daemon allocates 117MB where it allocated 277MB, 58% less
* compress drops off the profile entirely, from 96MB
* decompress allocates 9MB where it allocated 55MB, 84% less
* the gc goes from 7.6% to 4.5% of the daemon cpu, and the daemon from
  2.01% to 1.80% of a core

The unchanged paths hold still across the two windows, which is what
makes the comparison worth reading: the metrics handler 0.71s -> 0.74s,
the cluster data deep copy 0.72s -> 0.65s, the heartbeat receive loop
0.63s -> 0.60s. The cpu spent compressing is unchanged at 0.08s, as
expected. Only the garbage is gone.

A pooled reader keeps a reference to the last message until its next use,
because zlib's Resetter reads the header eagerly and cannot be pointed at
a dummy source the way the writer can be pointed at io.Discard. It is
bounded by GOMAXPROCS times the message size.
The sender encrypts a message once and hands the very same bytes to every
tx driver, so a cluster running n heartbeats delivers n byte identical
frames per message. Every rx driver decrypted, decompressed and
unmarshalled its copy in full, and msgFromRx then dropped all but the
first on their UpdatedAt. The work was already being discarded; it just
was not being skipped, and on an idle cluster it was the most expensive
thing the daemon did.

The rx drivers now hash the frame and ask hbdedup whether another link
already delivered it, before paying its decoding.

Two invariants shape it. A link is still reported alive on a duplicate:
one delivering nothing but copies is a working link, and dropping its
liveness report would have it declared stale, which is why the cache
holds the sender nodename and hands it back on a hit. And a frame is
recorded after it reached the daemon, never before: a frame recorded by a
link that then failed to deliver it would be skipped by the links still
to come, and the message lost. Recording late only costs the decoding of
a copy arriving in the race window.

Entries expire by generation rather than one by one, so a daemon running
for months does not remember every frame it ever received, and no sweep
walks the map.

The disk and relay receivers verify a frame came from the node whose slot
it sat in. They keep that check on the skipped path, against the nodename
the cache holds.

The unicast receiver decrypted inside encryptconn.MessageWithNode, with
no frame to hash in between, so encryptconn grew Frame and DecryptFrame.
MessageWithNode is unchanged, and is now the two composed.

Measured on the profiled cluster, five heartbeats, per 300s window:

* the unicast receive loop goes from 11.13% of the daemon cpu to 4.94%.
  Inside it, decryption 0.24s -> 0.02s and unmarshalling 0.34s -> 0.10s:
  what is left is the frames this link wins the race for, plus reading
  and hashing, which no dedup can avoid.
* the disk receiver drops off the profile, from 2.41%.
* the dedup itself costs 0.02s, hashing at 14GB/s with no allocation.

The multicast receiver does not benefit, and the profile says why: its
cost is the per datagram fragment framing, a json unmarshal of a 50KB
chunk carried as base64, not the message decoding the dedup skips. That
framing sits upstream of the reassembly, the first point where a frame
exists to be keyed, so no dedup reaches it. Worth its own look.

Verified on the cluster: 30 of 30 rx peer links beating across the four
driver kinds, and stopping then starting a receiver has the dedup follow
the live driver set, the unicast loop moving from Seen to Delivered when
the multicast one goes away, and back when it returns.
daemondata.ClusterData is behind a package level singleflight.Group, so
callers whose requests overlap are handed the very same *clusterdump.Data
pointer rather than a copy each. WithSelector and WithNamespace purged
the dataset they were given, deleting from Cluster.Object and from every
node's Instance map, so a caller that filtered was writing to a struct
its neighbours were reading.

The api handler defended against that, but only on one of the two ways it
filters: it deep copied when selector or namespace was passed in the
query, and not before the rbac filtering, which is the branch a plain
om mon from a namespace scoped user takes. A guest:ns1 request overlapping
the collector feed could therefore strip every non ns1 object out from
under it, and the collector would post a cluster that looks like it just
lost a hundred objects. get_daemon_events replay shares the pointer the
same way. The window is the channel round trip to the daemondata
goroutine plus the deep copy itself, a json marshal and unmarshal of the
whole dataset, so it is widest exactly when the daemon is busy.

The filters now build a view instead: Cluster.Object and each node's
Instance map are rebuilt with the paths kept, and everything the filter
does not look at is shared with the receiver, which is left alone. The
sharing is sound because both datasets are only ever serialized, and the
doc comment says so, since that is the constraint the next edit has to
respect. Both filters were doing the same two loops over the same two
maps around a different predicate, so the loops moved into one filter
method and the predicates are what is left of each.

The defensive deep copy goes away with the mutation it was guarding, and
with it one of the five full serializations the filtered path used to
pay. It is also what would let a single json rendering of the dataset be
shared between callers of different privilege rather than recomputed per
request, which is the direction the deep copy cost wants to be taken in.

Removing it does mean a nil dataset is no longer quietly rescued: json
marshalling a nil *Data yields "null" and unmarshalling that gave back an
empty Data. The rbac branch already panicked on nil, so the handler now
answers 500 instead, on both branches.

TestFiltersDoNotModifyTheReceiver pins the invariant the handler rests
on. It fails against the previous in place implementation, checked by
restoring it.

Verified on the profiled three node cluster after redeploy, on both
filtering branches. The query parameter branch: namespace and selector
responses are exact subsets of the unfiltered one, set compared. The rbac
branch, as a user granted admin:test and operator:mon and nothing cluster
wide: 18 objects over exactly the test and mon namespaces, set equal to
that subset of the unfiltered dump, no leak and nothing missing, all
three nodes still present with their instance maps filtered.

Then the shape of the bug itself, 60 unfiltered root requests interleaved
with 60 of that user's, 24 in flight: every root caller saw all 103
objects and every rbac caller saw only its 18, no cross contamination.
The requests do overlap, which is what makes that worth reading: one
takes 21ms, and 24 concurrent finish in 194ms where serializing them
would take 500ms, so the singleflight is collapsing them onto a shared
pointer under exactly this load.
clusterdump.Data.DeepCopy marshalled the whole dataset to json and
unmarshalled it back. On the profiled cluster that was 12% of the daemon
cpu and 77% of everything the daemondata goroutine did, and it ran inside
opGetClusterData, so every other cluster data operation waited behind it.

It was not the only one. node.Node.DeepCopy and resource.Status.DeepCopy
were json round trips too, and the dataset is mostly made of those: 3
nodes carrying 253 instances carrying 594 resource statuses here. A real
Data.DeepCopy calling into them would have kept nearly all of the cost,
so all three are hand written now, along with the instance.Instance and
node.Os methods they needed.

instance.Monitor.DeepCopy is deliberately left alone. Its json marshal is
inside the GlobalExpectOptions branch and is not a copy: it converts a
peer's map[string]any into the typed MonitorGlobalExpectOptionsPlacedAt
the orchestration reads. It is also nil in all 253 instance monitors here.

Benchmarked against a captured 103 object dataset, the copy goes from
16.44ms to 1.02ms, from 2.91MB to 0.94MB, and from 37210 allocations to
8285. The time matters twice: it is cpu, and it is 16ms data.run spends
answering nothing else.

The any typed fields are what a hand written method cannot reach, since
the concrete type is only known at run time: resource.Status.Info holds
strings, ints and lists set by drivers here, 226 of the 594 statuses have
one. util/deepcopy covers them by reflection, and Slice covers the plain
slices while keeping nil apart from empty, which matters because several
of these fields carry no omitempty and so serialize the two differently.

Routing the copy through the hand written methods exposed six bugs in
them, all pre existing, none of which had been visible because the json
round trip never called them:

* daemonsubsystem.Daemon.DeepCopy dropped Nodename
* daemonsubsystem.Heartbeat.DeepCopy dropped SecretVersion and UpdatedAt
* pool.Status.DeepCopy dropped Usage.Shared
* cluster.Config.DeepCopy dropped Issues
* instance.ActorConfig.DeepCopy dropped Children, Parents and
  MonitorAction, and shared the Flex pointer
* instance.ResourceConfigs.DeepCopy shared RestartDelay, through
  &(*cfg.RestartDelay), which is the pointer it was handed

Five are the same mistake, building the copy by listing the fields wanted,
which silently stops copying every field added after it was written. They
are all n := *t and then deepen now, which cannot drop a field. The sixth
is the &(*p) idiom, which looks like it dereferences and re-addresses and
does neither.

The daemonsubsystem methods keep one thing they were doing that a copy
would not: their collections come out non-nil even when they went in nil.
That is deliberate and now documented and tested. Those fields carry no
omitempty, and hbctrl builds Alerts by appending to a nil slice, so an
alert-free heartbeat stream is nil by the time it gets here. A faithful
copy served "alerts": null where an api client had always been served
"alerts": [], which a redeploy of an earlier draft of this change showed
on the wire. Normalizing in DeepCopy rather than at the sources is what
also covers a peer sending null over the heartbeat.

The tests drive the dataset by reflection rather than from a fixture,
because a fixture covers the fields its author thought of and stops
covering the ones added later. A filler writes a distinct non-zero value
into all ~1900 fields, choosing a valid member for the enum types, whose
MarshalText rejects out of range values. Then the copy must serialize
byte for byte like the json round trip it replaces, since the collector
feed and GET /daemon/status put it on the wire; separately with every
collection nil, which the filler cannot produce; and a second reflection
pass mutates the original in place, following maps, slices and pointers
rather than replacing them, after which the copy must not have moved.
That last one asserts the mutation was real, or it would pass against a
copy that shares everything.

Setting OM_TEST_CLUSTER_DUMP to a captured GET /api/cluster/status body
replays both properties against real data. On this cluster's 612KB dump
the two serializations are identical, which is the wire compatibility
this change needed to show. It is also what caught Daemon.Nodename, the
only one of the six that was non-zero in the live data.
Two things pgmetrics got wrong about cgroup v2, both of which show as a
metric that is silently not there.

A cgroup v2 limit file holds the literal "max" when nothing is limited,
which is the default. parseUint rejected it, and the caller dropped the
value on error, so cgroup_memory_max_bytes was empty on every cgroup on
the node rather than on an unusual one. It now reads +Inf, which is what
prometheus uses for no limit and what the text exposition renders, so a
query can ask which cgroups are capped instead of guessing from an absent
series. 0 would not do, being a limit of zero. The help string claimed
0 = unlimited and described nothing the code could produce.

The other is that nothing was ever removed. Prometheus vectors do not
forget a label combination on their own, and collect() skipped an object
whose cgroup had gone with a bare continue, so its series went on serving
the values of the last collection that found it. A stopped object looked
like a running one, indefinitely, and cgroup_exists in particular kept
answering 1 though its own comment says "(1) or not (0)" and Set(0) was
never called anywhere. The series count also only ever grew, for as long
as the daemon ran, over every object it had ever seen.

Now an object with no cgroup reports exists 0 and has its usage series
dropped, and an object gone from the cluster keeps nothing at all. That
bounds the endpoint by the number of objects, which is the bound it
should have had.

Dropping the series needs the list of metrics to drop, and register and
unregister each had their own hand written copy of that list. There is
one now. Three enumerations of the same fourteen metrics is the same
failure mode a DeepCopy that lists the fields it wants has: the copy that
is not updated stops covering what is added later.

Not addressed, and worth its own look: cgroup_blkio_weight is empty for
the same shape of reason, io.weight being "default 100" rather than a
bare number under cgroup v2, and cgroup_cpu_shares, cgroup_cpu_quota_us
and cgroup_cpu_period_us are empty because they read the cgroup v1 files,
which do not exist under v2 where the information is in cpu.max and
cpu.weight. Five of the fourteen metric families publish nothing at all
on this node.
hbmcast sent each fragment of a message as a json envelope with the chunk
inside it as a []byte, which json encodes as base64. The receiver paid a
json parse and a base64 decode per datagram, before it could know which
message the fragment even belonged to. A message is parsed once per
fragment and decrypted once in total, so the framing was the cost, not
the message: across the two windows that measured the hb dedup, this
driver's json.Unmarshal was 0.31s in both, unmoved by a change that
demonstrably removed the equivalent work from the unicast receiver. The
whole receiver was 8.75% of the idle daemon.

A datagram is now a 24 byte header and the chunk, unaltered: magic,
framing version, the 16 bytes of the message uuid rather than its 36
character string, and the index and count as uint16.

Decoding one goes from 393us to 13.7us on a full size chunk, 174MB/s to
3.7GB/s, and from 7 allocations to 2. What is left is almost entirely the
copy of the chunk out of the read buffer, which is not optional: the
receiver reads every datagram into one buffer and holds fragments in the
assembly map until their message is complete, so a chunk pointing into
that buffer would be overwritten by the datagram after it. The json path
got that copy for free, out of base64 decoding into a fresh slice.
TestDecodeFragmentCopiesTheChunk is there because it would be silent
corruption rather than a failure.

The base64 also broke the size arithmetic, which is the stronger reason
to change the framing. MaxChunkSize is 51200, and every fragment but a
message's last carries a full one. As base64 in the envelope that is
68333 bytes on the wire: larger than the receiver's 61440 read buffer,
and larger than a UDP payload may legally be. So any message over 51200
bytes had at least one fragment that could not be sent at all, and the
write error was logged at Trace before abandoning the whole message,
saying nothing at any usual log level. Raw, the same chunk is 51224 bytes
and MaxChunkSize means what it says. That write failure is a Warnf now.

MaxDatagramSize is the largest a UDP payload can legally be, so whatever
reaches the socket is read whole. It was 61440, and Go truncates a
datagram that does not fit the buffer without saying so, which for a
fragment means a corrupted message. The binary framing needs 51224 of it;
the rest is headroom for a peer still sending json, whose datagrams are a
third larger.

On compatibility: the receiver reads both framings, telling them apart by
the magic, so a peer on an older release goes on being understood, and
the version digit keeps the next change from being a flag day. The
transmitter sends binary only. That is the half worth knowing about
during a rolling upgrade: a node that has not been upgraded yet cannot
read an upgraded node's fragments, and will see it as stale on the
multicast link until it is upgraded too. On a cluster where multicast is
the only heartbeat that is a membership risk for the length of the
upgrade. Sending both framings for a release would remove it, at the cost
of the traffic and of the parse this change is removing.
A multicast datagram is delivered to every member of the group, the
sender included, so on this three node cluster a third of what the
receiver reads is its own. It was recognised as such from the nodename
inside the message, which is the last thing the receiver can reach: every
fragment reassembled, the frame hashed against the dedup cache, and the
whole message decrypted, before finding out it was never for us.

The transmitter now records the id of each message it sends, and the
receiver drops a fragment carrying one on its header, a map lookup after
the 24 bytes it has already read. The pair share the ring, which is what
it has to be: a datagram only comes back on the group its own tx sent it
to, so a per heartbeat ring is exactly right and a shared one would be
wrong.

Sixteen ids are kept, evicted oldest first, no clock. Our own datagrams
come back at once and a message's fragments arrive together, so nothing
older is ever asked about.

The nodename check stays, as the backstop for what the ring cannot see: a
datagram from a tx that has since restarted, or one whose id aged out
because a great many messages were sent while its fragments were still
arriving.

Not done, and the note that suggested it was wrong: skipping the interval
re-send when nothing changed. That re-send is the beat. Every driver has
it, ucast and disk as well as this one, and with an interval of 5s
against a timeout of 15s it is what keeps a peer from declaring this node
stale while daemondata has nothing new to say. Skipping it would not be
an optimisation, it would be a liveness change, and on a quiet cluster it
would take a node out of the membership rather than save it any work.
The two callers that put the dataset on the wire were each paying three
serializations of it. daemondata deep copied it, which since the copy
became a real one is a walk of the whole dataset, and then the caller
marshalled what came back: GET /daemon/status through echo, the collector
feed into its request body. The copy exists only so that the caller can
marshal something that will not change under it, so marshalling where it
cannot change removes the need for it.

ClusterDataJSON marshals on the daemondata goroutine and returns bytes.
No copy, and nothing for the caller to do but write them: JSONBlob for
the api, a json.RawMessage field for the collector body, so embedding it
copies bytes rather than walking the dataset a second time. It stays
behind the same singleflight, and sharing is now unconditionally safe:
sharing the struct was what made concurrent callers a bug, bytes cannot
be modified by anyone.

Measured on the captured 103 object dataset, per caller per cycle:

    copy then marshal   5.79ms   2.75MB   21780 allocs
    marshal only        4.44ms   1.68MB   13492 allocs

The api keeps the struct path for a request that filters, by selector, by
namespace, or by an rbac grant that is not cluster wide, since filtering
needs a struct. That predicate is evaluated before asking daemondata,
which is what lets the common request take the cheap path.

The trade this makes, which the note proposing it did not mention: the
marshal moves onto the daemondata goroutine, where it blocks every other
cluster data operation while it runs. That goroutine is busy 4.44ms per
reader now where it was busy 1.02ms doing the copy, so its occupancy goes
up more than fourfold even as total cpu drops by 19%.

Worth it on the numbers, and worth saying out loud. Two readers at the
rates this cluster runs, a poller every 6s and the collector every 10s,
put that at about 0.11% of wall time against 0.026%, and each individual
block is 4.44ms where before P1 it was the 16ms of a json round trip
deep copy. So this is still well under half of what the same goroutine
was blocked for a few commits ago, while allocating less than either. If
a reader ever arrives that polls hard, the answer is not to go back to
copying but to stop marshalling per read.
pgmetrics walked 24 cgroups on a ticker, opening about ten sysfs files in
each, whether or not anyone was looking. On the profiled cluster that was
6.8% of the daemon's cpu and 10.8% of its idle allocation, and 4.4% and
7.8% of those were os.ReadFile alone.

Splitting the endpoints made that worse in principle: /metrics/pg is
meant to be scraped on its own, much longer interval, or not at all until
someone is looking, while the collection went on at fifteen seconds
regardless.

The metrics are now const metrics, built during the walk by the collector
that emits them, and the walk happens when a scrape asks. A snapshot is
reused for minInterval, which is the ticker period this replaces, so the
data is no less fresh than it was and a /metrics scrape every few seconds
does not walk every few seconds. Nothing scraping means nothing walking.

The obvious shape, a collector that refreshes the gauge vectors and emits
nothing, does not work: a registry gathers its collectors concurrently
and in no defined order, so the vectors could be read either side of the
refresh. Emitting what you just read, from one collector, has no such
question in it.

The hints on /metrics are the reason this needed a second collector
rather than one. They have to stay fresh on an endpoint scraped every few
seconds, and making that trigger the full walk would have saved nothing.
So the walk takes a full flag: the cheap one reads the two files the
memory utilization ratio needs and counts what it stats, the full one
reads the other eight and emits the detail. Benchmarked over 100 objects
of which 24 have a cgroup, 591us against 1929us, and 131KB against 526KB.

Const metrics also delete a class of bug rather than fixing it. A vector
remembers every label combination it was ever given, which is why a
vanished cgroup had to be deleted from each of fourteen of them by hand,
why the manager carried two maps to know which cgroups to run that pass
for, and why doing it on every tick for every object without a cgroup
cost 9.4% of the daemon a few commits ago. A walk emits what it found, so
a cgroup that is gone is simply not in the next one. forgetUsage, the
maps, the deletion pass and the ticker are all gone with it, and the file
is 180 lines shorter.

New takes no queue sizer: the manager subscribes to nothing now, having
no goroutine left to receive on.
The objects table sets viewPath from the selected row and viewNode from
the selected column, in two independent ifs, so a cell at the crossing of
an object row and a node column has both. The config view read them in
the order node, cluster, object, and so answered such a cell with the
node's configuration.

'e' read them in the order object, node, cluster, and so opened the
object's configuration for the very same cell. One of the two orders is
right and it is 'e' that had it: a cell that names an object is about
that object, whichever node's column it sits in.

The order is now written once, in configTargetFor, which both read. That
is the actual fix. Two copies of a precedence is what let them disagree,
and reordering the switch without removing the second copy would leave
the next pair free to drift the same way.

Two tests, because they fail for different reasons. The unit test pins
the decision, including the case the bug was about. The other drives the
real application against the node's daemon with a simulation screen,
lands on an instance cell, presses 'c' and reads the title off the view:
against the old order it reports 'c' showed the node configuration
("dev2n1 configuration") for a cell naming object appstatuslog, which is
the report this fixes, in the words it was reported in.
The commit that made daemondata hand out the dataset already marshalled
put it in the request body as a json.RawMessage field, on the assumption
that embedding it there would copy bytes. It does not. encoding/json
compacts a RawMessage, which validates and copies it byte by byte, and
appendCompact was 7.5% of the daemon in the profile after that deploy.

Measured over the 103 object dataset, the whole body:

    deep copy, then marshal the struct   5.45ms  2.57MB  21780 allocs
    marshal, then a RawMessage field     8.22ms  1.91MB  13493 allocs
    marshal, then splice                 4.25ms  1.55MB  13494 allocs

So the change traded a deep copy for a compaction that costs more than
the copy did: it was a 51% regression on cpu, bought with a 26% drop in
allocation. Splicing is what it was supposed to be, and is 22% below
where it started on cpu as well as 40% below on allocation.

The dataset is no longer a field of the body. The envelope is marshalled
without it and the bytes go in before its closing brace, through an
io.MultiReader, so nothing walks the dataset between daemondata and the
socket.

Escaping survives, which is the thing to check when stepping around a
serializer: the dataset comes out of a json.Marshal that has already
escaped what needs escaping, so compacting it again only copied it. The
test covers a dataset holding & and <, and asserts the body parses to the
same thing the RawMessage field produced.
The echo middleware breaks each of its four metrics down by url, and two
of them are histograms, so one route reached with one method and one
status code is 36 series. On a cluster whose nodes talk to each other
over the api that was 1025 of the 1100 series /metrics answered with,
and it grows with the number of routes exercised rather than with
anything an operator asked to watch.

The middleware now registers into its own registry, with its names
prefixed route_ so the plain ones are free for the aggregates, the way
the scheduler's object_runs_total leaves runs_total to the hint. The
rate limiter's denials by method and path follow, being the same kind of
series about the same requests.

What is left on /metrics is the request rate by code and method and the
latency of all routes taken together, which is what an alert fires on,
plus the unlabelled denial counter, whose value is the sum over the
vector it replaces. Each says where the breakdown is in its help text.

DoNotUseRequestPathFor404 comes along with the move: without it a
request no route matches is labelled with the path it asked for, so
anything scanning the listener writes series nothing ever removes, and
the endpoint they now write to is the one meant to be scraped rarely.

/metrics goes from 1100 to 85 series on a dev node, 16 of them the api
hints, against 123 at /metrics/api.
Someone reading a scrape of /metrics has no way to learn that
/metrics/pg, /metrics/pubsub and /metrics/scheduler exist, and the
aggregates that were left behind are the place they would look: an
operator wondering which object is running the schedule entries counted
by opensvc_scheduler_runs_total is already holding the metric that knows
the answer is one endpoint away.

So each hint whose breakdown is served apart names the endpoint and the
dimension it is broken down by, in the parenthetical the api hints
introduced. Only the hints that actually have a counterpart there get
one: the pubsub queue counters have no detail, and pointing at
/metrics/pubsub from them would send a reader looking for something that
is not there.
Five call sites reached a peer's api as the local node by sending the
cluster secret as a basic auth password, which the peer compared to its
own copy. That secret is also the key every sec object is encrypted
with, so a call that resolved to the wrong listener handed over rather
more than the config file it came for, a hazard both core/client and
daemon/discover carried a TODO about.

They now mint a five second token signed with the cluster CA private
key, which every node has, so any peer verifies it with the jwt strategy
it already runs. This is what the api proxy has been doing since it
learned to forward a request to the node that can answer it, and what
the collector's dequeue action does; CreateNodeToken is that, named, and
those two now call it.

With the last user gone the basic node strategy is removed, along with
NodeAuthenticater, ccfg.NodeDB and the client's habit of calling itself
by the local hostname when it had no other identity to offer. A peer
running the previous version still authenticates with the secret, so
this expects the cluster to be upgraded before node to node calls work
again.

CreateToken no longer returns an empty token and no error when the sign
key failed to load: the caller sent the empty string as its bearer and
read the 401 back as a credentials problem, one node away from the
message saying why.
The daemon compiled about a thousand lines of go-guardian to run six
strategies, of which it wrote the interesting half itself: ux was already
hand written, and jwt, x509 and basic user were our validate functions
wrapped in a header parser and a cache. The library's own contribution
was r.BasicAuth, an Authorization header split, Certificate.Verify
against a pool, a loop over the chain, and the openid id token verifier.
Those are here now, at about a third of the size, and with tests, which
the package had none of.

It also settles the two jwt packages: go-jose came in only under
go-guardian's openid strategy, and the id tokens are now verified with
golang-jwt, the one this repository already signs its own tokens with in
daemonauth, omcmd and the pure array driver. The jwks client that go-jose
was there for is jwks.go: it fetches the provider's key set, caches it
for what its Cache-Control asks, and refetches when a token names a key
id it does not know, which is what a key rotation looks like from here.

auth.Info and its map of string lists become the Info struct. The
extensions map was how a fixed library interface carried what this daemon
wanted, and every reader had to know which key held the grants; the
fields say it instead.

Verification got stricter on the way. Both jwt strategies now name the
signing methods they accept, so a token asking to be verified with
something we never issue is refused before a key is fetched for it, and
both require an expiration claim, which also removes a nil dereference on
a token that carried no registered claims at all. The openid strategy
refuses a token whose alg does not match the alg its key was published
for.

The authentication cache is keyed by a digest of the credential rather
than by the username, so a heap dump does not hand over the passwords
presented to the daemon, a wrong password cannot be answered by the entry
a right one left behind, and an entry lives at most a few seconds however
long the token it came from is valid for. It is also bounded: its keys
come from the request.
The 401 body carried what each strategy made of the request, and those
errors are not equally uninformative: "read password from
system/usr/bob: key does not exist" for a name nobody has, "wrong
password" for one that exists. Any client could walk a list of names
through it and learn which are real, and read off which strategies the
listener runs while doing it.

The body now says the credentials were refused and nothing else. The
line above it already logs the full joined error with the peer address,
so the detail an operator needs to debug a client is still written down,
at the daemon rather than at whoever asked.

The password comparison in usr.go becomes constant time in passing. It
is not what was leaking: the timing difference between the two paths
above is nanoseconds behind a tls handshake, a file read and a decrypt,
and fifteen requests of each measure the same 12ms. It is one line, and
it removes the question.
Creating a client certificate for a usr object took two keywords set
first, and neither had a default: cn, because the api reads the common
name as the username, and ca, because a certificate the cluster
authority did not sign is refused. The documented procedure did not
mention them, and what it produced was a self signed certificate naming
nobody, refused twice over by the listener it was made for.

They now default to the name of the object and to system/sec/ca, so the
certificate a usr object generates authenticates as that user, which is
the only thing it could have been for. The sec keywords keep no default:
system/sec/ca signs itself, and a listener certificate is named after a
host rather than after its object.
The cluster `ca` keyword is documented as a list: "the listener accepts a
x509 client certificate if it is trusted by any CA certificate found in
these sec objects". The listener writes the certificate chain of each of
them into the ca file, and the x509 strategy then pem decoded the file
once, took the certificate that came out and made a pool of it. So the
first authority worked, and an operator who added a second watched
certificates it signed be refused as signed by an unknown authority,
with the keyword documentation saying otherwise.

The pool is now built from the whole file. A certificate signed by any
authority in it is accepted, one signed by anything else is still
refused, and a file with no certificate in it is an error rather than a
pool that trusts nothing.
The speaker seeds its send queue from every instance status it holds,
and a datastore or a configuration object has an instance status like
anything else. It has no resources though, so what followed was one
warning per object per speaker change: "object does not support resource
info" for the local instances, whose object cannot answer, and
"unexpected response: 400 Bad Request" for the peer ones, whose api
refuses the question. Both are the same mistake read from the two ends.

The queue now takes svc and vol instances, which are the kinds whose
objects have resources. The two errors stay where they are, as the
answer to a question this daemon no longer asks.
Since the journald writer was capped at info, five days after the audit
endpoint landed, "om daemon log --level debug" has set a global that
nothing downstream can honor: the daemon runs with --quiet, so journald
is its only writer, and the writer drops what is below info. The request
succeeded and changed nothing, and the command printed nothing either
way, so there was no way to tell.

The endpoint now refuses a level below info and names the audit endpoint
in the refusal, which is where a debug or trace feed comes from: audit
reads the messages before they reach a writer, which is why it works and
this cannot.

It also answers with the level in effect, and a GET reports it without
changing it. Nothing reported it before, so a node left silent by an
earlier --level none had nothing on it to say why. "om daemon log" with
no --level now prints the level of each selected node, and with one
prints what the level became.

Sub-actions in general say more when they fail: doNode read the status
code and dropped the body, so a 400 explaining itself was reported as
"unexpected status code 400". It now renders the problem document, and
closes the response body, which it never did.
The listener log level set two things: the level of that listener's own
logger, and daemonapi.LogLevel, a package global the request middleware
gave to every api handler logger. So the half of it that worked silenced
the info lines api handlers emit, and it did so for both listeners at
once, whichever one --name selected. The per listener control it offered
was one global with two writers.

The other half never worked. Asking for debug or trace set a logger
level that the global level and the journald writer, capped at info,
both filter afterwards, and the audit endpoint reads the messages before
any of that, so it was neither necessary there nor sufficient anywhere.

What is left covers it: om daemon log --level warn silences the daemon,
and om daemon audit is the debug and trace feed. If listener scoped
quieting is wanted again, it should come back as a setting each listener
owns rather than as a global they share.

The ux listener now has nothing to do with a DaemonCtl message: the log
levels were the only actions it acted on, and it has no start, stop or
restart of its own. It says so rather than falling through a switch with
one unreachable branch left in it.
A listener answered to two names. Audit called it api.inet, from the
label it registers when a session starts. The actions called it
http-inet, which the api prefixed with lsnr- to reach the name the
listener subscribes to. So the taxonomy a client learns from "om daemon
audit --sub" did not address the same listener through "om daemon
listener restart --name", and the name that did work was documented
nowhere but an api.yaml example.

Both now come from daemonenv: the listener labels its audit sessions and
its control subscription with the same constant the api validates the
path name against, so the name in one place is the name in the other.
An unknown name is refused with the two that exist, where it used to be
accepted, queued and matched by no subscriber. This renames what the
actions take: --name api.inet, not http-inet.

The heartbeat actions are left as they are. Audit spells that
subsystem hb:1 and the action name is 1, prefixed to hb#1, and the
reason the audit label is not hb#1 is that a subsystem list travels in a
query string, where # ends it.

The log control body loses debug from its enum: the endpoint refuses
everything below info now, so the schema said a value that always fails.
The flag help says the same, and both gain a sentence pointing at the
audit endpoint for a debug or trace feed.
The daemon commands were of two minds about what a verb acts on. "om
daemon kill" takes its pids as arguments, with --node and --signal as
the flags they are, while the listener, heartbeat and audit commands
asked for the thing itself through --name and --sub. The first reads
like the rest of the world's command lines, so the others follow it:

    om daemon listener restart api.inet
    om daemon hb restart 1.rx
    om daemon audit imon:* icfg:*

The flags are gone rather than deprecated: --name silently ignored an
argument given in its place, and a spelling that quietly does nothing is
what made this worth touching. Cobra now says "accepts 1 arg(s),
received 0" where the missing name used to be a required flag error.

The audit subsystems travel as a repeated query parameter instead of one
comma joined string, so the client stops joining what the daemon splits.

The subsystem names get a list of their own, which the help text and the
shell completion both read: the help had been written out by hand beside
them and had lost mntmon, and the listener names in it now come from the
same daemonenv constants the actions and the listeners use. Completing
"om daemon audit hb" offers hb, hb.ctrl, hb.main, hb.peer_dropper and
hb:, which was until now discoverable only by reading the help.

Left as a flag: "om daemon ps --sub". It selects what to show rather
than what to act on, and it reads better beside the other filters it
will grow than as the argument of a listing.
v2 read the identity of an app action from the file it ran: a script
owned by a user other than root ran as that user, with the group of the
file, and the user and group keywords were what decided for a script
owned by root. That is what let an operator install a script as
themselves and have it run as themselves, without holding the right to
edit the object configuration.

v3 kept the keywords and dropped the file: every action ran as root
unless a keyword said otherwise, and a v2 configuration that relied on
the ownership silently gained root privileges it never asked for.

The policy is back, from drivers/resource/app/__init__.py of the v2
tree, including the cases around it: a uid with no passwd entry is not
an identity to demote to, so it falls back to the keywords, and a
command needing a shell is run through /bin/sh, whose owner says
nothing about who wrote the command, so it does too.

Two things v2 did in the same breath come with it. The group follows the
user when only the user is named, where om exec demoted the user and
left the command in the group of the daemon, which is root's. And
HOME, USER and LOGNAME name the user the command runs as, where a
script demoted to its owner read the HOME of the daemon that started it.
An env keyword naming one of the three still wins.

Verified on a node: a script owned by daemon runs as daemon:daemon with
HOME=/usr/sbin, with no keyword and even against user=root; the same
script owned by root runs as root, or as the user keyword when there is
one.
…n act on

The relay heartbeat sent a message every time the daemon produced one,
which is one per 250ms propagation tick whenever anything changed, and
the interval only said how long to wait when nothing did. Audited on an
idle three node cluster, one node posted 19 times in 12 seconds to a
stream configured with interval=60s.

All but the last of those were wasted by construction: the relay keeps
one message per node, overwritten by each post, and a peer reads it once
per its own interval. What they were not wasting was the relay's rate
limiter, which every cluster using that relay shares by source address.

The transmitter now keeps the freshest message and posts it at the
interval: at most once, so a burst of changes is one post, and at least
once, since a post refreshes the timestamp the peers read this stream's
liveness from. A message arriving after a quiet interval still goes out
at once, so a node that just started does not wait a minute to be seen.
Measured after the change: 2 posts in 130 seconds, a minute apart, with
every relay stream still beating.

A refused caller is now told what happened. The rate limiter answered
403 with an empty body, which reads as a permission problem and leaves a
client nothing to pace by; it answers 429 with a Retry-After of the time
it needs to grant one token. The handler for an identifier it could not
extract, which is this listener's fault rather than the caller's,
answers 500 instead of the 429 it used to borrow.

And a relay stream that is refused or unreachable says so: both
directions logged every failed beat at trace, which no writer of this
daemon emits, so a throttled heartbeat looked like peers going stale for
no reason. The first failure of a run is a warning, the rest are traces,
and the recovery is logged too.
Pacing the posts to the interval left the stream one second of margin.
The receiver marks a peer alive only when it reads a timestamp it has
not seen, and both sides tick at the interval, so one failed post
stretches the gap between two liveness marks to twice the interval,
against a stale timeout of twice the interval plus a second. A refused
post and a moment of jitter were a stale peer. Before the pacing the
transmitter posted about once a second whatever the interval said, and
that accidental margin was what covered this.

So the failed post is retried rather than skipped. The interval paces
the posts that succeed; one that does not is tried again a quarter of an
interval later, or after the delay a refusing relay asked for, whichever
is longer, and never later than the next beat was due anyway. The
attempts are paced too: a relay that is down would otherwise be posted
to once per message the daemon produces, which is what this all started
from.

And the stale timeout floor goes from twice the interval to four times
it, so three missed beats are tolerated and the fourth is the one that
declares the peer gone. A relay is reached over a network this cluster
does not own, where one lost post is not news. On a node: reajust
timeout: 15s => 4m1s for the interval=60s stream, with every relay
stream beating.

The unicast and disk drivers keep the two interval floor. They still
send per message, so their beats are far more frequent than their
interval and the floor is not what protects them.
The metrics a node exposes were reachable with a curl to the listener's
unix socket, or with a token and a curl to its api. Both are a lot of
typing for something an operator wants while looking at something else,
and neither reads a peer without an address, a certificate and a
credential.

The api endpoint was already node addressed and already proxied to the
node named in the path, so this is the command that was missing in front
of it:

    om node metrics
    om node metrics --node dev2n2
    om node metrics --node '*'

Several nodes are printed one after the other, each preceded by a
comment naming it, so the output stays readable by what reads an
exposition format.

The proxy carries the grants of the caller, so what a user without the
root grant reads is what the endpoint gives them: the metrics of the
objects in the namespaces they have a grant on, and none of the
daemon's own. The command help says so, since the output of a guest is
otherwise a puzzle.
The recover in the om main wrote the stack of the panicking goroutine
to om.stack, and it caught the panics of the command line, where the
panic is on goroutine 1 and the file is a copy of what the terminal
already showed. It caught nothing of what crashes the daemon this same
binary runs. A recover is per goroutine, so a panic in any of the
daemon's other goroutines never reached it and wrote no file at all,
and a fatal error, which is what a concurrent map write or a deadlock
detection is, is not recoverable at all. Both were verified rather than
assumed.

The daemon now asks the runtime for the report instead. SetTraceback
makes it carry every goroutine, where the default prints only the one
that crashed and the service unit sets no GOTRACEBACK, and
SetCrashOutput copies it to om.stack, for a node whose journal was
rotated or never kept. Both cover a crash on any goroutine and the
fatal errors, which is the half that was missing.

The file is truncated when the daemon starts, and a daemon that starts
is often a daemon that just crashed, restarted by its service manager
seconds later. So a report found there is moved to om.stack.1 first,
and only an empty file, left by a daemon that did not crash, is
overwritten in place.

Verified on a node: SIGQUIT to the daemon, systemd restarts it, and the
report of the crash is in om.stack.1 with its 900 goroutines, next to
the empty om.stack the new daemon created.

The recover is gone from both mains. A panicking command prints its
stack to stderr, where the operator running it is looking.
It printed under the ungrouped commands, away from list, logs and
drivers, which is where a reader looks for a command that reads
something and changes nothing.
A prkey identifies a node to the storage, and two nodes holding the
same one can each preempt the reservations of the other, which is what
a scsireserv resource relies on to keep a peer away from a device it
was not given. It happens when a node.conf is copied to a peer without
redacting the prkey, and nothing said so until a takeover did the wrong
thing.

The daemon says so now. The prkey is already in the node config every
node gossips, so nmon compares the peers' to its own on the config
changes it already subscribes to, and writes a warning naming the peer
it collides with. The warning is written when the collision appears and
when it clears, not on every config change of every node in between.

"om node prkey" and "ox node scsi prkey" say so too, on the reading
they already print: the key stays on stdout, the explanation goes to
stderr, and the exit code is not zero. A key the daemon could not be
asked for is not a duplicate: the caller is told the check did not run
and is not failed for it.

Verified on a cluster by giving two nodes the same key: both daemons
logged it, the command exited 1 with the value still on stdout, and
setting the key back logged that it is not used by a peer anymore.

This sees the nodes of one cluster. Another cluster registering its own
keys on the same luns collides in the same way and is invisible from
here, which is why the message says what it found rather than that the
prkey is unique.
The daemon knew a node's prkey collided with a peer's, and said so in
the journal. An operator reads the journal after something went wrong,
which for a duplicate prkey is after a takeover preempted the wrong
reservation.

So the finding travels with the configuration it is about. node.Config
gains Issues, the node config equality gates the publication of it as
it does the rest, and nmon recomputes the list rather than appending to
it: an issue that was corrected has to leave, and looking again is the
only way to know it was. A peer changing its configuration can create
or clear an issue here without a local file changing, which is why the
peer event refreshes and republishes it. It converges, because the
issues of a node are computed from the peers' keywords and never from
their issues.

The api carries them too. GetNode maps the node config field by field
into the api type, so a field added to one and not the other is a field
"ox node ls" cannot see.

The mark is on the states line, which both renderers already share: om
mon writes it there, and the tui does not have to spell it. In the tui
that line becomes selectable, and following the mark opens the issues
of that node in a view of its own.

The node name cell is left alone. The mark had been appended to it,
which is not only uncolored there: the selection handler reads the node
name from that cell's text, so a marked node was a node whose name had
a "!" in it.

Verified on a cluster: two nodes given the same prkey are marked on the
states line of om mon, carry the issue in the dataset the tui reads and
in the api "ox node ls -o flat" reads, and setting the key back clears
the mark and the list.
The tab renderer walked a map every type built of itself, by hand, in
an Unstructured method. The jsonpath it feeds resolves a struct field
by its json tag already, which is the name the tab expressions are
written in, so the maps were a second serialization of types that
already had one, with nothing to keep the two together.

They drifted, as a second copy does. The node config gained an issues
field yesterday, in the struct, in the api schema and in the api
mapping: "ox node ls -o flat" printed it and a tab column of it printed
nothing. The pool map had lost "shared" some time before that. A map
also has to hand write what the language does for free: Node's
dereferenced a nil Monitor, so a tab render of a node whose monitor
data had not arrived yet was a segmentation fault.

So the renderer hands the jsonpath what it was given. A type whose
columns are not its fields still composes a map, and that is what the
interface is for now: ObjectData is a union, a raw message with no
fields to select, and the heartbeat table entry turns states into the
words a reader reads. Both keep their Unstructured method. The 52
mirrors of the api types are gone, and the GetItems methods that lived
in the same file are in one of their own, since the renderer does need
to be told which field of a list holds the rows.

The commands that were building maps to add a column now add it in a
type: a pool line is the pool the api sent with the sizes in the units
a reader reads, embedded so that the pool fields keep their names. The
listings that were building maps for no reason at all hand their items
over.

Two fixes came with it. The optional fields of the api types are
pointers, and a cell is the value, not its address. And a body already
read, as the generated clients with a typed response leave it, is no
longer an error in the sub action runner: it broke "om daemon ps" when
that runner started reading bodies.

Checked against the previous binary on a live cluster, sorted, for
node, pool, array, network, capabilities, drivers, relay, hb, object,
instance, schedule, config and status listings, in tab, json and flat:
identical, but for "shared" appearing in the pool output and the
heartbeat times keeping their nanoseconds.
The heartbeat table composed its changed_at and last_beating_at as
RFC3339Nano text, and the three other times of the same map as values.
So a listing showed two columns to the nanosecond and three to the
second, and no caller could ask for anything else: by the time the
renderer saw them they were strings.

They are values now, like their neighbours. The renderer prints a time
to the second, which is what a column of a listing wants, and the
datasets keep the nanoseconds the type carries, which is what a reader
of json wants. Both from one place, rather than from a decision taken
in the middle.
@cgalibern
cgalibern merged commit 52ac0c4 into opensvc:main Sep 2, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants