Skip to content

meta: stop mutating the published format, publish it only once stored - #7403

Open
solracsf wants to merge 7 commits into
juicedata:mainfrom
solracsf:fix/meta-format-mutation
Open

solracsf wants to merge 7 commits into
juicedata:mainfrom
solracsf:fix/meta-format-mutation

Conversation

@solracsf

@solracsf solracsf commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #7378, which stored baseMeta.fmt as an atomic.Pointer. That guards the pointer, not the struct it points at, and several call sites still wrote into the format every reader shares.

The reload path published too early: Load published the format before refresh handed it to the reload callbacks, and the mount's callback writes the command line overrides (Bucket, Storage, Tiers, UploadLimit) into it on every heartbeat. The parsing is split out of Load as loadFormat, and reloadFormat runs the callbacks while the format is still private, publishing it once they are done. The window where readers saw the stored values before the overrides were applied is gone too.

Status (pkg/meta/status.go) called Load, which publishes the format, and then RemoveSecret on that same pointer, replacing SecretKey, SessionToken and EncryptKey with removed for every reader of the volume. From a long lived client (the Java SDK calls Status on a mounted session) a later quota set would store the placeholders in the volume setting for good. Publishing there also dropped the patches the reload callbacks had applied, and because the next reload then finds the stored format unchanged and skips the callbacks, the client kept running without its own overrides until the stored format changed. It now reads through loadFormat, which does not publish, and scrubs a copy, so only the reported Sections carries the placeholders.

handleQuotaSet (pkg/meta/quota.go) turned DirStats or UserGroupQuota on in the format it had just read from getFormat(), then passed that same pointer on to be stored. On a mount or an SDK client that format is the one the reload callbacks have patched, so the bucket and the limits of a single client went into the volume setting for every other client. Two such calls also raced: each started from the same format, so whichever stored last dropped the flag the other had set, and both reported success. It now loads the stored format, sets the flag on that, and holds a lock across the load and the store.

GetFormat returns the format by value, which reads as a private copy, but Tiers is a map so the copy keeps pointing at the live one. Reading .config on a mount takes such a copy and hands it to the mount's patcher, which writes Tiers[0] straight back into the map the published format owns, while the heartbeat walks it in reflect.DeepEqual. That is a concurrent map read and write, which takes the process down rather than just racing. Format.Clone copies the map, and GetFormat hands that out.

Three related changes fell out of this.

handleQuotaSet only warned when the doInit enabling those flags failed, and created the quota anyway. Since the flag is no longer forced on locally, updateDirQuota sees it disabled and returns immediately, so the quota exists but is never accounted or enforced while the command reports success. It returns the error now, carrying EIO, and fs.FileSystem.HandleQuota passes that errno through instead of turning everything it does not recognise into EINVAL, so a metadata store that was briefly unreachable no longer reaches a Java caller as an invalid request.

Publishing moved out of the engines into Init. Each engine picked its own moment: redis between storing the setting and creating the root inode of a fresh volume, the SQL and KV engines before the transaction that stores it. A caller that got an error back could therefore be left running on a format the volume never received, and whether that happened depended on the engine. Init publishes once doInit reports success, so the engines only store and a fourth one cannot get the ordering wrong.

ErrNotFormatted replaces the message prefix that three places matched on with a string compare. Rewording that message would have quietly turned the reload's os.Exit(UmountCode) into a warning, leaving a mount running against a volume that no longer exists, with nothing failing to build.

Tests

TestPublishedFormatIsNotMutated covers the invariant in seven subtests (quota set, quota set keeps the local overrides local, tiers are copied, failed init, failed init (sql), status, reload callbacks), and TestQuotaSetFailsWhenFormatUpdateFails covers the error propagation. Each case was run against the code without its fix first and fails there, for example:

base_test.go:4533: GetFormat shares Tiers with the published format: Sc="patched"
base_test.go:4517: handleQuotaSet stored this client's override in the volume
base_test.go:4612: Status republished the stored format over the live one: Bucket=""
base_test.go:4661: handleQuotaSet did not report the failed store as EIO

The KV failed init case injects the failure through a tkvClient wrapper that fails txn but not simpleTxn, so doInit gets past its initial read and fails exactly where the setting would be stored. It changes Capacity rather than a flag, because enabling DirStats or UserGroupQuota runs a cleanup transaction that would fail earlier and hide the case under test. The SQL case gets the same reads pass, writes fail split from a read only client, since dbMeta.txn refuses to run while the initial read still goes through simpleTxn. That case fails with the setFormat call sql.go used to have before its transaction.

