Releases: scttfrdmn/objectfs
Release list
ObjectFS v0.13.0
Two things that existed and did nothing now do something.
cluster.enabled: true started a gossip ring and stopped there: the adapter never constructed a
ClusterManager, FileSystem had no coordinator, and AnnounceKey/QueryKeyOwnership returned
ErrNotSupported from stubs. A cluster's nodes could see each other and could not use each other. Now a
write invalidates what peers have cached at the ETag it wrote, a cache miss learns from peers which keys
are hot and reads further because of it, and a joining node is told what to warm before it serves its
first request. The bytes still come from S3 — peer data fetch is not in this release, and [#399] is why:
a sealed gossip datagram tops out at 5802 bytes of payload, so the datagram carries the metadata and
S3 carries the object. That is the honest version of the feature rather than a 128 KiB read that fails as
a 30-second timeout on a different host.
The observability half is the same shape. internal/awsrates held request prices verified against the
live AWS Pricing API and every path from a rate to a user was severed — a cost package with no
importer, a RecordCost with no caller, a report behind a config key no mount could set. Latency
percentiles were declared and never assigned, computed from a histogram that was ms % 100 rather than
a bucketing, so 50 ms and 250 ms shared a bucket. A Transfer Acceleration fallback was permanent for the
life of the mount, invisibly. Compression ran zstd over BAM, tar.zst and JPEG and then discarded the
result. Each of those was a feature the documentation described, and none of them could be observed or
switched back, which is the difference this tag is about.
Milestone v0.13.0 — Cache Warming & Operational Excellence closed at zero open, 20 issues.
Cost accounting is worth one caution: the request counts come from a middleware in the AWS SDK's
Deserialize step, not from the wrapper layer, because those two disagree — a 5 GB PutObject is one
wrapper call and 641 requests to S3. The figures are what S3 was asked for by this mount, per attempt,
excluding 5xx. They are not a substitute for Cost Explorer, and the metric's help text says so.
Added
-
Data that arrived compressed is no longer compressed again ([#184]). With compression enabled, a
write abovemin_sizewhose leading bytes name a compressed format — gzip, zstd, bzip2, lz4, xz, zip,
PNG, JPEG, GIF, WebP, MP4, Matroska, Ogg, MP3, PDF — is stored as-is and the codec is never entered.
These are the formats a research bucket is mostly made of, and a BAM counts: BGZF is gzip per block, so
its first bytes are gzip's.Measured on an Apple M4 Max at zstd level 3, six runs per case compared with
benchstat: an 8 MiB zstd
frame goes from 1.85 ms and 33 MiB of allocation to 3 ns and none, 1 MiB from 237 µs to 3 ns,
64 KiB from 7.4 µs to 3 ns. Data the skip does not apply to is unchanged — 1 MiB of compressible text is
91.5 µs against 90.4 µs, which is the row that says the check pays for itself, since a gate that taxes
the compressible path is not a win. Reproduce withBenchmarkCompressAlreadyCompressed,
BenchmarkCompressCompressibleTextandBenchmarkGateVersusAnalyzeininternal/compression.The gate is a magic-byte comparison at 17 ns, not the full analyzer.
Analyzealso runs a Shannon
entropy pass over its 4 KiB sample, which costs 3.8 µs and was +53% on a 64 KiB text write for a
figure the decision does not use — so the write path calls a newAlreadyCompressed, and both it and
Analyzeclassify through the sameclassifyByMagic, leaving one authority on what "already
compressed" means. The check sits after themin_sizefloor, so an object below the floor is not
sampled, and after the enabled check, so a mount with compression off samples nothing at all.Not done, deliberately: nothing is skipped on entropy. High entropy is evidence that compression
will not help, not proof, and no threshold for it has been measured here — declining to compress on a
guess is worse than spending the pass. Formats whose compression is internal to the container (CRAM,
Parquet, ORC, HDF5, Zarr) are also not skipped, since the container's magic bytes say nothing about its
contents;docs/features/compression.mdnow sorts the formats into the two lists explicitly, and
TestAlreadyCompressedMatchesTheDocumentedFormatListsfails if that page and the table disagree. -
A mount now publishes what it is spending at AWS ([#226]).
objectfs_s3_costis a new gauge family
carrying billable request counts by pricing group, bytes retrieved, three dollar figures, and — beside
each — the rate it was computed at, labelled with the region the rates were resolved for and the storage
tier they belong to. Before this,internal/awsratesheld a rate table verified against the live AWS
Pricing API by an integration test, and every path from a rate to a user was severed: the
access-pattern report was gated behind a config key no mount could set,internal/costhad zero
importers, andmetrics.RecordCosthad no caller anywhere.The requests are counted in the AWS SDK's own response path, not at the wrapper layer, and that is
the substance of it rather than a detail. The eight places this backend records metrics are not
one-to-one with billable calls: a 5 GBPutObjectis one wrapper call and 641 requests to S3 — one
CreateMultipartUpload, 639UploadParts, oneCompleteMultipartUpload— and a largeCopyObject,
a parallel ranged read and the capability probes fan out the same way, one of them counted nowhere at
all. Every one of those errors is in the direction that flatters. A middleware in the SDK's Deserialize
step, installed on every client this package builds including the connection pool's, cannot disagree
with what S3 received because it is what S3 received. It counts per attempt, since the retryer
re-enters that step; it requires a response, since a connect or DNS failure never reached S3 and is not
billed; and it counts only statuses below 500, because AWS does not bill for its own server errors
and does bill 4xx — aHeadObjectreturning 404 is a charged read, and 5xx during an S3 event would
otherwise inflate the figure at the moment an operator is looking at it hardest.Four pricing groups, not AWS's two request tiers: writes, lists, reads, and the free operations. Lists
are separate from writes even though the published price list calls both "Tier1", because on
DEEP_ARCHIVE a PUT is $0.05 per 1,000 and a LIST is $0.005 per 1,000 — a factor of ten, and pricing
a directory traversal at the write rate would overstate it worst on the class where the error is largest.
Classification is by operation-name prefix and an unrecognized operation counts as a write, so an
operation this code has never heard of makes the reported cost too high rather than too low.What the figures are is stated narrowly, because a cost number that implies more than it knows is worse
than none. They are this process's spend since it started, at list prices for the first volume band:
not a bill, and not a reconciliation of one.bytes_storedis what this mount has uploaded, not the
bucket's size — nothing lists the bucket, since that would be a billed request per scrape to publish a
metric. Every figure is monotonic and there is no reset, because rate-of-change is the form a useful
alert on cost takes and a counter something can zero is one that will be zeroed. A fresh mount reports
read_requestsof 1 before any filesystem work:NewBackend's health check is aHeadBucket, AWS bills
it, and the tests assert that 1 rather than asserting zero.Verified against a recording proxy rather than against the counter's own arithmetic: the tests compare
what the tally counted with what the S3 endpoint actually received, including a multipart write where
both sides must agree and the count must exceed the number of parts, and an injected 500 where the
counted total must equal the observed total minus the server errors. The dollar assertions state AWS's
published rates as literals and divide in the test, per the lesson from [#220]: the old tests passed
1024*1024*1024bytes, called it one GB, and asserted the per-GB rate came back — an expectation that
holds under both the right divisor and the wrong one. Byte-to-GB conversion goes through
awsrates.GBFromBytesthroughout, and the mutation to a binary divisor is caught with a ratio of
0.9313, which is exactly the 7.4% gap. -
A cache miss now reads further when a peer holds more of the object ([#142]). A read that misses
asks the cluster who holds the key and, if some peer holds a range reaching past what was just read,
reads ahead to the end of that range — capped at 4 MiB per miss. The bytes come from S3, not from the
peer: a gossip datagram carries 5802 bytes of object at the default limit, so a 128 KiB read is 21×
over and fails as a 30-second timeout on the requesting host ([#399]). What a peer's claim is evidence
of is that the key is hot in this cluster, which is the one thing a cold node cannot learn on its own,
and it is worth acting on even when the bytes still cost an S3 read.A claim is acted on only at the version this node is reading. A holder's range describes the object
it cached; against a different version the object may have a different length entirely, so warming on it
would fetch bytes chosen by a claim about something else. A version mismatch — or no known version on
either side — warms nothing, which costs exactly the read the application asked for. Every claim at the
matching version is considered and the furthest-reaching one wins, since holders arrive freshest-first
and freshest is not furthest.Warming goes through the existing read-ahead queue, so it inherits clamping to ...
ObjectFS v0.12.0
Coordination stops pretending. ObjectFS's distributed layer had a consistency taxonomy, a Raft log,
and a CacheReplicator; what it did was send the same PUT to N nodes writing the same key and call
majority success linearizable. That is replaced by compare-and-swap on the object store itself —
Backend.PutObjectIf asserts a precondition, internal/coord's lease re-asserts the CAS on every
guarded action, and an endpoint that cannot honour a precondition is refused rather than downgraded.
The code that simulated the guarantee is deleted rather than left as a fallback, because a fallback
here reports success to every contender for a lease, which is the outcome the mechanism exists to
prevent.
This tag carries two closed milestones: Distributed Foundations & Install Simplicity (24 issues)
and Test Harness, Coverage & Build Hygiene (24 issues). Both were at zero open before it was
cut.
The second is what makes the first credible. Four SDKs shipped in this repository and not one of
them compiled: the JavaScript SDK had 48 tsc errors, the Java SDK four mvn compile errors, and
the C SDK a maximum-length S3 key that came back one byte short — each surviving because no CI job
ran the compiler. Four build tags carried code nothing built. Ten tests in tests/fuse_test.go
asserted against the mock they constructed rather than against the filesystem they discarded. A
release that adds a coordination primitive on top of that has no basis for the claim, so the gates
came first: every build tag compiles in CI, every SDK builds and tests, the lint backlog is 570 →
299, and the four suites that could not fail are gone rather than repaired.
Two findings are worth reading before deploying. Ceph RGW ≤ 19.2.0 implements conditional writes
partially — it answers 412 for a key that does not exist, rejects the quoted ETag it just
returned, and ignores preconditions on CompleteMultipartUpload, so a conditional write large
enough to be multipart is silently unconditional. The mount-time capability probe now detects this
and refuses; docs/design/conditional-write-compatibility.md records the full matrix, measured
against real AWS, MinIO and RGW endpoints rather than read from documentation. Separately, gossip
had no message authentication, and a cluster will not start without a shared secret.
What changed
- Added — 19 entries
- Fixed — 75 entries
- Changed — 13 entries
- Removed — 8 entries
- Security — 2 entries
The full text of each entry is in CHANGELOG.md, under [0.12.0].
It is long by intent: an entry records what was wrong and how the fix was verified,
which is the evidence a filesystem release has to carry.
Verify a download:
sha256sum -c objectfs-<platform>.tar.gz.sha256
Container image: ghcr.io/scttfrdmn/objectfs:0.12.0
Full changelog: https://github.com/scttfrdmn/objectfs/blob/v0.12.0/CHANGELOG.md
ObjectFS v0.11.0
POSIX completeness and write-path safety: the operations a user reaches for first — rm, rmdir,
mv, chmod — do the thing rather than returning an error or, worse, succeeding without acting. All
28 issues on the milestone closed before this tag was cut.
Two themes run through it. The first is that a configuration key should select something: nine
blocks that a loader had never read now reach the code they name, and the pricing region — read at
exactly one line, and only to label a summary — now picks the prices, so a mount in sa-east-1 no
longer reports us-east-1's rates under its own region's name. The second is that a fix is paired
with the mechanism that fails if it recurs, because most of what this release corrects was invisible
to the compiler, to vet, and to lint: a config key nothing reads, a cost figure nothing checks, a
gate that cannot fail.
This is the first release cut from the merge commit that closes its milestone, which is the rule
v0.10.3 established after two tags shipped without the work they were named for.
Added
-
rmandrmdirwork.UnlinkandRmdirdelete the object rather than returning EROFS
([#163]). The stub they replace was itself a fix — go-fuse defaults an unimplemented
NodeUnlinkerto success, so before itrmexited 0, the kernel dropped the inode, and the
object stayed in the bucket billing with no path that reached it. Three details are load-bearing
and are pinned by tests:- Deleting a file discards whatever the write path still holds for it.
echo x > f; rm fis
ordinary and the kernel does not guarantee a flush before the unlink, so a surviving dirty range
would be PUT back by the next flush or by the unmount — the file returning from the dead at its
written size, with no error anywhere. rmdirrefuses a non-empty directory with ENOTEMPTY. S3 has no directories, so removing a
prefix's marker object while objects remain under it would succeed at the storage layer and leave
every one of them present, billing, and unreachable through the filesystem.- A missing file is ENOENT, not success. The backend's
DeleteObjectno-ops a key that is not
there — S3's contract — so absence is checked explicitly, and it is checked against both the
bucket and the write path:Createrecords attributes without a PUT, so a just-created file is
real and visible tostatwith no object behind it yet.
- Deleting a file discards whatever the write path still holds for it.
-
mvworks, and the README says exactly how far short of POSIX it falls.Renamecopies
server-side and then deletes, per object; renaming a directory moves everything under its prefix
([#164]). Before this, go-fuse's default for an absentNodeRenameranswered everymvwith
ENOTSUP. Six properties are load-bearing, each pinned by a test that was verified by mutation —
the implementation was broken in that specific way and the test watched to fail:- Each source object is deleted only after its own copy has succeeded. An interruption
therefore leaves the data at the old name, the new name, or both — never at neither. Duplicated
data is an operator's cleanup problem; missing data is not recoverable, so the ordering is fixed
in that direction deliberately, and a partial directory move is resumable by re-running the same
mv. - The copy is server-side. Reading and rewriting through the process would make renaming a
10 GiB file cost 20 GiB of transfer, and renaming a directory that times the object count.
Objects above S3's 5 GiB single-partCopyObjectlimit route throughUploadPartCopy. - The write path is flushed before the copy runs. A copy acts on objects, so a file whose only
content is dirty ranges in memory is invisible to it:echo hi > a; mv a bwould have copied a
key that did not exist yet, deleted the source, and then flushed the pending ranges back to the
old name — the file landing at neither name the user asked for. - The moved node is repointed, recursively for a directory.
go-fuse'sMvChildre-parents the
same inode, so theFileNodesurvives a rename holding the path it was constructed with. A
stored path is stale the moment a rename succeeds, and the consequence is silent in a way nothing
would catch: aftermv a b, a write tobflushed toaand recreated the source the rename
had just deleted. A stale key is a valid key, so every S3 call succeeds. - Prefix matching is on a path boundary.
mv dir dir-newmust not movedir2/file. That is not
a hypothetical spelling mistake — it is the same defect the cache'skeyMatcheshad, found in the
same audit. renameat2'sRENAME_EXCHANGEandRENAME_NOREPLACEare refused withEINVAL, not
approximated. Both are atomicity promises copy-then-delete cannot keep,EINVALis what the
kernel and libc expect for an unsupported flag, andmvand Git fall back correctly on it. A
foreignnewParentisEXDEV, which is what go-fuse's ownLoopbackNodeanswers.
There is no atomic alternative to reach for. S3's
RenameObject, added in 2026, is
directory-bucket (S3 Express) only, and object annotations — which ObjectFS needs for POSIX
attributes — are unsupported on directory buckets, so the two features are mutually exclusive. The
README now carries a Rename is not atomic section stating what a concurrent reader can observe,
what an interruption leaves behind, and which tools that breaks: anything relying on the
write-temp-then-rename idiom for atomic replacement is not safe here between concurrent writers. - Each source object is deleted only after its own copy has succeeded. An interruption
-
types.Backend.CopyObject, a server-side copy that preserves content encoding, content type,
storage class, and user metadata. Each of the four is a requirement rather than a nicety, and the
interface documentation says which failure each one prevents: the read path dispatches decoding on
the storedContent-Encodingand fails closed on one it cannot handle, so dropping it would leave a
compressed object permanently unreadable with its bytes intact; the storage-class default is
STANDARD, so dropping it would silently promote the object out of the tier being paid for; and
POSIX mode, ownership, and mtime live in user metadata and nowhere else, so dropping it would reset
a file's permissions, which is not a thingrenamedoes. Encryption is applied from configuration
rather than copied from the source, so a key rotation reaches renamed objects. -
internal/testaws: aDirectoryMarkerDeletecapability probe, andRequireDirectoryMarkerDelete
to skip on its absence. Deleting adir/marker whiledir/childstill exists panics the substrate
emulator, because its object store is a filesystem abstraction wheredir/is the directory
holding the child, and removing it orphans the child (filed as
scttfrdmn/substrate#534, reproduced against
afero alone with no substrate involved). In S3 a key is an opaque string anddir/is an ordinary
object, so this is the emulator's property and not ObjectFS's — which is the reason for a runtime
probe rather than an assertion either way. The alternative was to assert the emulator's behavior as
expected, which would encode a dependency's bug as this project's contract. -
internal/storage/s3/copy_live_test.go(-tags=integration) covers the multipart copy path against
real AWS, at real part sizes. It has no hermetic equivalent: substrate dispatches on
x-amz-copy-sourcebefore checkinguploadId, so it answers anUploadPartCopyas a whole-object
copy with a 200 (substrate#532). That leaves the branch where a mistake is most expensive resting on
nothing but reading — abandoned multipart parts are billed and invisible toListObjects. Two
shapes, both deliberate: a legal three-part copy with a short final part, which is where
inclusive/exclusive range mistakes surface, and an illegal one that S3 rejects at
CompleteMultipartUpload, which is how the abort path gets a failure arriving after every part has
already uploaded. -
A
fuse:section in the configuration file, and it is the first one any loader has read
([#180]).direct_io,keep_cache, andsync_read. Nine fields oninternal/fuse.MountOptionsand
internal/fuse.Configcarried yaml tags for a whole release and were decoded by nothing, because
config.Configurationhad nofusekey at all — so afuse:block in a config file was silently
discarded, and the two flags that reach the kernel per-open were settable in Go and returned as the
literal0from everyOpen. All three new fields default to false, false is the kernel's own
behavior for each, andNewDefaulttherefore names nofusesection: the zero value is the
default, and a second place for it to live is how the last set drifted.Each of the four seams between the YAML key and the value the kernel receives is now asserted by a
test verified by mutation — the mapping was deleted or reverted to v0.10.0's code and the intended
test watched to fail. That is the point of the change rather than a detail of it: every one of those
nine fields was correct at the layer that declared it, and died at a boundary no test crossed. The
adapter mapping in particular passed its package's whole suite with the three assignment lines
removed, which is why it was extracted into a method that can be called without a mount.What the flags do to the kernel — whether a second
read(2)at the same offset reaches the
filesystem, whether cached pages surviveopen(2)— cannot be observed without/dev/fuse, so it
lives behind afuse_mountbuild tag with amake test-fuse-mounttarget. CI compiles the tag it
cannot run, because a build tag nothing compiles is how four others in this repo came to carry code
that does not build ([#240]). Those tests fail rather than skip when the device is ab...
ObjectFS v0.10.3
Part 4 of the v0.10.0 audit: say only what the code does, and bill accurately. The audit found that
documentation, cost figures, and repository metadata each asserted things no mechanism checked, so
most of what follows is a correction paired with the gate that fails if it recurs — five of them,
now running on every PR.
This release exists because of a numbering defect worth stating plainly, since it is the second
instance of the same one. v0.10.2 was tagged when the first of this milestone's twelve issues
closed, and the remaining eleven landed after it — so the tag published under that number holds the
packaging fix and none of the work below, exactly as v0.10.1 was tagged two hours before the
module-path fix it was cut for. A tag is not a promise that can be revised: it is already in the
GitHub releases list and cached by the Go module proxy, which resolves a version to one tree
forever. So the fix is a new number rather than a moved tag, and the rule that prevents a third
instance is to cut the tag from the merge commit that closes the milestone, not from the one that
opens it.
Added
- A gate that fails when
CHANGELOG.md's version headings and its link definitions disagree. Keep a Changelog puts each release in a bracketed heading and defines the link separately at the bottom of the file — two hand edits per release with nothing connecting them, and markdown fails silently in both directions. An undefined reference renders as the literal text[0.10.2]instead of a link; a definition with no section renders as nothing. Neither breaks a build, fails a lint, or produces a visibly wrong page, so the only witness is a reader noticing a heading stopped being clickable, which is not a thing readers report. Both halves had already broken:[0.10.2]was never defined at all, and[Unreleased]still compared fromv0.10.1— so the link that answers "what is onmainbut not released" spanned two releases and 52 entries of already-released work.internal/config/changelog_test.gochecks three properties: every section has a definition and every definition has a section,[Unreleased]compares from the version constant, and each release's diff starts at the release immediately before it. The third exists because that failure is the one that renders and resolves — copying the previous definition and editing only the right-hand side produces a real GitHub diff covering more releases than the section it is attached to. All four failure modes were verified by mutation, which is also how the orphan-definition message got fixed: it printed the URL where the version belonged. This is the same defect the release itself is about, one file over — a fact restated in a second place with no mechanism to notice the two have drifted - A gate that fails when documentation names a Go symbol or a CLI flag that does not exist.
internal/config/docs_symbols_test.goextractspkg.Symbolreferences from fenced Go blocks — checked against the packages that same block imports, parsed withgo/ast— andobjectfscommand lines from shell blocks, checked against the flagscmd/objectfs/main.goactually declares. This is the mechanism #182 asked for, and the point is that it fires at authoring time: correcting the nineteen files that issue cataloged only resets the clock, since a fenced code block is a string as far as the compiler, vet, and lint are concerned. It found eleven defects on its first run, listed under Fixed below, and a companion test compares the flag list againstmain.goso the two cannot drift apart silently. The admission rule — which references get checked — was chosen by measuring three candidates rather than guessed: checking everylowercase.Uppercasein every Go block gives 93 findings of which 3 are real (s3.Clientis the AWS SDK,errors.Isis the standard library), file-scoped imports give 5 of which 3 are real, and block-scoped gives 3 of 3 with no false positives. Its one known blind spot is stated in the test rather than left to be discovered: a continuation block that uses a package imported by the block above it is not checked - A gate that fails when documentation links at a page that does not exist.
internal/config/docs_links_test.goextracts every relative markdown link from every tracked markdown file and resolves it on disk — relative paths against the linking file's directory, root-absolute paths indocs-platform/against VitePress's routing rule, where/guide/installationis served fromguide/installation.mdand/api/fromapi/index.md. This is #208's mechanism, and it is a Go test rather than the link checker in CI that issue proposed for three reasons recorded in the file: it needs no network and no new tool, so pre-commit and CI check at identical fidelity; it sits with the gates a contributor already satisfies; and an exemption can carry its reason in code, the waydocsExemptFromConfigSchemadoes. It found 45 dead links, not the 24 the issue catalogued — because #208 was written by walkingdocs/, and two whole classes live outside it: 13 links into SDKexamples/directories that have never existed, and 8 root-absolute VitePress routes. That gap is the finding, and it is the same shape asdocs_test.go'snestedSectionNames: scoping a gate to where the defects were already known is how the next cluster stays invisible. A link target is a path, not a symbol, which is why the symbol gate above cannot see it — a link written as[tuning]followed by(./perf.md)is prose to the compiler, to vet, and to lint, and stays prose after the file is renamed. A third test asserts the walk's reach rather than its findings, and it earns its place: a mutation that made the link regexp match nothing left the resolving test passing on zero links and green, and only the reach test caught it - A gate that checks
mkdocs.yml's nav against the tree, in both directions.TestMkDocsNavMatchesTheTreeasserts that every nav entry has a file and that every page underdocs/is either in the nav or exempt with a stated reason. Both directions, because that is how the defect ran: 47 of 50 entries pointed at no file, and 14 of the 17 pages in the tree were missing from the nav. Checking only that entries resolve would have left the orphans, which is the half a reader loses — a page absent from the nav is a page nobody finds. A nav entry is a link target with a different syntax, and that syntax is why it went unchecked: the link gate walks markdown, andnav:is YAML. It is a line scan rather than a YAML parse for a stated reason —mkdocs.ymlcarries!!python/name:tags for the emoji and superfences extensions, so decoding it needs a custom resolver or unsafe mode, and the nav is a flat list of- Title: path.mdlines that needs neither docs/features/compression.md— what transparent compression costs, and when it saves nothing. The question it answers is the one #186 was filed for: project-level compression saves bandwidth and end-to-end latency, so what else does it buy? Less than you would expect. It names four costs, each measured rather than asserted: a compressed object is not readable by anything but ObjectFS (aws s3 cpand boto3 both write the raw zstd frame to disk with a successful exit status — no error to notice); a 4 KiB read of a compressed object transfers the whole stored object, which is 1,836× / 7,344× / 29,380× amplification at 16/64/256 MiB; enabling compression turns off parallel range reads for every object in the bucket, compressed or not; and on the three tiers with a 128 KB billable floor, compressing a 100 KB object to 40 KB changes the invoice by zero. Byte counts are presented as the result and wall-clock only as an aside, for a reason stated on the page — bytes are a property of the design, latency is a property of the day, and the audit's 15.6×/43×/216.5× and this page's 3.0×/5.0×/12.3× are the same defect measured on different days. Every figure is either linked to the AWS page that publishes it or carries its bucket, region, date, and payload, which isdocs-platform/index.md's standard after its hardcoded chart was removed. Two of the numbers #186 itself specified are wrong, and the page states what AWS publishes instead: AWS applies no minimum billable object size toGLACIER,DEEP_ARCHIVE, orINTELLIGENT_TIERING. The archive classes' 40 KB is metadata added per object (32 KB at the archive rate, 8 KB at Standard), which points the opposite way from a floor — compression does reduce the bill there, it just cannot touch the surcharge, which is about 23× the payload for a 10 KB object onDEEP_ARCHIVE. Writing the page found three defects, filed as #228, #229, and #230- A gate that fails when
.github/labels.ymland the repository's labels disagree — in both directions.internal/config/labels_test.gois the fifth mechanical gate, and it exists because the file is a hand-maintained description of state held on GitHub and nothing compared the two, so they had drifted by nine labels. Both directions, because only one is intuitive: all nine existed on GitHub and were absent from the file, none the other way, so a sync that creates labels from the file is green on every one of them — it has nothing to create. That is the failure mode #190's own acceptance criteria name, and the test for the gate is the one they specify: create a label on GitHub without touching the file and confirm the job notices. Verified by doing exactly that, with a throwawayzz-drift-probe. Colors and descriptions are compared too, not just names — a label the file describes differently from the label that exists is drift with a longer fuse, because the name still filters correctly and nothing looks wrong. #190 proposed apaths:-filtered sync job that runs whenlabels.ymlchanges; measurement is why this one runs unconditionally instead. Every drift this repository has had originated on GitHub — tw...
ObjectFS v0.10.2
A packaging release, cut for one reason: v0.10.1 was tagged two hours before the module-path fix
merged, so the published tag still declared module github.com/objectfs/objectfs and
go get github.com/scttfrdmn/objectfs@v0.10.1 failed with module declares its path as — the exact
defect #213 was filed for. The fix existed on main and in no tag, which from a user's position is
indistinguishable from not being fixed. Everything else here is the packaging and contributor-path
work that landed alongside it.
Added
SECURITY.md— a security policy, with private vulnerability reporting enabled on the repository so a finding has somewhere to go that is not the public issue tracker. It documents what a reader cannot get from the code quickly: that the trust boundary is the mounting host and ObjectFS enforces no authorization of its own, that two unauthenticated HTTP listeners bind all interfaces by default (:8080metrics anddebugendpoints,:8081health) with the switch that turns each off, thatmode: offis the encryption default and what changed after the withdrawn v0.10.0at_restkey, and both stated limits of the SHA-256 read verification — a partial read is not verified, and an object with no recorded checksum verifies trivially. Every claim in it was verified by execution rather than read off the configuration schema, which is how the two listener defects below were found
Fixed
- The test harness could record a request after the client already had the response, so its own assertions were load-dependent.
internal/testawsproxies every request and logs it, and the read-path suite asserts on that log: bytes transferred and GETs issued are how read amplification and cache behaviour are measured, because neither the AWS SDK nor the emulator reports them. The log entry was appended afterproxy.ServeHTTPreturned — but the proxy writes the body to the socket inside that call, so a client could hold every byte of a response whose request was not yet recorded. Measured at 45–70 of 640 concurrent ranged reads. The visible symptom was in a different package:internal/fuseTestShortFileIsServedFromCachefailed on its precondition — "the first read issued no GET" — which reads as the read path serving bytes from a cache the fixture had just created empty, in a test whose entire subject is cache correctness. One CI run in seven. Requests are now published on arrival and their response fields filled in on completion, with the accessors waiting for anything still in flight; verified in both directions, since a regression test that cannot fail proves nothing - The module could not be imported under the name it gave for itself.
go.moddeclaredmodule github.com/objectfs/objectfs, and the code lives atgithub.com/scttfrdmn/objectfs. Go resolves an import path by fetching that path, sogo get github.com/scttfrdmn/objectfsfailed on the mismatch between the path requested and the path declared, while the declared path is a different project — an unrelated Python repository from 2017, 28 stars, last pushed 2019, in a single-repo organisation created the same day. Nothing published has ever existed at the declared path, which is whypkg.go.devhad nothing to index and the Go Reference badge rendered empty. The path is corrected ingo.modand in all 154 files that named it — 132 Go files, plus thegoimportslocal-prefixessetting in.golangci.yml, theDockerfileimage-source label, and the repository URLs in the Python and JavaScript SDK manifests, which pointed contributors at the wrong project. Verified by building an external consumer module against the corrected path, rather than by grepping for the string. This is breaking for any code that imported the old path, though nothing could have: it was never fetchable - Dependabot could not update Go dependencies, and had never merged anything. Two unrelated defects presenting as one symptom. The Go ecosystem failed on twelve consecutive weekly runs while
dockersucceeded in the same runs — Dependabot aborts per-ecosystem, so one broken ecosystem is silent unless the run list is read. The cause was upstream and is now resolved:proxy.golang.orghad no.modfor the pinnedcargoshipversion, Go fell through to direct git, and git reported the proxy's 404 ascould not read Username for 'https://github.com'— an authentication message for what was not an authentication failure, which is what sent the previous diagnosis after a credential that was never missing. Separately and more consequentially,.github/dependabot.ymllabelled every PRautomergeand that label did not exist; Dependabot drops unknown labels without reporting it, and every approve and merge step independabot-automerge.ymlwas gated on it, so 46 PRs were opened and none were ever merged. The label is now declared in.github/labels.ymlalongside the four others the config names .github/dependabot.yml:mavenandnpmecosystems forsdks/java,docs-platform, andsdks/javascript. Eight open Dependabot alerts — five againstjackson-databind, three againstvite, three of the eight high severity — were against manifests no ecosystem entry covered, so nothing could act on them.sdks/javascriptis included because CI runsnpm install && npm testthere on every PR, which makes its dependencies executed code. A ceiling worth stating: the npm security updates still cannot apply, because neither directory commits a lockfile and Dependabot cannot determine the installed version without one (#214).github/workflows/dependabot-automerge.ymlwaited oncheck-regexp: (test|lint|security).*, which is case-sensitive and start-anchored, so it matched 2 of the 9 checks CI produces and ignoredcoverage,config-examples, everycross-buildmatrix leg,sdk-metrics,fuzz-smoke, andSecurity Scan. The wait step is removed rather than corrected: which checks must pass now lives in branch protection onmain, which also governs human PRs and cannot drift from a regexp in a workflow file. Native auto-merge is enabled on the repository, without which the--autoflag would have failed even once the label matcheddocs-platform/docker-compose.ymlwas not valid YAML. Twohealthcheckentries put a bare URL inside a flow sequence, where the scanner readshttpas a plain scalar and then meets:in place of,or]. Docker's own parser is lenient enough to accept it, so it went unnoticed — butpre-commit run check-yaml --all-filesfailed on the file, which is the first thing a new contributor runs.gitignoredid not covercoverage/, the directorymake coveragewrites into. The three barecoverage.*filenames only match a profile written to the repository root, which no target producesscripts/setup-hooks.sh— the first commandCONTRIBUTING.mdtells a contributor to run — failed on any current macOS or Debian host, and exited 0 having installed nothing. Five defects:pip3 install pre-commitran first and dies withexternally-managed-environmenton a Homebrew or Debian Python (PEP 668), and because the installer was an if/elif chain testing only whether each command exists, a failingpip3never fell through to thebrewbranch that would have worked; the failure happened inside a condition, soset -euo pipefaildid not catch it; it installed gosec fromgithub.com/securecodewarrior/gosec, which is a 404 (the real module issecurego/gosec, whichsecurity.ymlalready uses); it pinned golangci-lint v1.55.2 against aversion: "2"config only v2.x can parse, handing contributors a lint failure that looks like their fault; and it wrote a.golangci.ymlif none was present, containing linters removed from golangci-lint years ago — now that a real config is committed, that branch would have overwritten it with an unusable one. Each install method is now tried until one succeeds,pipxfirst, every path verifies the command is onPATHafterwards, and the golangci-lint check is version-aware rather than presence-only. It also no longer overwrites.git/hooks/pre-commitwith a hand-rolled wrapper that blocked any commit touching a line matchingfmt.PrintorTODO, including inside a string literal or a comment explaining why a TODO is deliberatemakeprinted fouroverriding commands for targetwarnings on every invocation, includingmake help.BUILD_DIR := buildandCOVERAGE_DIR := coveragemade the directory-creation rule readbin build dist coverage:, colliding with the realbuildandcoveragetargets. The build worked — the later recipe wins, and both are.PHONY— but a build system that opens with four warnings reads as unmaintained, and the names would have genuinely collided the moment one stopped being.PHONY. Replaced with a%/.mkdirsentinel rule, which keeps the pattern out of the target namespace, declared as an order-only prerequisite so writing one binary does not rebuild its siblingspre-commit run --all-filescould not complete: thepretty-format-yamlhook crashed on import, because the pinned rev importspkg_resources, which modern setuptools no longer ships (Python 3.14 here). Bumped to a rev that does not. Fixing it exposed a second problem worth recording, since the obvious repair is the wrong one:check-yamlis PyYAML and follows YAML 1.1, where a bare URL in a flow sequence is a syntax error, whilepretty-format-yamlis ruamel and follows YAML 1.2, where it is legal — so quoting the URL to satisfy the first makes the second strip the quotes straight back off, and the two hooks disagree forever. Thedocker-compose.ymlhealthchecks are now block sequences, the one form both parsers accept and where there are no quotes left to remove.golangci.ymlis excluded from thepretty-format-yamlhook, which damages it: the formatter dedents every block sequence to its parent's column, destroying the nesting tha...
ObjectFS v0.10.1
Every entry below is user-facing, and the release is almost entirely one thing: the defects a deep
audit of v0.10.0 found, and the harness that would have caught them. v0.10.0 is withdrawn.
Four defects in v0.10.0 were verified by execution to lose or corrupt data, and one prevented the
shipped default configuration from mounting at all. They were not independent — they clustered in
three subsystems whose designs could not express what they were asked to do, which is why this
release adds internal/vfs rather than patching six call sites. The write path could not represent
an offset write, the read cache could not hit as keyed, and the FUSE node layer was missing most of
its contract.
The reason 32,680 lines of tests across 90 files caught none of it: every one was a seam defect —
a value correctly produced at one layer and silently dropped at the boundary to the next. A mock on
the far side of a seam agrees with its caller by construction. internal/testaws and
internal/difftest exist to remove that blind spot.
What changed
- Added — 57 entries
- Removed — 16 entries
- Changed — 34 entries
- Fixed — 101 entries
- Deprecated — 1 entry
The full text of each entry is in CHANGELOG.md, under [0.10.1].
It is long by intent: an entry records what was wrong and how the fix was verified,
which is the evidence a filesystem release has to carry.
Verify a download:
sha256sum -c objectfs-<platform>.tar.gz.sha256
Container image: ghcr.io/scttfrdmn/objectfs:0.10.1
Full changelog: https://github.com/scttfrdmn/objectfs/blob/v0.10.1/CHANGELOG.md
v0.10.0 — WITHDRAWN, do not use
⚠️ This release is WITHDRAWN — do not use it
A deep audit of v0.10.0 found defects that prevent the shipped default configuration from mounting
and that silently lose or corrupt user data. v0.10.1 is in progress. Until it ships, no tagged
version of ObjectFS should be used for data you care about.
The three that matter most
C1 — the default configuration cannot mount.
internal/config/config.go defaults compression.algorithm to gzip, but
internal/compression/codec.go implements only none, zstd, and lz4. Every layer that reads
config treats gzip as valid — only the codec factory disagrees — so objectfs s3://bucket /mnt
exits with Failed to start adapter. examples/config.yaml ships the same broken value.
H7 — offset writes truncate the object.
The write-buffer flush callback in internal/adapter/adapter.go is handed (key, data, offset) and
calls backend.PutObject(ctx, key, data), discarding the offset. PutObject is a whole-object
replace, so:
$ dd if=/dev/zero of=f bs=1M count=1 # 1 MiB file
$ printf X | dd of=f bs=1 seek=1048575 conv=notrunc
$ ls -l f
-rw-r--r-- 1 byte # the other 1,048,575 are gone
Non-contiguous writes — SQLite, mmap writeback, tar, HDF5 — return EIO instead. Flush errors are
recorded to a stats counter and never returned, so close(2) reports success after a failed upload.
C4 — read amplification on every object when compression is enabled.
internal/storage/s3/backend.go decides whole-object-versus-ranged fetch from the compression
configuration rather than from the object being read. A ranged read of any object — including
objects never compressed, objects below min_size, and objects written by other tools — downloads
the whole object, and parallel reads are disabled bucket-wide. Measured against real S3 in
us-west-2 with a fixed 4 KiB read:
| object size | compression off | compression on | penalty |
|---|---|---|---|
| 16 MiB | 123 ms | 1.92 s | 15.6× |
| 64 MiB | 117 ms | 5.03 s | 43.0× |
| 256 MiB | 227 ms | 49.2 s | 216.5× |
A 4 KiB read of a 10 GiB object transfers 10 GiB.
Also withdrawn for
- Silent corruption when the codec configuration changes.
Decompressin
internal/compression/s3_integration.goreturns the payload unchanged when the stored
Content-Encodingdoesn't match the configured codec — so an object written with zstd and read
after switching to lz4 emits the raw compressed frame with exit status 0. Theobjectfs-sha256
metadata this very release added is written and never read, so nothing catches it. - The read cache cannot hit and is never invalidated. The cache key includes the requested
length, so theLookupmetadata cache never hits (one S3 HEAD per path component perstat,
forever), short reads at EOF are uncacheable, and the 16 MB chunked cache population added in this
release is unreachable. There are nocache.Deletecalls anywhere ininternal/fuse, so a read
after a write on the same descriptor returns pre-write bytes for up to the 5-minute TTL. - A reachable panic that unmounts the filesystem.
GetObjectwith a negativesizeslices
data[offset:offset+size]with neither bounds arm firing —slice bounds out of range [100:99].
This kills the mount process and takes every open file descriptor with it. - This release's headline feature is inactive in production.
buildS3Configmaps 6 of roughly 30
s3.Configfields and does not mapParallelReadThreshold;NewBackenddoes not backfill it. The
parallel range GET path is gated onthreshold > 0, so it never runs on a real mount.PoolSize
is likewise unmapped, leaving a zero-capacity semaphore that blocks forever in
GetObjects/PutObjects. rmandrmdirreported success without deleting. Fixed after this tag (#163): the operations
now fail loudly withEROFSrather than silently lying.- Windows is not supported. The
cgofusebuild tag has never compiled. Any Windows claim in the
v0.10.0 documentation is wrong.
Why the test suite didn't catch this
Every defect above is a seam defect: a value correctly produced at one layer and silently dropped
at the boundary to the next. The suite's 32,680 lines across 90 files mock the neighbouring layer in
each case, so these are invisible to it by construction. v0.10.1 adds a differential-testing oracle —
identical operation sequences run against ObjectFS and against the local OS filesystem, asserted
byte-for-byte — plus fuzz targets over the write path, the range/slice domain, config loading, and
compression round-trips. Those tests are being written before the fixes, so each fix lands with a
failing-then-passing test.
Track progress: issues ·
milestones
ObjectFS v0.9.0
feat: ObjectFS v0.9.0 — stub replacement & quality pass
Implements all 10 issues from the v0.9.0 audit (#118–#127):
- #118: Fix hardcoded "0.6.0" version in API server; use ServerConfig.Version
- #119: S3 applyOptimization now calls CopyObject for real tier transitions
- #120: MultiLevelCache.Warmup() fetches keys from backend instead of no-op
- #121: CgoFuseFS.GetStats() returns real atomic counters (reads/writes/etc.)
- #122: attemptAutoRecovery() now calls Recoverable.Recover() with retry logic
- #123: Add POST/GET/DELETE /api/v1/mounts REST endpoints + MountManager iface
- #124: Adapter.Stop() clears cache and stops metrics collector (was TODO)
- #125: Add benchmarks for cache, buffer, and adapter packages
- #126: Add test coverage for internal/filesystem package (mockFilesystem)
- #127: Add sync.RWMutex to Go SDK Client for concurrent safety
Closes #118, #119, #120, #121, #122, #123, #124, #125, #126, #127
v0.8.0
What's Changed
Added
-
Distributed backend wiring (
internal/distributed/):ClusterManager.SetBackendandCoordinator.backendwire thetypes.BackendS3 backend intoexecuteLocally, replacing the in-process stub with realGetObject/PutObject/DeleteObject/ListObjectscalls; nil backend returns a descriptive error instead of phantom data (#85) -
Distributed cache invalidation (
internal/distributed/): NewMessageTypeCacheInvalidategossip message type,ClusterManager.SetCache/InvalidateCacheKeymethods, andhandleIncomingMessagedispatch that callscache.Delete(key)on all peers within one gossip round-trip (#86) -
S3 backend benchmarks (
internal/storage/s3/backend_bench_test.go): Nine benchmarks covering GetObject 1 KB / 1 MB / 10 MB, PutObject 1 KB / 1 MB, DeleteObject, ListObjects 100 / 1000 entries, concurrent Get, and latency distribution; use an in-process stub — no AWS credentials required (go test -bench=. ./internal/storage/s3/...) (#88) -
pjdfstest POSIX harness (
scripts/pjdfstest.sh): Shell script that mounts ObjectFS against a test bucket, runs the pjdfstest suite for POSIX compliance validation, and unmounts on exit;make test-posixtarget added to Makefile (#89) -
Java 17 SDK (
sdks/java/): Maven SDK withObjectFSClient(get, put, delete, list, head, mount, unmount, isHealthy),ObjectFSConfig(builder pattern),ObjectInfo,MountOptions,ObjectFSException,NotFoundException, and a full JUnit 4 test suite using MockWebServer (#90)
Changed
- Structured logging (
internal/distributed/,internal/health/,internal/fuse/,internal/adapter/,internal/cache/redis/,pkg/profiling/): Alllog.Printfcalls migrated to structuredslog.Info/slog.Warn/slog.Errorwith key-value attributes across 13 internal packages;log.Fatalretained incmd/objectfs/main.go(#87)
Full Changelog
https://github.com/scttfrdmn/objectfs/blob/main/CHANGELOG.md
Release v0.7.3
Changes
- fix: resolve nine bugs found during v0.7.3 audit (42d0d37)