Mounted buckets¶
A bucket mounted with gcsfuse or Mountpoint for Amazon S3 looks like a directory and is not. Reading through one has always worked. Publishing through one now works too, with one exception.
gcsfuse --implicit-dirs --metadata-cache-ttl-secs=0 example-bucket /mnt/gcs
export SIGNAL_DATASET_MOUNT_ROOTS=/mnt/gcs
shard = sds.write_shard(records, "/mnt/gcs/datasets/captures.sds", work_id="worker-000")
sds.publish("/mnt/gcs/datasets/captures.sds", [shard],
dataset_id="captures", snapshot_id="run-001")
From any other machine, that dataset opens as
sds.open("gs://example-bucket/datasets/captures.sds"). The two are the same
bytes: a dataset published through a mount is byte-identical to one published
natively, because references record no object version on either path.
Declaring a mount¶
SIGNAL_DATASET_MOUNT_ROOTS is a list of absolute path prefixes, separated the
way PATH is. Anything beneath one is treated as a mounted bucket.
A mount is declared, never detected. Probing, meaning trying link(2) and
degrading when it fails, would silently degrade on any filesystem that refused
a link for an unrelated reason, which is the “appears to support it” behaviour
worth avoiding. The environment variable is the right shape because “this
machine has a gcsfuse mount at /mnt/gcs” is a property of the deployment: it
belongs beside the gcsfuse invocation in your launcher or Dockerfile, and the
training script stays identical whether it reads from a mount or from gs://.
It is also the only mechanism that reaches everywhere. DatasetView.publish and
materialize take no object_store argument, so a capabilities= argument
could never get to them.
For a single store, pass it directly:
from signal_dataset.storage import MOUNTED_BUCKET, LocalObjectStore
objects = LocalObjectStore(root="/mnt/gcs/datasets/captures.sds",
capabilities=MOUNTED_BUCKET)
If you forget, the failure says so: a missing link(2) or flock(2) is
reported with the path, the filesystem type on Linux, and the variable to set.
What works where¶
local POSIX |
|
|
gcsfuse |
Mountpoint |
|
|---|---|---|---|---|---|
|
yes |
yes |
yes |
yes |
yes |
read annotations |
yes |
yes |
yes |
yes |
yes |
|
yes |
yes |
yes |
yes |
yes |
|
yes |
yes |
yes |
yes |
yes |
yes |
yes |
yes |
no |
no |
Why annotations are refused¶
Every object in the format is written once and never modified, with one
exception: annotations/catalog.json. Publishing a set writes a new content-addressed
revision and then swings that one pointer to it, under a compare-and-swap, so
that two publishers cannot erase each other’s sets.
A compare-and-swap needs either a lock or an atomic replace. gcsfuse implements neither POSIX file locks nor an atomic rename, and the local backend’s version token is the file’s modification time, which on a mount is derived from object metadata at coarse granularity, so two writes in the same tick are indistinguishable. Doing it anyway would silently drop one writer’s annotation set, which is the exact failure the revision-and-pointer design exists to prevent.
So it is refused, and refused before any shard is written: annotation shards are written first and the catalog last, so failing at the swap would leave objects nothing references, with no delete to clean them up.
The workaround is to publish the dataset through the mount and its annotations
natively. It is sound precisely because the two addresses are the same dataset.
Note that publish_annotations takes an open dataset rather than a URI, so the
native address is opened first:
sds.publish("/mnt/gcs/datasets/captures.sds", shards, ...) # through the mount
dataset = sds.open("gs://example-bucket/datasets/captures.sds") # native
sds.publish_annotations(dataset, "detections", records)
What publishing through a mount gives up¶
On a POSIX filesystem the backend writes a temporary file and links it to its final name. The link is atomic, and it fails if the name is taken, so an object at its final name is always complete.
A mount has no link(2), so the file is created at its final name directly. The
name therefore exists from the moment it is opened and the bytes arrive when it
is closed. A writer killed mid-upload leaves a short or empty object at a real
name.
That is detected rather than ignored. The retry compares what is stored against
what it was about to write and refuses, naming the object. Recovery is manual.
Delete it and run again, or republish under a new snapshot_id, because this
library never deletes anything.
Root confinement loses exactly one check. (st_dev, st_ino) no longer pins the
root directory’s identity, because a mount synthesizes inode numbers. In
practice this costs nothing: gcsfuse keeps a stable root inode for a mount’s
lifetime, so the check silently passed rather than protecting anything. The
protections that stop a path escaping, namely openat, O_NOFOLLOW, and
rejecting .., all still apply.
Mount options that matter¶
--implicit-dirs is effectively required for gcsfuse. This library never writes
directory placeholder objects, so a natively published dataset has no directory
entries and gcsfuse will not show objects/... without it.
--metadata-cache-ttl-secs=0 matters on a mount you publish through. Creating a
file exclusively is a lookup followed by a create, and a cached negative entry
can let the lookup miss an object that exists.
Mountpoint needs --allow-delete for nothing this library does, since it never
deletes. Note that it implements no rename, which is why the publish path
uses no temporary file at all.
One coordinator at a time¶
Mount publication assumes a single coordinator. Many workers may produce shards beforehand; one process publishes them.
This is not enforceable, because without a lock nothing here can exclude a
second writer, so it is asserted rather than pretended. What is checked: every control
document is verified to hold the bytes that were written, and two coordinators
sharing a snapshot_id collide on the first manifest, while two with different
ones collide on root.json, which is create-only.