Three places matched the message with a string prefix: the one that
builds it, the reload that has to bring the mount down when the volume
is gone, and format, which decides between creating and updating from
it. Rewording the message would have quietly turned the reload into a
warning and left the mount running against a volume that no longer
exists, with nothing failing to build.

Export the error and match it with errors.Is.

Signed-off-by: Git'Fellow <12234510+solracsf@users.noreply.github.com>
The format is published through setFormat and read concurrently through
getFormat, so an update has to be published as a new value. The atomic
pointer added for the format race guards the pointer, not the struct it
points at.

Load published the format before refresh handed it to the reload
callbacks, and the mount's callback writes the command line overrides
(Bucket, Storage, Tiers, UploadLimit) into it on every heartbeat. Split
the parsing out of Load as loadFormat, and let reloadFormat run the
callbacks while the format is still private, publishing it once they are
done. The window where readers saw the stored values before the
overrides were applied is gone too.

loadFormat also gives the callers that only want to read the format a
way to do so without publishing it, which the next changes use.

Signed-off-by: Git'Fellow <12234510+solracsf@users.noreply.github.com>
Turning DirStats or UserGroupQuota on wrote the flag straight into the
format every reader shares, then passed that same pointer to be stored.
On a mount or an SDK client that format is the one the reload callbacks
have patched with the options the client was started with, so the bucket
and the limits of a single client went to every other one. Two such
calls also raced: each started from the same format, so whichever stored
last dropped the flag the other had set, and both reported success.

Load the stored format instead, set the flag on that, and hold a lock
across the load and the store. The three cases only differed in the
flag, so they collapse into one helper and one shared case.

Failing to store the flag is now an error rather than a warning: the
quota would otherwise be created but never accounted, since
updateDirQuota gives up as soon as it sees the flag disabled. It carries
EIO, because a store that was briefly unreachable is worth retrying.

Signed-off-by: Git'Fellow <12234510+solracsf@users.noreply.github.com>
HandleQuota turned every error it did not recognise into EINVAL, so a
metadata store that was briefly unreachable reached a Java caller as
"invalid argument" and looked like a request worth giving up on rather
than retrying.

Pass through whichever errno the meta layer reported, and fall back to
EINVAL only when there is none.

Signed-off-by: Git'Fellow <12234510+solracsf@users.noreply.github.com>
Every engine published the format itself, and each one picked its own
moment: redis between storing the setting and creating the root inode of
a fresh volume, the other two before the transaction that stores it. So
a caller that got an error back could be left running on a format the
volume never received, and whether that happened depended on the engine.

Publish in Init, once the engine reports success. The engines just store
now, and a fourth one cannot get the ordering wrong.

The KV case is covered through a client whose writes fail, the SQL one
through a read-only client: its txn refuses to run while the read doInit
does first still goes through simpleTxn. The latter fails with the
setFormat call sql.go had before its transaction.

Signed-off-by: Git'Fellow <12234510+solracsf@users.noreply.github.com>
GetFormat returns the format by value, which reads as a private copy,
but Tiers is a map so the copy keeps pointing at the live one. Reading
the .config file of a mount takes such a copy and hands it to the
mount's patcher, which writes Tiers[0] straight back into the map the
published format owns, while the heartbeat walks it in DeepEqual. That
is a concurrent map read and write, which takes the whole process down
rather than just racing.

Give Format a Clone that copies the map, and hand that out.

Signed-off-by: Git'Fellow <12234510+solracsf@users.noreply.github.com>
Status loads the format through Load, which publishes it. On a client
that is only reporting, that overwrites the format the reload callbacks
had patched with this client's own options, and the next heartbeat then
finds the stored format equal to the published one, skips the callbacks
and never puts the patches back.

It also scrubbed the secrets out of the format it had just published,
replacing SecretKey, SessionToken and EncryptKey with "removed" for
every reader of the volume. From a long lived client (the Java SDK calls
Status on a mounted session) a later quota set would then store the
placeholders in the volume setting for good.

Read the format without publishing it and scrub a copy, so only the
reported Sections carries the placeholders. A client that has nothing
published yet still needs one for the StatFS below, which is the case of
the status command and its fresh client.

Signed-off-by: Git'Fellow <12234510+solracsf@users.noreply.github.com>
@solracsf
solracsf force-pushed the fix/meta-format-mutation branch from 2bf8b6b to 44810a9 Compare August 16, 2026 07:18
@solracsf solracsf changed the title meta: stop mutating the published format meta: stop mutating the published format, publish it only once stored Aug 16, 2026
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.

1 participant