Files
buckit/docs/request-flow.md

132 KiB
Raw Permalink Blame History

GET / PUT Object — Request Flow

How to read this document

Start with Concepts if terms like EC:4, xl.meta, shard, stripe, quorum, or DataDir are unfamiliar. Then read the storage layer relationship diagrams to see the major components before jumping into the detailed xl.meta, GET, PUT, and DELETE flows.

If you want to understand... Read...
The vocabulary used in every diagram Concepts you'll see throughout
Which layer calls which layer §1 Storage layer relationships
Which Go types represent the storage layers §2 Class diagram
Which HTTP S3 routes map to which handlers §3 S3 API routing reference
What is stored on disk §4 The xl.meta on-disk format
How reads, writes, and deletes work end to end §5 GET, §6 PUT, §7 DELETE
Why local and remote disks use different transports §8 Local vs distributed disk
How pools, sets, disks, and peer nodes are discovered §9 Cluster topology
What other metadata/configuration files exist §10 Internal configuration files

Concepts you'll see throughout

This document leans on a handful of distributed-systems and S3 terms. If any of the diagrams or explanations feel opaque, the definitions below are the missing context.

Storage organisation

  • Bucket — an S3-level namespace, e.g. s3://photos/cat.png lives in bucket photos. Created and destroyed via API; not a filesystem directory.
  • Object — one stored item: a key (the path string cat.png), bytes (the body), and metadata (headers like content-type, custom x-amz-meta-*).
  • Disk / drive — one physical device or one mounted volume. To Buckit, a disk is a directory the server can read/write into.
  • Set (Erasure Set) — a fixed-size group of disks (typically 16) that act as a single redundancy unit. ReedSolomon encoding (below) happens within one set — shards never cross sets.
  • Pool — a group of one or more Erasure sets added to the cluster as a single capacity expansion. To grow a cluster you add a new pool; existing pools stay at their original geometry.
  • Cluster — all pools together. One Buckit deployment exposes one cluster behind an S3 endpoint.

So the hierarchy is cluster → pools → sets → disks, and an object lives on exactly one set within one pool.

Deterministic object placement

Placement means deciding where an object belongs in the storage hierarchy: which pool, which erasure set inside that pool, and therefore which disks will hold that object's shard files. It is the routing decision before reading or writing object bytes.

Buckit does not keep a central "object key directory" that says photos/cat.png lives on set 7. Instead, every node can independently calculate the same placement from the object name and the cluster layout it loaded at startup. Within a pool, Buckit hashes the object key with a deterministic hash algorithm and maps the result to one erasure set. "Deterministic" means the same input key and the same cluster layout always produce the same set choice.

That design removes a central metadata service from the hot path. A GET does not first ask a directory server where the object is. For each pool, the landing node calculates the erasure set where the object would belong using the hash algorithm, then try to read that object's metadata (xl.meta file) from every disk in that set. If more than one pool returns valid metadata, the landing node chooses the newest valid response, and that erasure set in that pool becomes the one used to read the object bytes.

Erasure coding (ReedSolomon)

A way to store data redundantly that uses less space than full replication. The object's bytes are split into M data shards plus N parity shards computed from the data shards. Any M out of M+N shards are sufficient to reconstruct the original — so the storage tolerates up to N disks failing or returning wrong bytes.

EC:N is the storage-class notation for "use N parity shards." For example, EC:4 means 4 parity shards; on a 16-drive set that leaves 12 data shards.

Example: with EC:4 on a 16-drive set, M=12 data, N=4 parity. Storage overhead = (M+N)/M = 16/12 ≈ 1.33× (vs 4× if you replicated four times for comparable durability). You can lose any 4 of 16 disks and still serve reads.

Simplified EC:2 example with 6 disks: M=4 data disks and N=2 parity disks.

Original object bytes
        │
        ▼
Split into rows of 4 data blocks, then add 2 parity blocks per row.
Each column is one shard written to one disk.

                 Disk 1   Disk 2   Disk 3   Disk 4   Disk 5   Disk 6
                 Shard 1  Shard 2  Shard 3  Shard 4  Shard 5  Shard 6
                (part.1) (part.1) (part.1) (part.1) (part.1) (part.1)
               ┌────────┬────────┬────────┬────────┬────────┬────────┐
Stripe 1  ───▶ │block A │block B │block C │block D │parity A│parity B│
               ├────────┼────────┼────────┼────────┼────────┼────────┤
Stripe 2  ───▶ │block E │block F │block G │block H │parity C│parity D│
               ├────────┼────────┼────────┼────────┼────────┼────────┤
Stripe 3  ───▶ │block I │block J │block K │block L │parity E│parity F│
               └────────┴────────┴────────┴────────┴────────┴────────┘

One stripe = one row across all disks.
One block  = one cell in that row.
One shard  = one full column of blocks, stored on one disk.

In this 3-row example there are 6 shards total and 18 blocks total.
  • Shard — the shard is the file written on one disk for one object part. In the diagram, each column is one shard, and each shard is stored as that disk's part.1 file. With 6 disks there are 6 part.1 files for the same object part — one per disk. For multipart uploads, the next object part is stored as part.2, then part.3, and so on.
  • Stripe — one row in the diagram. During PUT, Buckit reads the object body (from upload) one stripe (4 data blocks in the example) at a time: take all data blocks (4 in the example), calculate the parity blocks (2 in the example) for that group, then append the data and parity blocks to each of the shard files (4 part.1) on different disks (6).
  • Block — one fixed-size cell inside a stripe/shard (usually 1 MiB in size). Each block has its own bitrot hash (below) so corruption can be detected at block granularity.

Quorum

The minimum number of disks that must agree (for reads) or successfully accept (for writes) before an operation is considered to have happened. The point is to survive failures and avoid split-brain when a network partition isolates a minority of disks.

In a set of D = M+N disks:

  • Read quorum — for a normal object, usually M (dataBlocks). Enough shards must be available to reconstruct the object bytes.
  • Write quorum — usually M; when M == N it is raised to M + 1 to avoid split-brain between two equal halves of the set.
  • Delete quorumD/2 + 1 (a simple majority). Looser than read/write quorum because a tombstone is just a small metadata flag — being strict here would unnecessarily fail deletes on degraded clusters.

When you see "reads xl.meta from quorum disks" in the diagrams, it does not mean "read only D/2+1 disks." The code fans out xl.meta reads to every disk in the set, then accepts metadata only when enough disks agree for that object's quorum. For a normal object that quorum is based on M data blocks; for delete-marker style metadata, simple majority (D/2+1) is enough.

Quick reference:

Operation Quorum used Why
GET Read quorum, usually M Enough disks must agree on xl.meta, then enough shards must be readable to reconstruct bytes.
PUT Write quorum, usually M; M+1 when M == N The new xl.meta and shard DataDir must commit on enough disks before the write is visible.
DELETE Majority, D/2 + 1 Deletes are metadata updates; requiring full write quorum would make deletes fail unnecessarily on degraded clusters.

Bitrot / silent corruption

A disk that returns wrong bytes without reporting an error — e.g. a flipped bit from a firmware bug, cosmic ray, faulty cable, or aging media. The OS and disk think nothing is wrong; only a checksum can detect it.

Buckit protects against bitrot by appending a small hash to each fixed-size block when writing the shard, and recomputing+verifying it on read. If the hash mismatches, that block is treated as failed and ReedSolomon reconstructs it from parity. The hash function used is HighwayHash (fast on modern CPUs because it uses SIMD instructions).

Distributed lock (dsync / NSLock)

When multiple Buckit nodes can each handle requests for the same object, an in-process lock isn't enough — node A could be writing while node B reads. dsync is Buckit's distributed lock library; it runs a quorum-based protocol across the cluster nodes so that "I hold the write lock for bucket/object" is true cluster-wide, not just on one node. NSLock ("namespace lock") is the wrapper used in the storage layer that takes a (bucket, object) pair and gives you RLock/Lock methods backed by dsync.

MessagePack ("msgpack")

The on-disk format Buckit uses for xl.meta and for many of its inter-node RPC payloads. It's a language-neutral binary serialisation format — like JSON but binary, with a 1-byte type tag in front of every value. Not a Go thing; libraries exist in essentially every language. Smaller than JSON, faster to parse, no separate schema file required (unlike Protobuf). See §4 for the full layout of xl.meta.

Hash functions you'll see (and why each one)

  • MD5 — used as the S3 ETag for non-multipart objects. Not used for security; it's just the value AWS S3 contractually returns.
  • SHA256 — used in SigV4, the AWS signature scheme. The client signs the request with HMAC-SHA256; the server recomputes and compares to authenticate.
  • HighwayHash — used for per-block bitrot detection inside shard files. Cryptographically not strong, but very fast.
  • xxhash — used for the xl.meta trailer CRC. Even faster than HighwayHash; not collision-resistant in a security sense, but more than enough to detect file corruption.
  • CRC32C — optional, for the AWS x-amz-checksum-crc32c header. Stored in MetaSys["x-minio-internal-crc"].

These are integrity hashes (detect corruption) except SHA256-in-SigV4, which is authentication (detect tampering by an attacker).

S3 / API terms

  • ETag — the S3 identifier for an object's bytes; for single-part objects it's md5(body). Returned on PUT, used by clients for cache validation and conditional requests.
  • SigV4 — AWS Signature Version 4, the request-signing scheme S3 uses (Authorization: AWS4-HMAC-SHA256 … header).
  • SSE-S3 / SSE-KMS / SSE-C — server-side encryption modes. SSE-S3 uses server-managed keys, SSE-KMS uses an external KMS, SSE-C uses keys the client provides per request.
  • Single-part PUT — the normal PUT /bucket/object request where the client sends the whole object body in one request. On disk, that object part is stored as one shard file named part.1 on each disk.
  • Multipart upload — uploading a large object as N numbered parts via separate UploadPart calls, then a final CompleteMultipartUpload. On disk, each uploaded part gets its own shard file name on every disk: part.1, part.2, part.3, and so on. Each part has its own ETag; the object's final ETag is md5(concat-of-part-md5s) + "-N".
  • Versioned bucket — a bucket where overwriting or deleting an object preserves the prior version, identified by a UUID. A "DELETE" without a version ID adds a delete marker (see next entry) instead of removing data. Default GETs hide everything below a delete marker; explicit ?versionId=… GETs still work.
  • DeleteMarker — a tombstone entry in a versioned bucket's version chain. When you DELETE an object on a versioned bucket without specifying a version, Buckit does not erase the bytes — it prepends a small "this object was deleted" record to the version chain in xl.meta. The record has its own UUID and timestamp, but no DataDir, no shards, no parts — it is purely metadata. Effects: a default GET returns 404 (the marker is the newest version); GET ?versionId=<original-id> still succeeds and reads the original bytes; ListObjectsV2 hides the object but ListObjectVersions shows both entries. To actually erase the bytes you must do a permanent delete with DELETE ?versionId=<original-id>. Used so deletes on versioned buckets are cheap (no shard rewrite, see §7), reversible (remove the marker → object reappears), and audit-friendly (every deletion is a recorded versioned event). On disk it looks like xlMetaV2DeleteMarker{VersionID, ModTime, MetaSys} — see §4.4 and the two-version sample in §4.9.
  • Object lock / WORM — Write-Once-Read-Many retention. An object under a lock cannot be deleted or overwritten until its retention end date — used for compliance (SEC 17a-4, GDPR retention, etc.).
  • Lifecycle / ILM — bucket-level rules that automatically transition (move to cheaper tier) or expire (delete) objects after a period.
  • Replication — copying writes to a peer S3 cluster asynchronously. The source records pending status in xl.meta; a background worker pushes to the target.

Networking terms

  • Multiplexed WebSocket — many independent logical streams sharing one underlying TCP+WebSocket connection. Each frame is tagged with a stream ID so concurrent calls don't have to open N sockets. Buckit's internal/grid is built this way.
  • Head-of-line blocking — when one slow message on a multiplexed connection delays every other message behind it (the head-of-line message is "blocking the line"). Streaming a 100 MB shard over the same WebSocket as a 50-byte metadata RPC would block the metadata call for seconds. That is exactly why Buckit routes shard bytes over plain HTTP and only metadata over the grid WebSocket — see §8.

1. Storage layer relationships

In this document, a layer is a way to group related code by what it does. It is a mental model, not something that necessarily exists as a separate process, service, or runtime object. Buckit is a complex system, so we organize its components into a hierarchy: the S3/API layer deals with HTTP and S3 rules, the object layer exposes object operations, the pool/set layers decide where an object belongs, and the disk layer performs local or remote disk I/O.

Layer-by-layer responsibility

Layer Class File Role
S3/API layer objectAPIHandlers cmd/object-handlers.go HTTP request handling: auth, headers, ranges, and S3 response framing. Not shown in the diagram, but it calls into ObjectLayer.
Object abstraction ObjectLayer cmd/object-api-interface.go Defines the object operations used by S3/API code, such as GetObjectNInfo, PutObject, and DeleteObject. The concrete implementation is usually erasureServerPools.
Cluster / pool routing erasureServerPools cmd/erasure-server-pool.go Top-level implementation of ObjectLayer; chooses which pool owns the object.
Pool / set routing erasureSets cmd/erasure-sets.go Chooses which erasure set inside a pool owns the object.
Erasure set engine erasureObjects cmd/erasure.go, cmd/erasure-object.go Coordinates quorum, locking, metadata, encoding/decoding, and disk operations for one set.
Disk abstraction StorageAPI cmd/storage-interface.go Interface for one disk endpoint, hiding local vs remote access.
Local disk xlStorage cmd/xl-storage.go Concrete StorageAPI for a disk mounted on the current node.
Remote disk storageRESTClient cmd/storage-rest-client.go Concrete StorageAPI proxy for a disk attached to another node.
Erasure coding helper Erasure cmd/erasure-coding.go Codec used by erasureObjects to split object bytes into data/parity shards and reconstruct them on reads.

Component view — how the layers fit together

flowchart TB
    client[S3 client<br/>AWS SDK / mc / s3cmd]

    subgraph api["S3/API layer"]
        router["Gorilla mux routes<br/>registerAPIRouter<br/>cmd/api-router.go"]
        handlers["objectAPIHandlers<br/>cmd/*-handlers.go"]
    end

    subgraph objectLayer["Object abstraction"]
        objectAPI["ObjectLayer interface<br/>cmd/object-api-interface.go"]
    end

    subgraph cluster["Erasure-coded object layer"]
        pools["erasureServerPools<br/>choose pool"]
        sets["erasureSets<br/>choose set"]
        objects["erasureObjects<br/>quorum, locks, xl.meta, shards"]
        codec["Erasure<br/>Reed-Solomon codec"]
    end

    subgraph disks["Disk access layer"]
        storageAPI["StorageAPI interface"]
        localDisk["xlStorage<br/>local filesystem disk"]
        remoteDisk["storageRESTClient<br/>remote disk proxy"]
    end

    subgraph physical["Physical storage"]
        fs["Mounted disk paths<br/>bucket/object/xl.meta + shard files"]
        peer["Peer Buckit node<br/>storage REST/grid server"]
    end

    client -->|HTTP S3 request| router
    router -->|matched route| handlers
    handlers -->|bucket/object operation| objectAPI
    objectAPI --> pools
    pools -->|pool index| sets
    sets -->|hashed set index| objects
    objects -->|encode/decode| codec
    objects -->|disk operations| storageAPI
    storageAPI --> localDisk
    storageAPI --> remoteDisk
    localDisk --> fs
    remoteDisk --> peer
    peer --> fs

2. Class diagram — the storage layer hierarchy

classDiagram
    class ObjectLayer {
        <<interface>>
        +GetObjectNInfo(ctx, bucket, object, rs, h, opts) GetObjectReader
        +PutObject(ctx, bucket, object, data, opts) ObjectInfo
        +GetObjectInfo(...) ObjectInfo
        +DeleteObject(...) ObjectInfo
    }

    class erasureServerPools {
        -serverPools []*erasureSets
        +GetObjectNInfo(...)
        +PutObject(...)
        -getPoolIdx(...) int
    }

    class erasureSets {
        -sets []*erasureObjects
        -distributionAlgo string
        +GetObjectNInfo(...)
        +PutObject(...)
        -getHashedSetIndex(name) int
    }

    class erasureObjects {
        -getDisks() []StorageAPI
        -getLockers() []dsync.NetLocker
        -nsMutex *nsLockMap
        -setDriveCount int
        -defaultParityCount int
        +GetObjectNInfo(...)
        +PutObject(...)
        -getObjectFileInfo(...) FileInfo
        -putObject(...)
    }

    class StorageAPI {
        <<interface>>
        +ReadFile(...)
        +CreateFile(...)
        +RenameData(...)
        +ReadVersion(...) FileInfo
        +WriteMetadata(...)
    }

    class xlStorage {
        -drivePath string
        -formatData []byte
        +ReadFile(...)
        +CreateFile(...)
        +RenameData(...)
    }

    class storageRESTClient {
        -endpoint Endpoint
        -gridConn *grid.Connection
        +ReadFile(...)
        +CreateFile(...)
        +RenameData(...)
    }

    class Erasure {
        -dataBlocks int
        -parityBlocks int
        -blockSize int64
        +Encode(src, writers) int64
        +Decode(writers, readers, ...) error
    }

    note for ObjectLayer "Top-level object storage interface used by S3 handlers; exposes object operations without tying callers to erasure-coded storage internals."
    note for erasureServerPools "Cluster-level ObjectLayer implementation; owns all server pools and chooses which pool should serve a given object."
    note for erasureSets "Pool-level collection of erasure sets; hashes an object name to a stable set within the pool."
    note for erasureObjects "Set-level object engine; coordinates locks, quorum, metadata, and Reed-Solomon reads/writes across one set."
    note for StorageAPI "Disk abstraction used by erasureObjects; hides whether a disk operation is local or proxied to another node."
    note for xlStorage "Local-disk StorageAPI implementation; reads and writes bucket/object files, shard data, and xl.meta on the filesystem."
    note for storageRESTClient "Remote-disk StorageAPI implementation; forwards disk operations to another server over the storage REST/grid transport."
    note for Erasure "Reed-Solomon codec used by erasureObjects to split object bytes into data/parity shards and reconstruct them on reads."

    ObjectLayer <|-- erasureServerPools : implements
    erasureServerPools "1" *-- "N" erasureSets : pools
    erasureSets "1" *-- "N" erasureObjects : sets
    erasureObjects --> StorageAPI : uses N disks
    StorageAPI <|-- xlStorage : local
    StorageAPI <|-- storageRESTClient : remote
    erasureObjects --> Erasure : encode/decode

3. S3 API routing reference

Major S3 APIs and classes

The external S3 API is registered in cmd/api-router.go and implemented as methods on objectAPIHandlers. Those handlers then call the storage-facing ObjectLayer interface, whose erasure-coded implementation is erasureServerPools -> erasureSets -> erasureObjects.

S3 API Route shape Handler class / method File Storage class / method
ListBuckets GET / objectAPIHandlers.ListBucketsHandler cmd/bucket-handlers.go ObjectLayer.ListBuckets
CreateBucket PUT /{bucket} objectAPIHandlers.PutBucketHandler cmd/bucket-handlers.go ObjectLayer.MakeBucket
HeadBucket HEAD /{bucket} objectAPIHandlers.HeadBucketHandler cmd/bucket-handlers.go ObjectLayer.GetBucketInfo
DeleteBucket DELETE /{bucket} objectAPIHandlers.DeleteBucketHandler cmd/bucket-handlers.go ObjectLayer.DeleteBucket
GetBucketLocation GET /{bucket}?location objectAPIHandlers.GetBucketLocationHandler cmd/bucket-handlers.go Bucket metadata/config lookup
ListObjectsV1 GET /{bucket} objectAPIHandlers.ListObjectsV1Handler cmd/bucket-listobjects-handlers.go ObjectLayer.ListObjects
ListObjectsV2 GET /{bucket}?list-type=2 objectAPIHandlers.ListObjectsV2Handler cmd/bucket-listobjects-handlers.go ObjectLayer.ListObjectsV2
ListObjectVersions GET /{bucket}?versions objectAPIHandlers.ListObjectVersionsHandler cmd/bucket-listobjects-handlers.go ObjectLayer.ListObjectVersions
GetObject GET /{bucket}/{object} objectAPIHandlers.GetObjectHandler cmd/object-handlers.go ObjectLayer.GetObjectNInfo
HeadObject HEAD /{bucket}/{object} objectAPIHandlers.HeadObjectHandler cmd/object-handlers.go ObjectLayer.GetObjectInfo
PutObject PUT /{bucket}/{object} objectAPIHandlers.PutObjectHandler cmd/object-handlers.go ObjectLayer.PutObject
CopyObject PUT /{bucket}/{object} + x-amz-copy-source objectAPIHandlers.CopyObjectHandler cmd/object-handlers.go ObjectLayer.CopyObject
DeleteObject DELETE /{bucket}/{object} objectAPIHandlers.DeleteObjectHandler cmd/object-handlers.go ObjectLayer.DeleteObject
DeleteObjects POST /{bucket}?delete objectAPIHandlers.DeleteMultipleObjectsHandler cmd/bucket-handlers.go ObjectLayer.DeleteObjects
CreateMultipartUpload POST /{bucket}/{object}?uploads objectAPIHandlers.NewMultipartUploadHandler cmd/object-multipart-handlers.go ObjectLayer.NewMultipartUpload
UploadPart PUT /{bucket}/{object}?partNumber&uploadId objectAPIHandlers.PutObjectPartHandler cmd/object-multipart-handlers.go ObjectLayer.PutObjectPart
UploadPartCopy PUT /{bucket}/{object}?partNumber&uploadId + x-amz-copy-source objectAPIHandlers.CopyObjectPartHandler cmd/object-multipart-handlers.go ObjectLayer.CopyObjectPart
ListParts GET /{bucket}/{object}?uploadId objectAPIHandlers.ListObjectPartsHandler cmd/object-multipart-handlers.go ObjectLayer.ListObjectParts
CompleteMultipartUpload POST /{bucket}/{object}?uploadId objectAPIHandlers.CompleteMultipartUploadHandler cmd/object-multipart-handlers.go ObjectLayer.CompleteMultipartUpload
AbortMultipartUpload DELETE /{bucket}/{object}?uploadId objectAPIHandlers.AbortMultipartUploadHandler cmd/object-multipart-handlers.go ObjectLayer.AbortMultipartUpload
ListMultipartUploads GET /{bucket}?uploads objectAPIHandlers.ListMultipartUploadsHandler cmd/bucket-handlers.go ObjectLayer.ListMultipartUploads
GetObjectTagging GET /{bucket}/{object}?tagging objectAPIHandlers.GetObjectTaggingHandler cmd/object-handlers.go ObjectLayer.GetObjectTags
PutObjectTagging PUT /{bucket}/{object}?tagging objectAPIHandlers.PutObjectTaggingHandler cmd/object-handlers.go ObjectLayer.PutObjectTags
DeleteObjectTagging DELETE /{bucket}/{object}?tagging objectAPIHandlers.DeleteObjectTaggingHandler cmd/object-handlers.go ObjectLayer.DeleteObjectTags
GetBucketPolicy GET /{bucket}?policy objectAPIHandlers.GetBucketPolicyHandler cmd/bucket-policy-handlers.go Bucket policy subsystem
PutBucketPolicy PUT /{bucket}?policy objectAPIHandlers.PutBucketPolicyHandler cmd/bucket-policy-handlers.go Bucket policy subsystem
DeleteBucketPolicy DELETE /{bucket}?policy objectAPIHandlers.DeleteBucketPolicyHandler cmd/bucket-policy-handlers.go Bucket policy subsystem
GetBucketLifecycle GET /{bucket}?lifecycle objectAPIHandlers.GetBucketLifecycleHandler cmd/bucket-lifecycle-handlers.go Bucket lifecycle subsystem
PutBucketLifecycle PUT /{bucket}?lifecycle objectAPIHandlers.PutBucketLifecycleHandler cmd/bucket-lifecycle-handlers.go Bucket lifecycle subsystem
DeleteBucketLifecycle DELETE /{bucket}?lifecycle objectAPIHandlers.DeleteBucketLifecycleHandler cmd/bucket-lifecycle-handlers.go Bucket lifecycle subsystem
GetBucketVersioning GET /{bucket}?versioning objectAPIHandlers.GetBucketVersioningHandler cmd/bucket-versioning-handler.go Bucket versioning subsystem
PutBucketVersioning PUT /{bucket}?versioning objectAPIHandlers.PutBucketVersioningHandler cmd/bucket-versioning-handler.go Bucket versioning subsystem
SelectObjectContent POST /{bucket}/{object}?select&select-type=2 objectAPIHandlers.SelectObjectContentHandler cmd/object-handlers.go ObjectLayer.GetObjectNInfo plus S3 Select engine

4. The xl.meta on-disk format

xl.meta is the per-object metadata file that lives next to the object's shard data on every disk in an erasure set. Every GET reads it (to discover layout and pick a version); every PUT writes a new one (atomically, via RenameData); every DELETE either rewrites it with a new tombstone version or strips a version out of it. Understanding its layout demystifies whole system.

Authoritative source: cmd/xl-storage-format-v2.go, cmd/xl-storage-meta-inline.go.

Note on MessagePack. Throughout this section "msgpack" refers to MessagePack — a language-neutral binary serialisation format ("JSON but binary"). It is not Go-specific; there are mature libraries in C, C++, Rust, Python, JS, Java, Ruby, etc. Each value starts with a 1-byte type tag (e.g. 0xc6 = bin32, 0xce = uint32, 0x82 = a 2-entry map), so a reader decodes without needing a schema. Buckit uses the tinylib/msgp Go library, which generates MarshalMsg/UnmarshalMsg methods at build time — that is why files ending in _gen.go exist in the repo (see CLAUDE.md → Code Generation). Picking msgpack over JSON gives smaller files and faster parsing; picking it over Protobuf avoids a separate schema-compilation step. Practical consequence: scripts in Python, Rust, etc. can decode the MessagePack parts using off-the-shelf libraries, but they must first handle Buckit's xl.meta file envelope described below.

4.1 On-disk byte layout

xl.meta is not just one MessagePack value from byte 0. The file starts with Buckit's own 8-byte header:

58 4c 32 20  01 00 03 00
└── "XL2 "   └─ major=1, minor=3

Only after that header does the MessagePack framing begin. The MessagePack part starts at the 0xc6 bin32 wrapper around the metadata payload.

┌──────────────────────────────────────────────────────────────────┐
│ 'X' 'L' '2' ' '         (4 bytes)  magic — xlHeader              │
│ major (uint16 LE)       (2 bytes)                                │ ← framing
│ minor (uint16 LE)       (2 bytes)  current version is 1.3        │
├──────────────────────────────────────────────────────────────────┤
│ 0xc6 + uint32 size      (5 bytes)  msgpack bin32 wrapper around  │
│                                    the metadata payload          │
├──────────────────────────────────────────────────────────────────┤
│ headerVersion (uint8)                                            │
│ metaVersion   (uint8)                                            │
│ versionsCount (int)                                              │
│                                                                  │ ← metadata
│ For each version (newest first):                                 │   payload
│   ┌── xlMetaV2VersionHeader (msgpack)  — small, indexable        │
│   └── xlMetaV2Version body (msgpack)   — full per-version blob   │
├──────────────────────────────────────────────────────────────────┤
│ 0xce + uint32 BE        (5 bytes)  trailer CRC                   │
│                                    xxhash64(metadata) → low32    │ ← integrity
├──────────────────────────────────────────────────────────────────┤
│ inline data (optional, variable)   small object bodies, keyed    │ ← inline
│                                    by VersionID (see §4.5)       │   data
└──────────────────────────────────────────────────────────────────┘

Key design choices:

  • Magic + version prefix lets the loader detect legacy xl.json (v1) files and dispatch to a converter.
  • Each version stored twice — once as a slim header, once as the full body. Listing & quorum-merge code only needs the header, so it can skip-scan thousands of versions without unmarshalling the heavy parts.
  • Trailer CRC uses xxhash truncated to 32 bits and covers only the metadata, not the inline data trailer (inline data has its own per-block bitrot hashes).
  • Append-only structure within a single write — but the file itself is replaced wholesale by RenameData, never edited in place.

What "msgpack bin32 wrapper" means. MessagePack — the binary serialisation format Buckit uses for the metadata payload — has three type tags for raw binary blobs depending on length: bin8 (≤255 B, tag 0xc4), bin16 (≤64 KiB, tag 0xc5) and bin32 (≤4 GiB, tag 0xc6). Each tag is followed by the length, then the bytes. xl.meta always uses the bin32 form for the whole metadata payload:

c6  00 00 02 b4   <… 692 bytes of metadata: headerVersion, metaVersion, versions[] …>
└┬┘ └────┬─────┘
 │       └── uint32 big-endian length = 0x2b4 = 692
 └────────── 0xc6 = msgpack "bin32" type marker

The wrapper exists because the file is not a single msgpack value — it is [XL2 magic][version][bin32-of-metadata][trailer CRC][optional inline data]. Wrapping the metadata as bin32 gives loaders two useful properties:

  1. A standard msgpack reader can scan the file in one pass. It sees the bin32 tag, jumps the declared length, and lands cleanly on the trailer CRC. No knowledge of the inner version-list layout is required.
  2. The length is known up front, so loaders can read the metadata into one slab (or mmap it) without parsing first — that is what the listing fast-path uses to touch only Headers without unmarshalling the heavy bodies.

bin32 is used (rather than bin16) because a heavily versioned object's metadata can exceed 64 KiB in principle; the variants only differ by 2 bytes of overhead, so there is no reason to ever pick the smaller one. The trailer CRC at the end uses the analogous 0xce tag — msgpack's uint32 marker — for the same kind of self-describing framing.

References: xlHeader and version constants cmd/xl-storage-format-v2.go:44, 69-70; framing in xlMetaV2.AppendTo :1194-1230; CRC validation :877-882.

4.2 Top-level struct

// cmd/xl-storage-format-v2.go:901
type xlMetaV2 struct {
    versions []xlMetaV2ShallowVersion // sorted by ModTime DESC; index 0 = latest
    data     xlMetaInlineData         // optional inline bodies (see §4.5)
    metaV    uint8                    // metadata schema version
}

It is essentially a journal of versions, newest first. Every PUT prepends; every DELETE either prepends a tombstone or removes one entry. Versioning-disabled buckets hold exactly one version.

4.3 xlMetaV2VersionHeader — the slim header that makes listing fast

What problem are we solving?

A heavily versioned object can have hundreds of versions in its chain. Each version's full body — parts list, erasure layout, two metadata maps — runs from ~100 bytes up to several KiB.

But many hot operations don't need any of those details. In particular:

  • Listing (ListObjectsV2, ListObjectVersions) only needs the version's UUID, timestamp, and whether it's a delete marker.
  • Quorum-merging — when a GET reads xl.meta from N disks and has to decide which version is "the latest that a majority of disks agree on" — needs a way to compare the same version across disks without unmarshalling and diffing the full bodies.
  • Picking the latest live version (skip delete markers, find the newest object) only needs Type and ModTime.

If those operations had to unmarshal every full version body, listing a bucket with millions of versioned objects would crawl, and quorum-merge would be unacceptably slow on large sets.

The solution

Store every version twice in xl.meta: once as a slim, fixed-shape header containing only the indexable fields, then immediately followed by the full body. Code that only needs the cheap fields skim-scans the headers. The expensive body is unmarshalled lazily — typically only for the one version actually selected.

// cmd/xl-storage-format-v2.go:249
type xlMetaV2VersionHeader struct {
    VersionID [16]byte    // UUID of the version
    ModTime   int64       // unix nanos
    Signature [4]byte     // deterministic content digest — for cross-disk merge
    Type      VersionType // 1 = Object, 2 = DeleteMarker, 3 = Legacy(v1)
    Flags     xlFlags     // FreeVersion | UsesDataDir | InlineData
    EcN, EcM  uint8       // erasure (parity, data) — 0/0 for legacy/delete-marker
}

The clever field is Signature — a 4-byte deterministic digest of the version's metadata and erasure parameters. Two disks that hold the same content for the same version will produce the same Signature. So when quorum-merge code reads xl.meta from N disks and asks "do these copies of version X actually represent the same content?", it compares 4-byte signatures instead of marshalled blobs. See mergeXLV2Versions(...) for the merge logic.

4.4 The version body — xlMetaV2Version and its three shapes

What problem are we solving?

A "version" in the chain is not always the same kind of thing. Three distinct shapes need to coexist in one chain:

  • A live object version — has shards on disk, an ETag, content-type, parts list, erasure parameters, two metadata maps. The rich case.
  • A delete-marker tombstone — has only an ID, a timestamp, and a little replication bookkeeping. No data, no shards, no parts.
  • A legacy v1 object — pre-v2 format, kept around so already-stored objects don't need an offline migration. Read-only — never produced by new writes, only converted on access.

The format needs a single "version" type that can hold any one of these, so the chain can be a uniform array.

The solution

A wrapper struct with one optional pointer for each shape; the Type field says which one is filled.

// cmd/xl-storage-format-v2.go:181
type xlMetaV2Version struct {
    Type             VersionType
    ObjectV1         *xlMetaV1Object       // set when Type == LegacyV1
    ObjectV2         *xlMetaV2Object       // set when Type == Object   (the common case)
    DeleteMarker     *xlMetaV2DeleteMarker // set when Type == DeleteMarker
    WrittenByVersion uint64                // Buckit build that wrote this version (forensics)
}

Type here is the same value as the Type in the slim header (§4.3) — they always agree, which is what lets the slim header decide which body shape to expect without reading the body.

The next two subsections list the fields of the two interesting shapes (xlMetaV2Object and xlMetaV2DeleteMarker); the legacy xlMetaV1Object is omitted because new code should never produce it.

xlMetaV2Object — a live object version (cmd/xl-storage-format-v2.go:156)

Field Meaning
VersionID [16]byte UUID of this version (matches the header)
DataDir [16]byte UUID of the directory under the object that holds this version's shard data — see §4.6
ErasureAlgorithm ReedSolomon (1)
ErasureM, ErasureN data / parity block counts (e.g. 12 / 4 for EC:4 on a 16-drive set)
ErasureBlockSize bytes per block before encoding (default 1 MiB)
ErasureIndex which shard index this disk holds, 0 … M+N-1
ErasureDist []uint8 the permutation mapping shard indices to disks (cluster-wide)
BitrotChecksumAlgo HighwayHash (1)
PartNumbers []int
PartSizes []int64
PartETags []string
PartActualSizes []int64
PartIndices [][]byte
per-part metadata for multipart uploads; single-part PUT has length-1 slices
Size int64, ModTime int64 object size before encoding, modification time
MetaSys map[string][]byte system metadata, see §4.7
MetaUser map[string]string user-provided x-amz-meta-* headers

xlMetaV2DeleteMarker — the tombstone case (cmd/xl-storage-format-v2.go:144):

Field Meaning
VersionID [16]byte new UUID assigned when the marker was created
ModTime int64 when the delete happened
MetaSys map[string][]byte replication-state, free-version tracking, governance bypass info

A delete marker has no DataDir, no erasure info, no parts — that is precisely why "delete on a versioned bucket touches no shards", as called out in §7.

4.5 Inline data — when small objects skip the part file

What problem are we solving?

The normal storage path for an object is two files per disk: a small xl.meta plus a separate part.1 containing the shard bytes. That's fine for a 100 MiB object — the metadata file is dwarfed by the data file.

For tiny objects (a 200-byte JSON config, a 1 KiB thumbnail, an empty marker file), the same path is wasteful:

  • Two disk operations per disk — open + read xl.meta, then open + read part.1 — when the actual payload is smaller than a single disk sector. The fixed cost dominates.
  • Filesystem clutter. A bucket with millions of small objects ends up with millions of tiny part.1 files. Inode-heavy operations (ls, the background scanner, fsck) get expensive, and small-file allocations waste FS block space.

The solution

Below a small-size threshold, store the body bytes inside xl.meta itself, as a trailer right after the metadata payload (see the byte-layout diagram in §4.1). No separate part.1 is created.

// cmd/xl-storage-meta-inline.go:29
type xlMetaInlineData []byte
// On the wire:  [version byte] [msgpack map keyed by VersionID → body bytes]

The map is keyed by VersionID so an xl.meta with multiple versions can carry one inline body per version. The version's Header.Flags field carries an InlineData bit that tells readers "this version's body is in the inline trailer, don't go looking for part.1" — and a matching marker x-minio-internal-inline-data is set in MetaSys for the same purpose.

Trade-offs

  • Pro: A GET of a tiny object is one disk read, not two. Latency drops sharply for small-object-heavy workloads.
  • Pro: No part.1 clutter; the filesystem holds only xl.meta. Scanner and listing paths get faster.
  • Con: xl.meta grows with the body, so any code path that reads xl.meta (listing, quorum-merge) now reads more bytes per object. That's why the threshold is small — beyond it, the listing cost outweighs the read-saving.

References: xl-storage-format-v2.go:533, 1698-1700.

4.6 DataDir — finding a version's shard files on disk

What problem are we solving?

A single object (e.g. bucket/photo.jpg) can have many versions over its lifetime. Each version's bytes are split into shards that get written as files on disk. Where on disk should those shard files live?

A naive layout — always at bucket/photo.jpg/part.1 — breaks immediately:

  • An overwrite would have to overwrite part.1, racing any concurrent reader who is still in the middle of streaming it.
  • Versioning becomes impossible — there's only one filename per object, no place to keep older versions.

Numbering by version (part.1.v1, part.1.v2, …) doesn't fix it either: parallel PUTs would race over "which number is next," and crashed PUTs would leave files that look real but aren't referenced.

The solution

Each version gets its own subdirectory, named by a fresh UUID generated at PUT time. That UUID is stored in the version's xl.meta entry as the DataDir field. The version's shard files (part.1, part.2, … for multipart) live underneath. Different versions never share a directory; their shards never collide.

disk1/bucket/photo.jpg/                       ← the object's directory on this disk
├── a192c1d5-9bd5-41fd-9a90-ab10e165398d/    ← version A's DataDir (UUID)
│   └── part.1                                ← version A's shard for this disk
├── c06e0436-f813-447e-ae5e-f2564df9dfd4/    ← version B's DataDir (a later overwrite)
│   └── part.1                                ← version B's shard
├── legacy/                                   ← pre-v2 layout, if this object was migrated
│   └── part.1
└── xl.meta                                   ← chain of versions A, B, legacy (newest first)

xl.meta is the index that ties everything together: each version entry inside it carries the DataDir UUID that points to the matching subdirectory.

Across the erasure set, each disk has the same bucket/object path and the same shard filename (part.1 for a single-part object), but each part.1 file contains that disk's shard:

disk1/
└── photos/cat.jpg/
    ├── xl.meta
    └── a192c1d5-9bd5-41fd-9a90-ab10e165398d/
        └── part.1        ← shard 1

disk2/
└── photos/cat.jpg/
    ├── xl.meta
    └── a192c1d5-9bd5-41fd-9a90-ab10e165398d/
        └── part.1        ← shard 2

...

disk16/
└── photos/cat.jpg/
    ├── xl.meta
    └── a192c1d5-9bd5-41fd-9a90-ab10e165398d/
        └── part.1        ← shard 16

For multipart objects, each disk stores one shard file per uploaded part: part.1, part.2, part.3, and so on, under the same version DataDir.

Why this layout works well

  • Overwrites don't touch existing data. A new PUT writes its shards into a fresh UUID subdirectory while the old version's directory is untouched. Only the final atomic xl.meta replacement makes the new version visible. Any in-flight GET holding a read lock on the old version keeps reading the old shards correctly (see §5 step 5).
  • Crashed PUTs are easy to clean up. A failed PUT leaves an orphan UUID directory that no xl.meta references. The background scanner walks each object's directory, lists the UUID subdirectories on disk, cross-checks them against xl.meta, and removes anything unreferenced.
  • DELETE is cheap and reversible. A permanent delete just removes the version's entry from xl.meta and renames the matching DataDir into a .trash-style location for async reclamation. A versioned-bucket delete doesn't touch the DataDir at all — it only prepends a tombstone, so the original version's directory remains reachable via ?versionId=.

Reference: xl-storage-format-v2.go:158, 682; on-disk layout doc-comment :90-102.

4.7 Two metadata maps: MetaSys and MetaUser

What problem are we solving?

An object has two distinct kinds of metadata:

  • Client metadata — what the user sent on PUT (Content-Type, x-amz-meta-*). Has to round-trip on GET/HEAD.
  • Server bookkeeping — facts the server tracks per version: replication status, lifecycle tier, WORM retention end date, inline-data marker, supplied CRC32C.

Mixing them in one map would be unsafe: a GET would leak internal state to clients, and a client could set internal state by sending headers. Both kinds also have to travel with the version they describe ("replicated" means nothing if not pinned to a specific version), so both live inside xl.meta rather than a sidecar file.

The solution

Every version body carries two separate maps:

  • MetaUser map[string]string — the client's metadata.
    • Filled from: HTTP headers on PUT.
    • Returned in: HTTP headers on GET / HEAD.
    • Examples: content-type, content-encoding, etag, x-amz-meta-author.
  • MetaSys map[string][]byte — the server's bookkeeping.
    • Filled by: the server, during PUT and later events (replication finishing, lifecycle transitions, retention being set, etc.).
    • Returned in: nothing — never exposed to the client.
    • Convention: every key starts with x-minio-internal-, so the boundary code can trivially filter them out anywhere.
    • Examples: x-minio-internal-replication-status, x-minio-internal-transition-tier, x-minio-internal-inline-data, x-minio-internal-objectlock-retainuntildate, x-minio-internal-crc.

The maps even have different Go types — map[string]string for MetaUser (because client headers are always strings) and map[string][]byte for MetaSys (because server bookkeeping sometimes needs raw bytes, e.g. binary timestamps).

Reference table

MetaUser MetaSys
Purpose round-trip the client's metadata server's per-version bookkeeping
Go type map[string]string map[string][]byte
Who writes it the client (PUT headers) the server (PUT, replication, lifecycle, …)
Visible to client? yes — returned on HEAD / GET no — internal only
Key convention arbitrary always prefixed x-minio-internal-…
Typical entries content-type, etag, x-amz-meta-* replication status, transition tier, inline-data marker, retention end date, CRC32C

4.8 BitrotChecksumAlgo — protecting the data from silent disk corruption

What problem are we solving?

Erasure coding only kicks in when a disk fails loudly — returns an I/O error, goes offline, refuses a read. ReedSolomon then rebuilds the missing shard from parity.

What it doesn't catch is when a disk silently lies — bits flip inside a shard (media wear, firmware bug, controller fault, cosmic ray) and the disk returns the corrupted bytes with no error reported. The OS thinks the read succeeded; ReedSolomon thinks the shard is healthy; the bad bytes flow into the decode and into the response. This is bitrot, and erasure coding alone cannot detect it — it only fixes things it knows are missing.

The defence: hash the data, verify on read

To catch a lying disk, you need an independent witness to what the bytes should be. The standard answer is a hash:

  1. When writing a shard, compute a hash of the bytes and store it.
  2. When reading the shard back, recompute the hash from what came off the disk.
  3. If the two hashes don't match, the disk lied — treat the shard as failed and let ReedSolomon reconstruct it from parity, just as if the disk had gone offline.

Buckit does exactly this, with one extra refinement: instead of one hash per whole shard, it computes one hash per fixed-size block inside the shard (default block size = 1 MiB). That way, a single bad sector only invalidates the one block it lives in, not the entire shard — ReedSolomon then has much less to reconstruct.

The hash function is HighwayHash, chosen because it's very fast on modern CPUs (uses SIMD instructions), so verification keeps up with disk read throughput.

Where exactly do the hashes go?

This is the part that surprises every reader. The natural assumption is "they're in xl.meta." They are not.

xl.meta records which hash algorithm is used (the BitrotChecksumAlgo field). It does not store the hashes themselves.

The hashes live inside each shard file, written immediately before the block they cover. So a shard file (part.1 under the version's DataDir/) looks like:

part.1 (one shard's bytes on one disk)
┌────────┬─────────────┬────────┬─────────────┬─────┬────────┬─────────────┐
│ hash_0 │   block_0   │ hash_1 │   block_1   │ ... │ hash_k │   block_k   │
└────────┴─────────────┴────────┴─────────────┴─────┴────────┴─────────────┘
  32 B       1 MiB        32 B      1 MiB              32 B      ≤1 MiB
                                                                 (last may be short)

Reading the shard:

  1. The reader (BitrotVerifier) reads hash_0, then reads block_0.
  2. It recomputes HighwayHash over the block and compares to hash_0. If they match, hand the block to the decoder; if not, mark the block bad.
  3. Repeat for the next block. The reader is purely sequential — no extra disk seeks.

If any block is marked bad, ReedSolomon reconstructs just that block using the same-position blocks on the other disks in the set.

Why interleave hashes with data, instead of grouping them in xl.meta?

Four reasons, all about resilience and performance:

  • Localised failures stay localised. Real-world disk corruption typically affects a single sector or page (a few KiB), not the whole drive. With this layout, a bad sector flips at most one block_i and possibly its hash_i — the verifier catches it, ReedSolomon fixes it, and the other blocks are unaffected. If hashes were grouped at the front of the file, a single bad sector in the hash region could invalidate every block behind it.
  • Reads can stream. Verification proceeds in lockstep with the disk read — read a hash, read its block, verify, hand off. No need to seek to a separate hash region first, which would double the random-I/O cost on every read.
  • Each block is independent. Losing block 5 to corruption doesn't affect the verifier's ability to check blocks 04 or 6k. There's no shared structure that a single bit-flip can compromise.
  • xl.meta stays slim. A 1 GiB shard at 1 MiB blocks needs ~1024 hashes. With 16 disks per set and many object versions, putting all those hashes in xl.meta would balloon it from a few KiB to many KiB per object. Listing code reads xl.meta constantly; keeping it small keeps listing fast.

Don't confuse this with the AWS object checksum

There's a second, completely separate kind of hash you'll see in this codebase. The AWS S3 API has optional headers like x-amz-checksum-crc32c (and -sha256, -sha1, -crc32) for end-to-end client integrity checking — the client computes a hash of the whole object's bytes, sends it on PUT, and gets it back on GET. The server stores it as MetaSys["x-minio-internal-crc"].

That object-level checksum is a client-facing feature. The per-block bitrot hashes described above are a server-internal defence against disk lies. They share nothing — different scope, different algorithm, different storage location, different audience.

Per-block bitrot hash AWS object checksum
Whose problem does it solve? the server's — silent disk corruption the client's — end-to-end integrity
Granularity one hash per 1 MiB block of one shard one hash for the whole object
Algorithm HighwayHash (fixed) CRC32C / CRC32 / SHA256 / SHA1 (client picks)
Where it lives interleaved inside each shard file MetaSys["x-minio-internal-crc"] in xl.meta
Visible to the client? no yes — returned in response headers
Computed when? every write, verified every read once at PUT, returned on GET

4.9 An annotated sample

The shipping debug tool at docs/debugging/xl-meta/main.go decodes a binary xl.meta to JSON. Below is what it would print for an object that has been written once, then deleted on a versioned bucket — so the version chain has two entries: a delete-marker (newest, index 0) and the original object (index 1).

Raw bytes — first ~32 bytes of the file

00000000  58 4c 32 20  01 00 03 00   ←  "XL2 " magic, then version 1.3 (LE uint16s)
00000008  c6 00 00 02 b4              ←  msgpack bin32 wrapper, payload = 0x000002b4 bytes
0000000d  03 02 02                    ←  headerVersion=3, metaVersion=2, versionsCount=2
00000010  …  msgpack version_header_0  …  ←  delete-marker header (slim)
   …      …  msgpack version_body_0    …  ←  delete-marker body
   …      …  msgpack version_header_1  …  ←  object header (slim)
   …      …  msgpack version_body_1    …  ←  object body
   …      ce a9 4f 12 c8              ←  trailer CRC: 0xce + xxhash low32 = 0xa94f12c8
   …      …  inline data (optional)    …  ←  absent for this object (size > inline cutoff)

The layout is exactly what was sketched in §4.1 — magic, version, msgpack-wrapped metadata, trailer CRC, optional inline data.

Decoded view (what go run docs/debugging/xl-meta prints)

{
  "Versions": [
    /* ─── Version 0  the delete marker (newest, returned first by listing) ──── */
    {
      "Header": {
        "Type":      2,                                  // 1=Object, 2=DeleteMarker, 3=Legacy(v1)
        "VersionID": "8d1a3b67e1bd4a1f9c2a4e5f06a7b8c9d", // UUID of THIS delete-marker version
        "ModTime":   "2026-04-25T14:31:08.412933000Z",   // wall-clock of the DELETE
        "Signature": "3f8a1b04",                          // 4-byte deterministic content digest
        "Flags":     0,                                   // (no FreeVersion / UsesDataDir / InlineData)
        "EcN":       0,                                   // delete markers carry no erasure info
        "EcM":       0
      },
      "Metadata": {
        "Type": 2,                                        // mirrors header.Type
        "DeleteMarker": {
          "VersionID": "8d1a3b67e1bd4a1f9c2a4e5f06a7b8c9d",
          "ModTime":   "2026-04-25T14:31:08.412933000Z",
          "MetaSys": {
            "x-minio-internal-replication-status": "PENDING",   // bucket has replication wired up
            "x-minio-internal-replication-timestamp": "..."
          }
        },
        "WrittenByVersion": 17440000000000               // Buckit build that wrote this entry
      }
    },

    /* ─── Version 1  the original object (older, but data shards still on disk) ──── */
    {
      "Header": {
        "Type":      1,                                          // ObjectV2
        "VersionID": "11ee7a14-c9a2-7c70-9b41-5f0f5cdb2a9f",     // UUID assigned at PUT time
        "ModTime":   "2026-04-25T14:30:51.018220000Z",
        "Signature": "1c40b9d2",
        "Flags":     2,                                          // UsesDataDir
        "EcN":       4,                                          //   parity = 4
        "EcM":       12                                          //   data   = 12  (EC:4 on a 16-drive set)
      },
      "Metadata": {
        "Type": 1,
        "V2Obj": {
          "VersionID":          "11ee7a14-c9a2-7c70-9b41-5f0f5cdb2a9f",
          "DataDir":            "a192c1d5-9bd5-41fd-9a90-ab10e165398d", // dir under .../object/ holding shards
          "ErasureAlgorithm":   1,                                       // 1 = Reed-Solomon
          "ErasureM":           12,                                      // # data shards
          "ErasureN":           4,                                       // # parity shards
          "ErasureBlockSize":   1048576,                                 // 1 MiB stripe size
          "ErasureIndex":       7,                                       // THIS disk holds shard #7 of (M+N)
          "ErasureDist":        [11,3,9,1,14,6,8,7,12,4,15,2,10,5,13,16],// shard→disk permutation across the set
          "BitrotChecksumAlgo": 1,                                       // 1 = HighwayHash (256-bit)
          "PartNumbers":        [1],                                     // single-part PUT
          "PartSizes":          [83886080],                              // ~80 MiB encoded shard size for this part
          "PartETags":          [""],                                    // multipart-only; "" for single-part
          "PartActualSizes":    [83886080],                              // logical size pre-erasure
          "PartIndices":        [],
          "Size":               83886080,                                // object size before erasure
          "ModTime":            "2026-04-25T14:30:51.018220000Z",

          "MetaSys": {                                                   // server-internal, NOT returned to S3 clients
            "x-minio-internal-actual-size":         "83886080",
            "x-minio-internal-replication-status":  "COMPLETED",
            "x-minio-internal-data-mtime":          "..."
          },
          "MetaUser": {                                                  // client-supplied, returned via HEAD/GET
            "content-type":          "application/octet-stream",
            "etag":                  "5d41402abc4b2a76b9719d911017c592",
            "x-amz-meta-author":     "alice@example.com",
            "x-amz-meta-build-id":   "ci-7421"
          }
        },
        "WrittenByVersion": 17440000000000
      }
    }
  ]
}

Field-by-field reference

Top-level wrapper (Versions[])

Field Where Meaning
Versions top of file Newest-first array. Listing skim-reads only the Header of each entry; full Metadata is unmarshalled lazily.

HeaderxlMetaV2VersionHeader (xl-storage-format-v2.go:249)

Field Type Meaning
Type 1/2/3 1 = Object, 2 = DeleteMarker, 3 = LegacyV1 (xl.json carry-over).
VersionID UUID Same UUID as in Metadata.VersionID; duplicated in the slim header for fast filtering.
ModTime nanos Wall-clock at which this version was created. Used to sort the chain.
Signature 4 bytes Deterministic XOR of marshalled metadata + erasure params. Lets quorum-merge across N disks compare versions without unmarshalling the body.
Flags bitset 1=FreeVersion (tier-only stub), 2=UsesDataDir (has shard files), 4=InlineData (body lives in this xl.meta).
EcN, EcM uint8 Parity / data shard counts. 0/0 for delete markers and legacy versions.

Metadata.V2ObjxlMetaV2Object (xl-storage-format-v2.go:156)

Field Meaning
VersionID UUID matching the header.
DataDir UUID of the directory under bucket/object/ on this disk that holds this version's shard files (part.1, part.2, …). New per PUT, never reused.
ErasureAlgorithm Always 1 = ReedSolomon today. Future-proofing for alt algos.
ErasureM / ErasureN Data and parity shard counts; sum = drives in the set. Per-version, so EC:4 and EC:8 objects can coexist in the same set.
ErasureBlockSize Stripe size before encoding. Default 1 MiB. The encoder reads this many bytes × M per stripe and emits one shard per drive.
ErasureIndex Which shard of the set this disk holds (0-based). On a 16-drive set, every disk's xl.meta is identical except for this field and the per-shard bitrot checksums.
ErasureDist Permutation array mapping shard index → disk index across the whole set. Determines which physical disk holds shard 0, shard 1, etc. Stable for a version's lifetime.
BitrotChecksumAlgo Algorithm used by per-block hashes that live next to the data inside the shard file, not in xl.meta. Today 1 = HighwayHash.
PartNumbers, PartSizes, PartETags, PartActualSizes, PartIndices Parallel slices describing parts of a multipart upload. Length 1 for single-part PUT. PartActualSizes[i] is the logical (pre-erasure, pre-compress) size; PartSizes[i] is what the part takes up after encoding.
Size Logical object size in bytes. Equal to sum of PartActualSizes for unencrypted/uncompressed objects.
ModTime Same as Header.ModTime (duplicated for legacy reasons).
MetaSys map[string][]byte. Server-only; clients never see these keys. Examples below.
MetaUser map[string]string. Returned verbatim on HEAD / GET. Houses content-type, etag, and any x-amz-meta-* header the client sent.

Metadata.DeleteMarkerxlMetaV2DeleteMarker (xl-storage-format-v2.go:144)

Field Meaning
VersionID New UUID generated when the DELETE was processed.
ModTime When the DELETE happened.
MetaSys Replication-state and free-version (tier) accounting only. No data, no parts.

Common MetaSys keys you will see

Key Set by Purpose
x-minio-internal-actual-size PUT Pre-encryption / pre-compression size.
x-minio-internal-inline-data PUT (small obj) Marker that the body is inline (see §4.5); pairs with Header.Flags=InlineData.
x-minio-internal-replication-status replication subsystem PENDING / COMPLETED / FAILED / REPLICA.
x-minio-internal-replication-timestamp replication subsystem When status last transitioned.
x-minio-internal-transition-tier lifecycle ILM Name of the remote tier this object has been transitioned to.
x-minio-internal-transition-status lifecycle ILM pending / complete.
x-minio-internal-crc PUT (if client sent x-amz-checksum-*) Whole-object checksum (CRC32C / SHA256 / etc).
x-minio-internal-objectlock-retainuntildate object-lock Retention end date for WORM/governance mode.
x-minio-internal-objectlock-legalhold object-lock ON / OFF.

Common MetaUser keys

Key Origin Notes
content-type client Content-Type header Returned in GET / HEAD.
content-encoding client Same.
etag server (or client for SSE-C) Quoted MD5 for normal objects; opaque for multipart.
x-amz-meta-* client Arbitrary user keys; lower-cased on storage.
x-amz-server-side-encryption server (set during PUT if SSE applied) AES256, aws:kms.

Reading this on a real disk

If you have access to a Buckit drive, you can decode any object's xl.meta yourself:

go run ./docs/debugging/xl-meta /path/to/disk/bucket/object/xl.meta

The tool's output matches the JSON shape above (it is the source of truth for these field names). Pass it a glob, and it pretty-prints every match.


4.10 How the format ties back to GET / PUT / DELETE

Step in flow diagrams What it does to xl.meta
GET §5 step 9 (getObjectFileInfo) Fan out xl.meta reads to every disk, merge by VersionHeader.Signature, pick the latest non-deleted version that satisfies object read quorum
PUT §6 step 15 (per-disk xl.meta finalised) Build a fresh xlMetaV2Object per disk with that disk's ErasureIndex and bitrot algo
PUT §6 step 18 (RenameData) Atomically replace xl.meta and move the new DataDir into place on quorum disks
DELETE §7 tombstone path (steps 1316) Prepend an xlMetaV2DeleteMarker to versions, leave all DataDirs untouched
DELETE §7 permanent path (steps 1719) Remove the targeted xlMetaV2Object from versions, async-reclaim its DataDir

5. Sequence diagram — GET /bucket/object

At a glance — what a GET does, in plain English

The bytes of the object do not exist as a single file anywhere — they are re-assembled per request:

  1. Find the disks. Hash the object name to pick the 16-disk set that owns it.
  2. Read xl.meta from every disk in parallel and pick the latest version that satisfies object read quorum. This tells the server where the shards live and how they were encoded.
  3. Read the shards in parallel and ReedSolomon-decode them. Any M of the M+N shards is enough; missing or bitrot-corrupted ones are reconstructed from parity.
  4. Stream the reconstructed bytes straight into the HTTP response — they never land fully in server memory.

Plumbing not in the list: signature/permission check, distributed read lock, response-body cleanup callbacks.

The core idea

A GET locates the object's latest live version, asks every disk in its set to ship its shard, and reconstructs the original bytes the moment enough shards have arrived. The object never exists as a single file anywhere — it is re-assembled per request from M of the M+N shards. That means a GET succeeds even if up to N disks are offline, slow, or returning bitrot-corrupted bytes; ReedSolomon math fills in whatever is missing or wrong.

On a non-versioned bucket, an object has exactly one version, and GET /bucket/object reads that one. Simple.

On a versioned bucket, the object's version chain may have many entries. A plain GET reads versions[0] (newest) — but if that entry is a DeleteMarker, the response is 404 and the bytes underneath are not served. To reach a specific historical version, the client passes ?versionId=<uuid>; Buckit walks the chain to find it.

What the client experiences:

Client request Result
GET /bucket/object Newest non-deleted version → 200 OK + bytes; if the newest version is a DeleteMarker → 404.
GET /bucket/object?versionId=<id> That specific version, even if a newer DeleteMarker exists. 200 if the version is an object; 405 + x-amz-delete-marker: true if the version is itself a delete marker.
GET /bucket/object with Range: bytes=N-M 206 Partial Content + just the requested byte range. The storage layer reads only the affected stripes.
GET /bucket/object with If-None-Match: <etag> 304 Not Modified if ETag matches. ETag is computed at PUT time and stored in xl.meta.
Concurrent GETs on the same object All proceed in parallel — they share a read lock (see §5 step 5).
GET racing a PUT to the same key Reader holds the read lock while the writer waits for an exclusive write lock; the reader sees the old version coherently, then the writer commits, and subsequent readers see the new one. No torn reads.
GET when N of the M+N disks are offline Still 200 OK. ReedSolomon reconstructs the missing shards from the surviving ones (see Concepts → erasure coding).
GET when one disk has bitrot-corrupted bytes Still 200 OK. The per-block HighwayHash on each shard catches the bad block, and parity reconstructs it.

How it looks on disk during a GET (mirroring the §4.9 sample). The reader walks xl.meta's version chain, picks the right entry, and uses its DataDir to find the shard files:

versions[0] = { Type=1, V2Obj { VersionID=…, DataDir=a192c1d5-…, ErasureM=12, ErasureN=4, ErasureIndex=7, … }}
                                                  │
                  on disk, under bucket/object/   ▼
                                          a192c1d5-9bd5-41fd-9a90-ab10e165398d/
                                            └── part.1   ← this disk's shard #7 of (12+4)

Each of the 16 disks in the set holds an xl.meta describing the same version chain (with quorum on what's "latest"), but its ErasureIndex differs — disk 0's shard is index 0, disk 1's is index 1, and so on, permuted by ErasureDist. The GET fans out to all 16, decodes from any 12, and the HTTP response begins streaming.

Putting it on the wire

sequenceDiagram
    autonumber
    actor C as S3 Client
    participant R as Router<br/>(api-router.go:373)
    participant H as objectAPIHandlers<br/>GetObjectHandler<br/>(object-handlers.go:717)
    participant P as erasureServerPools<br/>(erasure-server-pool.go:907)
    participant S as erasureSets<br/>(erasure-sets.go:733)
    participant E as erasureObjects<br/>(erasure-object.go:203)
    participant D as StorageAPI [N disks]<br/>(xlStorage / storageRESTClient)
    participant EC as Erasure (RS decoder)

    C->>R: GET /bucket/object  (Range, sig)
    R->>H: dispatch
    H->>H: authenticateRequest(GetObjectAction)<br/>parse Range, opts
    H->>P: GetObjectNInfo(ctx, bucket, object, rs, h, opts)

    P->>P: NewNSLock + RLock<br/>getLatestObjectInfoWithIdx → poolIdx
    Note over P: RLock allows many concurrent readers but<br/>blocks writers, so shards cannot be<br/>overwritten while we are reading them.
    P->>S: pools[poolIdx].GetObjectNInfo(...)
    S->>S: getHashedSetIndex(object)
    Note over S: CRC-based deterministic hash of the<br/>object name → which erasure set owns it.<br/>No global index, no lookup table.
    S->>E: sets[setIdx].GetObjectNInfo(...)

    Note over E: getObjectFileInfo() — fan out xl.meta reads
    par read xl.meta from each disk
        E->>D: ReadXL / ReadVersion (xl.meta)
        D-->>E: FileInfo (or err if disk down)
    end
    E->>E: merge FileInfos by header signature<br/>pick latest version with read quorum
    Note over E: Comparing 4-byte VersionHeader.Signature<br/>across disks is cheap — full per-version<br/>bodies only need to be unmarshalled<br/>for the chosen version.
    par read each shard
        E->>D: ReadFile(volume, path, offset, len)
        D-->>E: shard bytes (or nil if disk down)
    end
    E->>EC: Decode(shards) — Reed-Solomon reconstruct
    Note over EC: As long as ≥ dataBlocks shards are<br/>intact, the original bytes are<br/>recoverable — up to parityBlocks disks<br/>can be missing or bitrot-corrupted.
    EC-->>E: original block(s)

    E-->>S: GetObjectReader{ObjectInfo, io.Reader, cleanup}
    S-->>P: GetObjectReader
    P-->>H: GetObjectReader

    H->>H: setObjectHeaders (etag, content-type, range)
    H->>C: 200 OK + streamed body
    H->>H: gr.Close() → run cleanupFns

What's happening at each key step (GET)

Step numbers correspond to the autonumber labels in the sequence diagram above.

  • Step 3 — Auth & Range parsing in the handler. authenticateRequest(GetObjectAction) runs both signature verification (SigV4) and IAM/bucket-policy evaluation. The HTTP Range header is parsed here so that the storage layer below can do an offset-bounded read instead of streaming the whole object. If the request is anonymous and the policy rejects it, the handler also distinguishes "no such key" from "access denied" to match S3 semantics.
  • Step 5 — Why a read lock across the cluster. NewNSLock + RLock acquires a distributed read lock through dsync (the cluster-wide locking library — see Concepts). It is a read (shared) lock, so multiple GETs on the same object run in parallel without blocking each other; a concurrent PUT/DELETE would need a write (exclusive) lock and therefore would block here. That is what guarantees the reader sees a single coherent version of xl.meta and its shards, instead of a mix of old and new bytes from a half-finished write.
  • Steps 5 & 7 — Pool discovery, then set hashing. Pools can be added over time and an object lives in exactly one pool, so step 5 looks up the pool by reading metadata from each pool (getLatestObjectInfoWithIdx). Within a pool, set assignment in step 7 is computed from a CRC-based hash of the object name — there is no metadata server, no shared catalog: every node can independently decide which set's disks own a given key.
  • Steps 911 — Reading xl.meta with quorum. xl.meta is a small MessagePack-encoded file (see §4 for its full layout) that lists the object's version chain, the data/parity layout, the inline-data flag, and pointers to each version's shard directory. The par block at steps 910 fans out a ReadXL/ReadVersion to every disk in the set in parallel; step 11 then merges the results. The merge is cheap because each disk's xlMetaV2VersionHeader.Signature is a 4-byte deterministic digest — comparing those across disks decides quorum without unmarshalling full version bodies. Quorum here means: accept a version only when enough disks agree for that object's read quorum, normally dataBlocks (M) for a live object. Delete-marker style metadata can use simple majority (D/2+1).
  • Steps 1213 — Parallel shard reads. Inside the second par block, one goroutine per disk fetches that disk's shard for the chosen version. Even though N reads are issued, only dataBlocks of them have to succeed before decoding can start — slow disks are effectively skipped. (The earlier par at steps 910 read tiny xl.meta files; this one streams the actual shard bytes.)
  • Steps 1415 & 20 — ReedSolomon decode & streaming response. ReedSolomon (see Concepts) is the math that lets any dataBlocks of the dataBlocks + parityBlocks shards reconstruct the original bytes. The decoder (step 14) feeds shards into that math as they arrive from the disks, reconstructs missing ones from parity if necessary, and writes the recovered plaintext into a Go pipe (an io.Reader connected to an io.Writer in memory — no disk involved). Step 20 ranges over that pipe to write the HTTP response body, so large objects stream straight from disk to socket — they never sit fully in memory on the server.
  • Step 21 — gr.Close() cleanup. GetObjectReader carries a slice of cleanup functions (close disk readers, release the read lock from step 5, decrement metrics, drop GC roots). They run exactly once when the response body is closed, regardless of whether the client disconnected mid-stream.

Key call sites for GET

  • Route: cmd/api-router.go:373
  • Handler entry: GetObjectHandler cmd/object-handlers.go:717getObjectHandler :313
  • Auth: authenticateRequest(...) cmd/object-handlers.go:327
  • Pool dispatch: erasureServerPools.GetObjectNInfo cmd/erasure-server-pool.go:907
  • Set dispatch: erasureSets.GetObjectNInfo cmd/erasure-sets.go:733
  • Erasure read: erasureObjects.GetObjectNInfo cmd/erasure-object.go:203, decoder loop in getObjectWithFileInfo :310
  • Result type: GetObjectReader cmd/object-api-utils.go:760

6. Sequence diagram — PUT /bucket/object

At a glance — what a PUT does, in plain English

The whole design is write-to-temp, then atomic swap:

  1. ReedSolomon-encode the body as it streams in, writing each resulting shard to a temporary directory on a different disk. (Hashes for the ETag and signature are computed in the same pass.)
  2. Atomically commit with RenameData — every disk swaps its temp directory into place and replaces xl.meta to reference the new shards. Until this rename, no reader sees the new bytes; after it succeeds on a quorum (M+1) of disks, only the new bytes are visible. There is no "half-written" state.
  3. Return the ETag (and x-amz-version-id on a versioned bucket).

Plumbing not in the list: signature/permission check, pool selection by free space, distributed write lock, post-response notifications and replication scheduling.

The core idea

A PUT streams the body through ReedSolomon encoding into per-disk temporary files, then atomically swaps them into place with a single RenameData call. Until that swap commits on a quorum of disks, the new bytes are completely invisible to any reader; immediately after, only the new bytes are visible. There is no in-between state where a GET could see "half-written" data.

On a non-versioned bucket, a PUT to an existing key replaces the previous version. The old xl.meta entry is overwritten and the old DataDir is renamed aside for the background scanner to reclaim. Old bytes become unreachable as soon as the rename succeeds.

On a versioned bucket, a PUT to an existing key prepends a new entry to the version chain in xl.meta. The previous version's DataDir and shards are not touched — they remain reachable via ?versionId=<old-id>. Each PUT generates a fresh UUID VersionID and a fresh UUID DataDir, so versions never collide on disk.

What the client experiences:

Client request Result
PUT /bucket/object (single-part, non-versioned bucket) 200 OK + ETag: "<md5>" header. Subsequent GETs read the new bytes.
PUT /bucket/object (single-part, versioned bucket) 200 OK + ETag + x-amz-version-id: <new-uuid>. Older versions remain reachable via ?versionId=.
PUT racing a GET on the same key The PUT waits for the read lock to clear; the in-flight GET keeps reading the old version coherently; only after the PUT's RenameData commits do new GETs see the new bytes.
PUT racing another PUT on the same key They serialise on the write lock (see step 16). The second writer sees the first's commit before doing its own work. The chain ends up with two prepended entries in commit order.
PUT with Content-MD5 header The server verifies the body hash matches as bytes stream through PutObjReader (no extra read pass). Mismatch → 400 BadDigest, no xl.meta is ever written.
PUT with SSE / KMS / SSE-C headers Body is encrypted as a streaming wrapper around PutObjReader before erasure encoding. The erasure layer is unaware.
PUT on a bucket with object lock retention If the object is locked and the PUT would overwrite, returns 403; the existing version is protected. Versioned buckets with retention create a new version under the same retention policy.
PUT while ≥ M+1 disks are healthy Succeeds — write quorum is M+1.
PUT while fewer than M+1 disks are healthy Fails with InsufficientWriteQuorum. Nothing is committed; the in-progress tmp/ files are left for the scanner to clean up.

PUT commit point — quick view

Before commit:
  old DataDir/part.1 is visible through xl.meta
  tmp/<uuid>/part.1 is being written, but no reader can see it

Commit:
  RenameData(tmp → final DataDir)
  replace xl.meta so the new version points at the final DataDir

After write quorum:
  new DataDir/part.1 is visible
  old version remains reachable only if bucket versioning keeps it

How the on-disk view evolves through a PUT (mirroring the §4.9 sample). Before the PUT to a versioned bucket that already has one version:

xl.meta:
  versions[0] = { Type=1, V2Obj { VersionID=A, DataDir=a192c1d5-…, … } }

bucket/object/
  ├── a192c1d5-9bd5-41fd-9a90-ab10e165398d/   ← version A's shards
  │   └── part.1
  └── xl.meta

During the PUT (encoding into a tmp directory, not yet committed):

bucket/object/
  ├── a192c1d5-…/                              ← version A's shards (untouched)
  │   └── part.1
  ├── tmp-<uuid>/                              ← new shards being written here
  │   └── part.1                               ← bitrot-hashed, partial
  └── xl.meta                                  ← still references only version A

After RenameData commits on a quorum of disks:

xl.meta:
  versions[0] = { Type=1, V2Obj { VersionID=B, DataDir=c06e0436-…, … } }   ← new version on top
  versions[1] = { Type=1, V2Obj { VersionID=A, DataDir=a192c1d5-…, … } }   ← original, still reachable

bucket/object/
  ├── c06e0436-f813-447e-ae5e-f2564df9dfd4/   ← version B's shards (newly committed)
  │   └── part.1
  ├── a192c1d5-9bd5-41fd-9a90-ab10e165398d/   ← version A's shards (preserved)
  │   └── part.1
  └── xl.meta                                  ← now lists both versions

On a non-versioned bucket the same rename happens, except xl.meta ends up with only versions[0] (= the new version) and the old DataDir (a192c1d5-…) is renamed to a .trash-style location for the scanner to remove.

Putting it on the wire

sequenceDiagram
    autonumber
    actor C as S3 Client
    participant R as Router<br/>(api-router.go:399)
    participant H as objectAPIHandlers<br/>PutObjectHandler<br/>(object-handlers.go:1793)
    participant P as erasureServerPools<br/>(erasure-server-pool.go:1085)
    participant S as erasureSets<br/>(erasure-sets.go:739)
    participant E as erasureObjects<br/>putObject<br/>(erasure-object.go:1296)
    participant EC as Erasure (RS encoder)
    participant D as StorageAPI [N disks]
    participant L as dsync NetLocker

    C->>R: PUT /bucket/object  (body, sig, headers)
    R->>H: dispatch
    H->>H: signature verify, isPutActionAllowed,<br/>build hash.Reader → PutObjReader,<br/>SSE/compress wrap
    Note over H: PutObjReader pipes the body through<br/>MD5 + SHA256 + size accounting as bytes<br/>flow, so the ETag and signature checks<br/>cost no extra read pass.
    H->>P: PutObject(ctx, bucket, object, pReader, opts)

    P->>P: getPoolIdx() — pick target pool
    Note over P: Placement uses free-space and rebalance<br/>rules — NOT a hash. Pools can be added<br/>later, and new writes land where there<br/>is room.
    P->>S: pools[poolIdx].PutObject(...)
    S->>S: getHashedSetIndex(object)
    S->>E: sets[setIdx].PutObject(...) → putObject(...)

    E->>E: compute (data, parity) drive counts<br/>build per-disk FileInfo (xl.meta skeleton)
    E->>D: open bitrot writers on temp dir<br/>(CreateFile per disk)
    Note over D: Each fixed-size block of the shard gets<br/>a HighwayHash hash appended. Future<br/>reads recompute it, so silent disk<br/>corruption is detected, not just lost<br/>disks.
    E->>EC: erasure.Encode(src=pReader, writers=[]w)
    loop per stripe
        EC->>EC: RS split → N shards
        EC->>D: write shard_i + bitrot hash
    end
    EC-->>E: bytes written

    E->>E: finalize partsMetadata[] (etag, checksum, inline?)
    E->>L: NewNSLock(bucket,object).GetLock()
    L-->>E: write lock
    E->>D: RenameData(tmp → final) on quorum disks<br/>(atomic xl.meta swap)
    Note over D: COMMIT POINT. Up to here everything is<br/>in tmp/ and invisible. RenameData<br/>atomically moves data dir + writes the<br/>new xl.meta. After quorum succeeds,<br/>readers see only the new version. On<br/>crash, tmp/ is cleaned up later.
    D-->>E: ok / errors
    E->>E: commitRenameDataDir + reduceWriteQuorumErrs
    E->>L: Unlock

    E-->>S: ObjectInfo (etag, size, versionID)
    S-->>P: ObjectInfo
    P-->>H: ObjectInfo
    H->>C: 200 OK (ETag header)

What's happening at each key step (PUT)

Step numbers correspond to the autonumber labels in the sequence diagram above.

  • Step 3 — Why wrap the body in PutObjReader. The S3 contract requires the server to verify the client's MD5/SHA256 of the body and return that MD5 as the ETag. Re-reading the body would cost a full extra pass and possibly buffering. Instead hash.Reader / PutObjReader is a streaming wrapper: as the encoder pulls bytes through it, the same bytes feed running hash computations. By the time the encoder sees EOF, the ETag and signature verdict are also ready.
  • Step 3 — Optional inline transformations. Before reaching the storage layer the bytes may also pass through SSE-S3/SSE-KMS/SSE-C encryption and/or zstd compression — also as streaming wrappers. Compression and encryption are invisible to the erasure layer; it only sees an opaque io.Reader.
  • Step 5 — Pool placement is policy, not hashing. Unlike GET (where you have to find the existing object), a fresh PUT chooses where to land. getPoolIdx picks the pool with the most free space, subject to rebalance rules. This is also why a later PUT to the same key may land in a different pool than the earlier version did, and why DELETE has to discover the pool by lookup.
  • Step 9 — Drive count from the storage class. (dataDrives, parityDrives) is read from the bucket/object storage class (e.g. EC:4 on a 16-drive set means 12 data + 4 parity). This is per-object, so the same set can hold objects with different durability levels. The same step also builds a per-disk FileInfo (xl.meta skeleton) describing the layout for each shard.
  • Step 10 — Bitrot writers wrap the disk writers. Bitrot (see Concepts) is silent disk corruption — bytes that read back wrong without any error being raised. To detect it, every fixed-size block written gets a small HighwayHash appended next to the block bytes inside the shard file. On read, the verifier recomputes the hash and rejects the block on mismatch. ReedSolomon then reconstructs the bad block from parity, so a flipped bit on disk doesn't return wrong data to the client — it triggers automatic recovery.
  • Steps 1114 — ReedSolomon Encode loop. The encoder pulls a stripe of dataBlocks * blockSize bytes from pReader, computes parityBlocks parity shards, and writes one shard per writer. It does this until EOF — so a 100 MB upload streams through, never landing fully in memory. The inner loop (steps 1213) repeats once per stripe.
  • Step 15 — Per-disk xl.meta finalisation. After encoding completes, each of the N disks ends up with its own xl.meta describing its shard: which version, what bitrot algorithm, which checksums per block, whether the data was small enough to inline directly into xl.meta rather than into its own file. The version UUID and ETag are identical across disks; the per-shard checksums differ.
  • Steps 1617 — Write lock just before commit. Note that the lock is taken after all the heavy I/O is already done into tmp/. This minimises the time the lock is held — concurrent readers and writers of the same key only block during the rename, not during the upload.
  • Step 18 — RenameData is the atomic commit point. Before it succeeds, the new version is invisible: any concurrent GET still sees the old xl.meta. After it succeeds on a quorum of disks, GETs see only the new version. A crash in the middle leaves orphaned tmp/ directories that the background scanner later removes. There is no "half-written object" state visible to clients.
  • After step 25 — Side effects after the response. Bucket notifications, replication scheduling, and lifecycle event hooks (not drawn in the diagram) all run after the 200 has gone back to the client, on the request goroutine. They are best-effort; a slow Lambda subscriber cannot back-pressure the data path.

Key call sites for PUT

  • Route: cmd/api-router.go:399
  • Handler: PutObjectHandler cmd/object-handlers.go:1793, body wrapping at :1951
  • Pool dispatch: erasureServerPools.PutObject cmd/erasure-server-pool.go:1085
  • Set dispatch: erasureSets.PutObject cmd/erasure-sets.go:739
  • Encode loop: erasureObjects.putObject cmd/erasure-object.go:1296erasure.Encode(...) at :1440
  • Atomic rename: renameData at :1553 (write lock acquired just above)
  • Reader wrapper: PutObjReader cmd/object-api-utils.go:1040

7. Sequence diagram — DELETE /bucket/object

At a glance — what a DELETE does, in plain English

DELETE is a metadata-only operation — no shard bytes are ever transferred:

  1. Pick the path based on bucket versioning and any ?versionId=:
    • Tombstone (versioned bucket, no version specified) → prepend a delete-marker entry to xl.meta. Shard files are not touched — the previous version remains readable via ?versionId=.
    • Permanent (unversioned bucket, or explicit version) → strip the version's entry from xl.meta and rename its DataDir aside for an async sweeper to reclaim later.
  2. Send a small DeleteVersion RPC to every disk in the set. Metadata-only — these ride the grid WebSocket, not the file-streaming HTTP path. Accept once a simple majority of disks acknowledge.

Plumbing not in the list: signature/permission check, object-lock retention enforcement, distributed write lock, post-response notifications and replication-of-delete scheduling.

The core idea

DELETE behaves very differently depending on whether the bucket has versioning enabled.

On a non-versioned bucket, DELETE /bucket/object removes the object's metadata and (asynchronously) reclaims its bytes. Gone.

On a versioned bucket, the same DELETE does not touch the object's bytes at all. Instead, Buckit prepends a new entry to the object's version chain in xl.meta. That entry is a DeleteMarker — it has a fresh UUID, a timestamp, and the type field Type=2. It carries no DataDir, no shards, no parts — it is purely metadata (see xlMetaV2DeleteMarker in §4.4 and the two-version sample in §4.9).

What changes for clients afterwards:

Client request Result on a versioned bucket after the DELETE
GET /bucket/object 404 Not Found — the most-recent version is the delete marker, so the object appears gone.
GET /bucket/object?versionId=<original-id> 200 OK + the original bytes — historical versions remain reachable when the version ID is known.
HEAD /bucket/object?versionId=<delete-marker-id> 405 Method Not Allowed + x-amz-delete-marker: true response header — the marker itself is queryable.
ListObjectsV2 The object is hidden.
ListObjectVersions Both entries are returned: the delete marker (IsLatest:true, IsDeleteMarker:true) and the original version (IsLatest:false).

To actually erase the bytes you must do a permanent delete, which is DELETE /bucket/object?versionId=<original-id> — that strips the matching xlMetaV2Object out of the version chain and renames its DataDir for async reclamation. (Deleting the marker itself, DELETE ?versionId=<delete-marker-id>, "uncovers" the previous version and the object reappears.)

Why this design exists:

  • Cheap. No shard rewrite, no ReedSolomon work — just a small msgpack append. The diagram below shows that all the disk-side work is DeleteVersion, a metadata-only RPC over the grid WebSocket.
  • Reversible. Accidental deletes can be undone by removing the marker. Useful for ransomware protection and "undo" features.
  • Audit-friendly. Every deletion is a recorded version with a timestamp (and, with MFA-Delete, an authenticated identity), instead of being a destructive event with no trace.

How it looks on disk after a single PUT followed by a versioned DELETE:

versions[0] = { Type=2, DeleteMarker {VersionID, ModTime, MetaSys}        }   ← tombstone, no data
versions[1] = { Type=1, V2Obj        {VersionID, DataDir, ErasureM, … }   }   ← original, untouched

The shard files referenced by versions[1].DataDir are still on disk and continue to be readable via ?versionId=. They are only reclaimed if someone permanently deletes that version.

Putting it on the wire

DELETE is, in implementation terms, mostly a metadata operation. On a versioned bucket the handler does not erase the object's data shards at all — it just writes a delete-marker tombstone entry into xl.meta on quorum disks. Permanent removal (unversioned bucket, or an explicit ?versionId=… delete) similarly goes through StorageAPI.DeleteVersion, which is a small structured RPC. Reclaiming the now-orphaned shards is left to the background scanner.

sequenceDiagram
    autonumber
    actor C as S3 Client
    participant R as Router<br/>(api-router.go:403)
    participant H as objectAPIHandlers<br/>DeleteObjectHandler<br/>(object-handlers.go:2563)
    participant P as erasureServerPools<br/>DeleteObject<br/>(erasure-server-pool.go:1126)
    participant S as erasureSets<br/>(erasure-sets.go:765)
    participant E as erasureObjects<br/>DeleteObject<br/>(erasure-object.go:1885)
    participant L as dsync NetLocker
    participant D as StorageAPI [N disks]
    participant EV as event / replication

    C->>R: DELETE /bucket/object[?versionId=…]
    R->>H: dispatch
    H->>H: checkRequestAuthType(DeleteObjectAction)<br/>delOpts(), object-lock & retention checks<br/>SetEvalMetadataFn (replication decision)<br/>SetEvalRetentionBypassFn

    H->>P: objectAPI.DeleteObject(ctx, bucket, object, opts)
    P->>L: NewNSLock(bucket,object).GetLock()  [WRITE]
    L-->>P: lock acquired
    P->>P: getPoolInfoExistingWithOpts → poolIdx
    alt opts.DeletePrefix
        P->>P: deletePrefix(...) (lifecycle expiry path)
    else normal delete
        P->>S: serverPools[poolIdx].DeleteObject(...)
        S->>S: getHashedSetIndex(object)
        S->>E: sets[setIdx].DeleteObject(...)

        E->>E: getObjectInfoAndQuorum() → goi<br/>EvalMetadataFn (replication)<br/>EvalRetentionBypassFn

        alt versioned bucket (markDelete & Versioned)
            Note over E: build tombstone FileInfo —<br/>Deleted=true, MarkDeleted=true,<br/>VersionID=newUUID, ModTime=now
            E->>E: deleteObjectVersion(...)<br/>(erasure-object.go:1626)
            par per disk
                E->>D: DeleteVersion(bucket, object, fi=tombstone)
                D-->>E: ok / errDiskNotFound
            end
            E->>E: reduceWriteQuorumErrs (D/2+1)
            Note right of E: data shards untouched —<br/>only xl.meta gets a new<br/>delete-marker version
        else permanent delete (unversioned, or specific versionID)
            Note over E: build FileInfo for target version
            par per disk
                E->>D: DeleteVersion(bucket, object, fi)<br/>removes xl.meta entry +<br/>schedules data dir cleanup
                D-->>E: ok / err
            end
            E->>E: reduceWriteQuorumErrs (D/2+1)
        end

        E-->>S: ObjectInfo (with DeleteMarker flag, VersionID)
        S-->>P: ObjectInfo
    end
    P->>L: Unlock
    P-->>H: ObjectInfo

    H->>C: 204 No Content<br/>(x-amz-delete-marker, x-amz-version-id)
    H->>EV: sendEvent(ObjectRemovedDelete /<br/>ObjectRemovedDeleteMarkerCreated)
    opt replication pending
        H->>EV: scheduleReplicationDelete(...)
    end
    opt tiered storage
        H->>H: objSweeper.Sweep() — remove transitioned remote object
    end

What's happening at each key step (DELETE)

Step numbers correspond to the autonumber labels in the sequence diagram above.

  • Step 3 — Authorisation is the first gate. checkRequestAuthType(DeleteObjectAction) runs before any storage call. A bucket with object lock enabled (the S3 WORM/retention feature — see Concepts) also rejects DeletePrefix here, so a recursive delete cannot bypass retention rules even from an authorised user.
  • Step 3 — Eval callbacks wired in but not invoked yet. The handler attaches two closures to opts: EvalMetadataFn (decides whether this delete needs to be replicated to a peer cluster) and EvalRetentionBypassFn (checks legal-hold / retention with the x-amz-bypass-governance-retention header). They are invoked later in step 12, after the storage layer has fetched the current ObjectInfo — that way the decision is made against the version actually present, not against a possibly-stale view.
  • Steps 56 — Distributed write lock at the pool layer. Unlike PUT (which locks late, just for the rename), DELETE locks early, before pool discovery. That is because the pool that owns the object can in principle change under us if a rebalance is in flight; holding the write lock across the discovery + delete step makes the pair atomic from any concurrent reader's perspective.
  • Step 7 — Pool discovery. getPoolInfoExistingWithOpts reads xl.meta from each pool to find the one that actually has the object. If multiple pools claim the same key (a transient state during pool addition), the multi-pool fan-out branch (not drawn) deletes from all of them concurrently to converge.
  • Steps 1219 — Two very different paths inside erasureObjects.DeleteObject.
    • Tombstone branch — versioned bucket (steps 1316). Build a tombstone FileInfo (Deleted=true, MarkDeleted=true, VersionID=newUUID) and append it to the version chain in xl.meta. The original data shards are not touched at all. A subsequent unversioned GET sees the tombstone and returns 404; a versioned GET with the explicit prior versionId still works and reads the original bytes.
    • Permanent-delete branch — unversioned bucket or explicit ?versionId=… (steps 1719). Remove the targeted version from xl.meta and rename its data directory into a .trash-style location for the background scanner to reclaim. The request returns as soon as the rename is committed on a quorum of disks; reclaiming bytes is not on the hot path.
  • Steps 16 & 19 — The looser quorum. Both branches finish with reduceWriteQuorumErrs against D/2 + 1 quorum (erasure-object.go:1634), where D is the total number of disks in the set. Normal read/write quorum is computed from the storage class and object erasure metadata. The comment at that line explains the choice: storage-class-derived quorum exists to protect data durability against silent corruption, but a tombstone is just a small metadata record — over-strict quorum on deletes would unnecessarily fail in degraded clusters where reads still work.
  • Steps 14 & 17 — Why every disk arrow is a grid/WebSocket call. StorageAPI.DeleteVersion is dispatched through storageDeleteVersionRPC.Call(ctx, client.gridConn, …). Its payload is a small msgpack FileInfo, ideal for the multiplexed connection. There is no shard data to ship across the network, so the plain-HTTP file-streaming endpoints are not used at all — confirmed in §8.
  • Steps 2527 — Side effects after the 204. Event notification (s3:ObjectRemoved:Delete or s3:ObjectRemoved:DeleteMarkerCreated) at step 25, async replication-delete scheduling at step 26 (only if the bucket has a replication target with a pending delete state), and objSweeper.Sweep() at step 27 for tiered objects whose remote (transitioned) copy must also be removed. As with PUT, these run after the response is on the wire.

Key call sites for DELETE

  • Route: cmd/api-router.go:403
  • Handler: DeleteObjectHandler cmd/object-handlers.go:2563; auth at :2582, opts/lock checks at :2608-2620, replication decision callback at :2624, retention bypass at :2652, dispatch at :2671, event emit at :2712, replication schedule at :2742, tier sweep at :2747
  • Pool dispatch + write lock: erasureServerPools.DeleteObject cmd/erasure-server-pool.go:1126 (lock acquired at :1136, prefix path at :1144, multi-pool fan-out at :1183)
  • Set dispatch: erasureSets.DeleteObject cmd/erasure-sets.go:765
  • Erasure logic: erasureObjects.DeleteObject cmd/erasure-object.go:1885; tombstone path at :2092, permanent delete path at :2128
  • Tombstone fan-out: erasureObjects.deleteObjectVersion cmd/erasure-object.go:1626 — quorum is D/2+1, weaker than normal object read/write quorum
  • Disk RPC: storageRESTClient.DeleteVersion cmd/storage-rest-client.go:436 (uses storageDeleteVersionRPC.Call(...) over grid)

Why DELETE is "free" of bulk transport

Concern DELETE behavior
Data shards Not touched on a tombstone delete; on a permanent delete, the data dir is renamed/marked for async removal — no bytes streamed across nodes
Disk RPC Single grid call (DeleteVersion) — small msgpack payload
Quorum D/2 + 1 (write quorum lower than for PUT, see :1634)
Cleanup of orphaned shards Background scanner / heal, not the request path

So in distributed mode every disk arrow in the DELETE diagram travels over the grid WebSocket (see §8). The plain-HTTP file streaming path is never used for a DELETE.


8. Local vs distributed disk — runtime polymorphism

The same erasureObjects.putObject / getObjectFileInfo code paths talk to disks through the StorageAPI interface; whether each disk is local or on another node is decided once at format time.

When the disk is remote, storageRESTClient uses two different transports depending on the kind of call. Small, structured RPCs (metadata, control) ride the multiplexed WebSocket internal/grid connection. Bulk file bytes (shards) are streamed over a plain HTTP request body — not over the grid WebSocket.

flowchart LR
    EO[erasureObjects] -->|getDisks| API[StorageAPI N times]
    API -->|local endpoint| XL[xlStorage<br/>direct fs syscalls<br/>cmd/xl-storage.go]
    API -->|remote endpoint| RC[storageRESTClient<br/>cmd/storage-rest-client.go]

    RC -->|metadata / control RPC<br/>WriteMetadata, RenameData,<br/>ReadVersion, ReadXL, WriteAll,<br/>CheckParts, ListDir, DeleteVersion| GRID[internal/grid<br/>multiplexed WebSocket]
    RC -->|bulk file bytes<br/>CreateFile, ReadFile,<br/>ReadFileStream, AppendFile| HTTP[plain HTTP/2 request<br/>streamed body]

    GRID -->|peer node| RS[storageRESTServer<br/>cmd/storage-rest-server.go]
    HTTP -->|peer node| RS
    RS --> XL2[xlStorage on peer]

Why the split? internal/grid is optimised for small, frequent messages — many concurrent RPCs share one WebSocket via stream multiplexing (one TCP+WebSocket connection carrying many independent logical streams; see Concepts). The trade-off of multiplexing is head-of-line blocking: if one slow message hogs the underlying connection, every other message queued behind it has to wait. Streaming a multi-MB shard over the grid WebSocket would block every concurrent metadata RPC on that same connection for as long as the transfer takes. So file-data calls take a dedicated HTTP request whose body can be streamed in isolation, while small metadata calls keep flowing on the multiplexed channel. See internal/grid/README.md for the full design rationale.

Concretely, in cmd/storage-rest-client.go:

Call Transport Reference
WriteMetadata, UpdateMetadata grid (*RPC.Call(ctx, client.gridConn, …)) :412, :426
WriteAll, CheckParts, DeleteVersion grid :456, :468, :440
ReadVersion, ReadXL, ReadAll grid :534, :577, :612
RenameData, ListDir grid :485, :675
CreateFile (write shard bytes) HTTP (client.call(...), streamed body) :399
ReadFile, ReadFileStream (read shard bytes) HTTP (client.callGet(...)) :644, :629
AppendFile HTTP :387

So in distributed mode the GET/PUT sequence diagrams above are essentially unchanged — but inside each remote-disk arrow, the transport differs:

  • getObjectFileInfo (reads xl.meta) → grid / WebSocket
  • ReadFile of each erasure shard → plain HTTP
  • Encode writers calling CreateFile per disk → plain HTTP
  • RenameData (atomic xl.meta swap) → grid / WebSocket

9. Cluster topology — pools, sets, disks, and peer nodes

The topology comes from the server startup endpoints. Buckit parses those endpoints, groups them into pools, divides each pool into erasure sets, and marks every disk endpoint as either local to this process or remote on a peer node.

For example, a distributed erasure deployment might be started with endpoint arguments like this:

minio server \
  http://node{1...4}:9000/export{1...4}

That expands to 16 disk endpoints: /export1 through /export4 on each of node1, node2, node3, and node4. On node1, endpoints whose host resolves to node1 are marked local and opened as xlStorage; endpoints for node2, node3, and node4 are marked remote and accessed through storageRESTClient.

A deployment with two pools can pass two endpoint groups:

minio server \
  http://node{1...4}:9000/export{1...4} \
  http://node{5...8}:9000/export{1...4}

The first group becomes pool 0 and the second group becomes pool 1.

Where it is stored and when it is loaded

The topology is not read from xl.meta. It is built in memory during server startup from the CLI/config endpoint layout, then stored in the global globalEndpoints variable (cmd/globals.go). The same EndpointServerPools value is passed to the grid, routers, and object layer.

Startup step File What happens
Parse endpoint args into pool layouts cmd/endpoint-ellipses.go Ellipsis args such as http://node{1...4}:9000/export{1...4} expand into set layouts.
Build endpoint topology cmd/endpoint-ellipses.go:createServerEndpoints Calls CreatePoolEndpoints, then wraps each pool as PoolEndpoints with SetCount, DrivesPerSet, Endpoints, and original command line.
Resolve local vs remote endpoints cmd/endpoint.go:CreatePoolEndpoints Creates Endpoint values, assigns PoolIdx, SetIdx, DiskIdx, and updates IsLocal.
Store globally cmd/server-main.go:serverHandleCmdArgs Assigns the result to globalEndpoints; also derives globalNodes, local node name, proxy endpoints, and notification clients.
Initialize inter-node RPC cmd/server-main.go Passes globalEndpoints to initGlobalGrid and initGlobalLockGrid.
Register storage REST routes cmd/routers.go:configureServerHandler Passes globalEndpoints into distributed erasure routers so peer disk RPC endpoints are available.
Build object layer cmd/server-main.go:newObjectLayer Passes globalEndpoints to newErasureServerPools, which creates pools, sets, and per-endpoint StorageAPI disks.

After startup, request handling uses this in-memory topology. A GET does not ask the cluster "which peer has disk 7?" It selects the pool/set, gets that set's endpoint slice, and each endpoint already carries enough information to choose local disk access or remote peer access.

The important types are:

Type / function File Role
Endpoint cmd/endpoint.go One disk endpoint: path/URL plus PoolIdx, SetIdx, DiskIdx, and IsLocal. Its GridHost() method derives the peer grid address for remote calls.
PoolEndpoints cmd/endpoint.go All disk endpoints that belong to one pool.
EndpointServerPools cmd/endpoint.go All pools for this server deployment. This is the topology object passed into storage startup.
CreatePoolEndpoints cmd/endpoint.go Builds endpoint groups from the configured pool layouts and resolves which endpoints are local.
newErasureServerPools cmd/erasure-server-pool.go Builds the top-level ObjectLayer from EndpointServerPools.
newErasureSets cmd/erasure-sets.go Splits one pool's endpoints into set-sized groups and creates erasureObjects for each set.
newStorageAPI cmd/object-api-common.go Turns one Endpoint into either local xlStorage or remote storageRESTClient.

Conceptually:

Configured server endpoints
        │
        ▼
EndpointServerPools
        │
        ├── Pool 0: endpoint list
        │       ├── Set 0: first setDriveCount endpoints
        │       ├── Set 1: next setDriveCount endpoints
        │       └── ...
        │
        └── Pool 1: endpoint list
                ├── Set 0
                └── ...

Within each set, every endpoint becomes a StorageAPI disk:

flowchart TB
    eps["EndpointServerPools<br/>all configured disk endpoints"]

    subgraph pool0["Pool 0"]
        subgraph set0["Set 0"]
            ep00["Endpoint 0<br/>PoolIdx=0 SetIdx=0 DiskIdx=0<br/>IsLocal=true"]
            ep01["Endpoint 1<br/>PoolIdx=0 SetIdx=0 DiskIdx=1<br/>IsLocal=false<br/>GridHost=node-b:9000"]
            ep02["Endpoint 2<br/>PoolIdx=0 SetIdx=0 DiskIdx=2<br/>IsLocal=false<br/>GridHost=node-c:9000"]
        end
        subgraph set1["Set 1"]
            ep10["Endpoint ..."]
        end
    end

    eps --> pool0
    ep00 --> xl["xlStorage<br/>local disk path"]
    ep01 --> rc1["storageRESTClient<br/>peer node-b"]
    ep02 --> rc2["storageRESTClient<br/>peer node-c"]

IsLocal answers "is this disk attached to the current server process?" If yes, newStorageAPI opens it as xlStorage. If no, newStorageAPI builds a storageRESTClient, and that client uses the endpoint's GridHost() value to find the peer node connection.

That means "peer nodes for the same set" are not discovered from object metadata. They are known from the startup endpoint topology: the selected set is a slice of endpoints, and each endpoint already says which peer owns that disk path.

Object placement then works on top of that topology:

Step Decision Where it happens
Pick a pool for a new PUT Policy/free-space/rebalance decision erasureServerPools.getPoolIdx
Find the pool for an existing GET/DELETE Read metadata across pools and choose the one with the object erasureServerPools
Pick a set within a pool Hash the object name to a set index erasureSets.getHashedSetIndex
Pick disks within the set Use that set's endpoint slice erasureObjects.getDisks
Choose local vs remote disk access Endpoint IsLocal decides xlStorage vs storageRESTClient newStorageAPI

So the runtime path is:

object name
  -> pool decision
  -> set index inside that pool
  -> endpoint list for that set
  -> local xlStorage or remote storageRESTClient per endpoint

How a landing node finds the pool for GET

For a GET, the landing node starts with only the bucket/object name and the in-memory topology. It does not get a pool ID from the request, and xl.meta does not store the endpoint/node topology.

If there is only one pool, the answer is pool 0. If there are multiple pools, erasureServerPools.GetObjectNInfo calls getLatestObjectInfoWithIdx. That method asks every pool in parallel for GetObjectInfo(bucket, object). Each pool hashes the object name to its own set, reads that set's xl.meta from quorum disks, and returns either object metadata or a not-found/error result.

The landing node then sorts successful pool results by object modification time and chooses the newest object version. The chosen pool index (zIdx) is used for the actual stream:

GET /bucket/object lands on any node
  -> erasureServerPools.GetObjectNInfo
  -> ask every pool: do you have current xl.meta for this object?
  -> choose newest successful ObjectInfo
  -> use that pool index for serverPools[zIdx].GetObjectNInfo(...)
  -> selected pool hashes object name to set
  -> selected set reads shard files from local/remote disks

This multi-pool lookup matters because pools can be added over time, and older object versions may live in older pools while new writes may land elsewhere. During unusual cases such as duplicate writes or rebalance, the newest ModTime wins; if a result is a delete marker, normal S3 delete-marker behavior is preserved.


10. Internal configuration files — what lives outside xl.meta

xl.meta is only per-object metadata. Buckit also stores server, IAM, bucket, disk-format, and operational metadata under the internal .minio.sys namespace.

Area Stored where What it contains
Disk/erasure format .minio.sys/format.json on each disk Deployment ID, disk UUIDs, erasure sets, and this disk's identity/position. Used to verify the drive belongs to the cluster.
Server config .minio.sys/config/config.json Cluster/server subsystems: API settings, storage class, compression, scanner, notifications, identity providers, KMS, and similar server-wide configuration.
IAM config .minio.sys/config/iam/... Users, groups, service accounts, STS credentials, IAM policies, and policy mappings. Can also be stored in etcd if configured.
Bucket metadata .minio.sys/buckets/<bucket>/.metadata.bin plus bucket config files Bucket-level metadata and cached parsed configuration.
Bucket policy .minio.sys/buckets/<bucket>/policy.json S3 bucket access policy.
Bucket lifecycle .minio.sys/buckets/<bucket>/lifecycle.xml Expiration and transition rules.
Bucket versioning .minio.sys/buckets/<bucket>/versioning.xml Versioning enabled/suspended state.
Bucket replication .minio.sys/buckets/<bucket>/replication.xml Replication rules and target ARNs.
Bucket notification .minio.sys/buckets/<bucket>/notification.xml Event notification rules.
Bucket encryption .minio.sys/buckets/<bucket>/bucket-encryption.xml Default bucket server-side encryption config.
Bucket tagging .minio.sys/buckets/<bucket>/tagging.xml Bucket tags.
Bucket quota .minio.sys/buckets/<bucket>/quota.json Bucket quota limits.
Object lock .minio.sys/buckets/<bucket>/object-lock.xml Retention and legal-hold bucket config.
Multipart temp state .minio.sys/multipart/... In-progress multipart upload metadata and temporary part data before completion.
Temp/trash .minio.sys/tmp/... Temporary writes, trash, and cleanup state.
Healing / scanner / usage .minio.sys/buckets/... and related internal paths Data usage cache, background heal state, MRF lists, and metacache/listing state.
Replication operational state .minio.sys/buckets/<bucket>/replication/... Resync state, MRF queues, and replication bookkeeping.

Conceptually, .minio.sys is Buckit's internal metadata namespace. Some entries are normal internal objects written through the object layer, while a few bootstrapping files are direct per-disk files.

.minio.sys/
├── format.json              direct per-disk file, not an erasure-coded object
├── config/
│   ├── config.json
│   └── iam/
├── buckets/
│   └── <bucket>/
├── multipart/
└── tmp/

Bucket-level config is one branch of that internal namespace:

.minio.sys/
└── buckets/
    └── photos/
        ├── .metadata.bin
        ├── policy.json
        ├── lifecycle.xml
        ├── versioning.xml
        ├── replication.xml
        ├── notification.xml
        ├── bucket-encryption.xml
        ├── tagging.xml
        ├── quota.json
        └── object-lock.xml

Most internal entries, such as bucket policy/versioning/lifecycle/notification config, are written like normal S3 objects and are protected by the cluster's normal erasure/quorum path. The difference is that they live under Buckit's internal .minio.sys namespace instead of a user's bucket namespace.

format.json is the important exception. It is a direct per-disk format file stored at <disk>/.minio.sys/format.json, used before the object layer is fully available. In a 16-disk deployment there are 16 physical format.json files. They share the same deployment/set layout, but each one has a different xl.this value identifying the disk that owns that file.

Major settings in format.json and config.json

format.json answers "what disk is this, and which erasure deployment does it belong to?" It is intentionally small and bootstrapping-oriented:

Field Meaning
version Metadata format version for the format.json wrapper.
format Backend format type, usually erasure storage (xl) or single-drive erasure mode.
id Deployment ID. Disks from different deployments must not be mixed.
xl.version Erasure backend format version.
xl.this UUID of this physical disk. This is the main per-disk difference between otherwise similar format.json files.
xl.sets Full matrix of disk UUIDs grouped by erasure set. This is how Buckit verifies each disk's expected set/slot.
xl.distributionAlgo Hash/distribution algorithm used to map object names to erasure sets.

Example format.json for one disk in a 4-disk erasure set:

{
  "version": "1",
  "format": "xl",
  "id": "deployment-uuid",
  "xl": {
    "version": "3",
    "this": "disk-2-uuid",
    "sets": [
      [
        "disk-1-uuid",
        "disk-2-uuid",
        "disk-3-uuid",
        "disk-4-uuid"
      ]
    ],
    "distributionAlgo": "SIPMOD+PARITY"
  }
}

On disk 3, the same file would have the same id, sets, and distributionAlgo, but xl.this would be disk-3-uuid.

config.json answers "how should this server/cluster behave?" In code it is a map shaped like subsystem -> settings block name -> key/value list (internal/config/config.go:413).

For many subsystems there is only one settings block, named default. For example, there is usually one global api config:

{
  "api": {
    "default": [
      { "key": "requests_max", "value": "0" },
      { "key": "requests_deadline", "value": "10s" }
    ]
  }
}

Some subsystems can have multiple named settings blocks because you may configure more than one destination. For example, bucket notifications may send events to two webhook targets:

{
  "notify_webhook": {
    "audit-events": [
      { "key": "enable", "value": "on" },
      { "key": "endpoint", "value": "https://audit.example.com/minio-events" }
    ],
    "billing-events": [
      { "key": "enable", "value": "on" },
      { "key": "endpoint", "value": "https://billing.example.com/minio-events" }
    ]
  }
}

So the middle name means:

Shape Meaning
"api" -> "default" -> keys One global API settings block.
"scanner" -> "default" -> keys One global scanner settings block.
"notify_webhook" -> "audit-events" -> keys One named webhook notification target.
"notify_webhook" -> "billing-events" -> keys Another named webhook notification target.

A larger config.json combines many subsystems:

{
  "api": {
    "default": [
      { "key": "requests_max", "value": "0" }
    ]
  },
  "scanner": {
    "default": [
      { "key": "cycle", "value": "1.0" },
      { "key": "delay", "value": "10.0" }
    ]
  },
  "notify_webhook": {
    "primary": [
      { "key": "enable", "value": "on" },
      { "key": "endpoint", "value": "https://example.com/events" }
    ]
  }
}

The exact keys vary by subsystem, but the major subsystem groups are:

Subsystem group Examples What it controls
API behavior api Request limits, throttling-related settings, auth behavior, and other global S3/API runtime knobs.
Storage redundancy storage_class Default and reduced-redundancy parity choices such as how many parity shards to use.
Healing heal Background healing frequency and bitrot verification behavior.
Scanner scanner Namespace scanner pacing and limits used for usage calculation, lifecycle, healing, and cleanup.
Compression compression Whether server-side object compression is enabled and which objects are eligible.
Identity providers identity_openid, identity_ldap, identity_tls, identity_plugin External identity integrations for authentication and SSO.
Policy plugins policy_opa, policy_plugin External authorization / policy enforcement integrations.
Server logging and audit logger_webhook, audit_webhook, audit_kafka Where server logs and audit events are shipped.
Bucket notification targets notify_webhook, notify_kafka, notify_amqp, notify_mqtt, notify_nats, and others External event targets used by bucket notification rules.
KMS / encryption-related config KMS-related environment/config integration Server-side encryption key management and auto-encryption behavior.
External metadata store etcd Optional external storage for IAM assets and bucket federation behavior.
Site and region labels site, region Cluster identity labels used by admin APIs, metrics, and integrations.
Operational subsystems drive, ilm, batch, browser, subnet, callhome Drive behavior, lifecycle worker behavior, batch jobs, browser settings, SUBNET/callhome integration.

Environment variables can override many config.json values at runtime. Also, if KMS is configured, the persisted config.json data can be encrypted before it is saved under .minio.sys/config/config.json.

The high-level split is:

.minio.sys/format.json
  -> direct per-disk file: disk identity and erasure-set membership

.minio.sys/config/config.json
.minio.sys/config/iam/...
  -> server-wide configuration and identity/access configuration

.minio.sys/buckets/<bucket>/...
  -> bucket-level configuration and operational bucket metadata

<bucket>/<object>/xl.meta
  -> per-object version/data metadata

format.json and startup endpoints complement each other. Startup endpoints build the live in-memory topology (globalEndpoints); format.json verifies that each disk is the expected disk from the expected erasure deployment. Neither replaces xl.meta, which remains the per-object source of truth for versions, DataDir, parts, and checksums.

Startup load sequence

At startup, Buckit has to bootstrap from information that lives in different places. The order matters because some files are needed before the normal object layer can be trusted.

server command line / environment
  -> parse endpoint layout into pools, sets, and disk endpoints
  -> connect to local and remote disk endpoints
  -> read each disk's .minio.sys/format.json
  -> verify disks match the expected deployment and set layout
  -> build erasure sets, server pools, and the ObjectLayer
  -> load server config, IAM, and bucket metadata from .minio.sys
  -> register routers and begin serving S3/Admin requests
Phase Main source Result
Endpoint parsing Startup endpoint arguments In-memory pool/set/disk topology in globalEndpoints.
Disk identity check <disk>/.minio.sys/format.json Verifies each disk belongs to the deployment and identifies its set/slot.
Object layer creation Endpoint topology + format.json Builds erasureServerPools, erasureSets, and per-disk StorageAPI implementations.
Cluster config load .minio.sys/config/... Loads server-wide config and IAM state.
Bucket metadata load .minio.sys/buckets/<bucket>/... Fills globalBucketMetadataSys so request handlers can read bucket config from memory.

Which metadata file answers which question?

Metadata Scope Stored as Answers
Startup endpoints Process / cluster topology CLI args or configured server startup input Which pools, sets, hosts, and disk paths should this process use?
format.json One physical disk Direct per-disk file Is this disk part of this deployment, and which set/slot UUID does it own?
.minio.sys/config/config.json Cluster/server Internal config object What are the server-wide subsystem settings?
.minio.sys/config/iam/... Cluster identity/access Internal config objects, or etcd if configured Which users, groups, policies, and credentials exist?
.minio.sys/buckets/<bucket>/... One bucket Internal bucket metadata objects What policy, versioning, lifecycle, notification, quota, encryption, and object-lock settings apply to this bucket?
<bucket>/<object>/xl.meta One object on one disk Per-object metadata file next to object data Which versions exist, where are their shard files, and what erasure/checksum metadata describes them?

Failure behavior quick reference

Problem What Buckit does
A disk is missing format.json Treats the disk as unformatted or replaced. Startup/heal can write the expected format only when the rest of the set has enough consistent format metadata.
A disk has corrupt or inconsistent format.json Rejects that disk for the deployment until it is repaired or healed; the disk must match the deployment ID, set layout, and expected disk UUID position.
One node misses a bucket metadata refresh RPC The node may temporarily use stale in-memory bucket config. The periodic bucket metadata refresh loop reloads from .minio.sys as a backstop.
One disk has stale xl.meta for an object Reads collect xl.meta from the erasure set and use quorum/metadata reconciliation to select the correct object version. Healing can repair stale or missing metadata.
Some object shard files are missing or corrupt GET can still succeed if read quorum is available; missing/corrupt blocks are reconstructed from erasure data and parity, then healing can repair the bad copy.

What metadata a request consults

Request stage Metadata used Why
Route request to handler S3 API router table Chooses the S3 handler method.
Authenticate / authorize IAM config and bucket policy Verifies credentials and permissions.
Check bucket behavior globalBucketMetadataSys Applies versioning, object lock, lifecycle, notification, encryption, quota, and related bucket settings.
Choose pool/set/disk endpoints Startup topology in globalEndpoints; for object reads, pool lookup may probe object metadata Finds the erasure set and local/remote disk endpoints to use.
Read or update object version state xl.meta from the selected erasure set Determines current version, delete markers, DataDir, part list, erasure layout, and user/system metadata.
Read or write object bytes Shard files under the version's DataDir Streams part.1, part.2, etc. through bitrot verification and erasure encode/decode.
Emit events / replication work Bucket notification/replication config plus object result Decides whether to publish bucket notifications or enqueue replication work.

How bucket metadata changes reach every node

Bucket config is read often: request handlers need policy, versioning, lifecycle, notification, encryption, quota, and object-lock settings while serving normal S3 traffic. Reading those files from .minio.sys on every request would be too expensive, so each node keeps bucket metadata in memory in globalBucketMetadataSys.

That creates the synchronization problem this subsection explains: if an admin request changes photos/versioning.xml on one node, every other node must stop using its old cached version and reload the updated bucket metadata.

Buckit handles that with an explicit in-cluster cache refresh: the node that accepts the change persists it, updates its own in-memory cache, then tells all other nodes in the same distributed cluster to reload the same bucket metadata from .minio.sys. This fan-out is per remote server host from the startup endpoint topology, not per disk, set, or pool.

sequenceDiagram
    actor Caller as Admin/S3 handler
    participant BMS as globalBucketMetadataSys
    participant Disk as .minio.sys object layer
    participant Notify as globalNotificationSys
    participant PeerClient as peerRESTClient
    participant PeerServer as peerRESTServer
    participant PeerBMS as peer globalBucketMetadataSys

    Caller->>BMS: Update/Delete(bucket, configFile, data)
    BMS->>Disk: save .minio.sys/buckets/<bucket>/.metadata.bin
    BMS->>BMS: Set(bucket, metadata)
    BMS->>Notify: LoadBucketMetadata(bucket)
    Notify->>PeerClient: fan out to all other nodes in the same cluster
    PeerClient->>PeerServer: loadBucketMetadataRPC over grid
    PeerServer->>Disk: loadBucketMetadata(bucket)
    PeerServer->>PeerBMS: Set(bucket, metadata)
    PeerServer->>PeerServer: refresh notification rules and bucket targets
Step Code What happens
Bucket config write cmd/bucket-metadata-sys.go:Update, Delete, updateAndParse Loads current bucket metadata, changes one config file such as policy/versioning/notification, then calls save.
Local commit cmd/bucket-metadata-sys.go:save Persists metadata through the object layer, updates the local globalBucketMetadataSys cache, then calls globalNotificationSys.LoadBucketMetadata.
Cluster fan-out cmd/notification.go:LoadBucketMetadata Iterates sys.peerClients and sends a reload request to each remote client. peerClients excludes the local node.
RPC client cmd/peer-rest-client.go:LoadBucketMetadata Calls the peer loadBucketMetadataRPC handler over the grid connection.
RPC server cmd/peer-rest-server.go:LoadBucketMetadataHandler Reloads bucket metadata from disk, updates that node's in-memory cache, and refreshes event notification rules / bucket targets if those configs are present.
Delete case cmd/notification.go:DeleteBucketMetadata, cmd/peer-rest-server.go:DeleteBucketMetadataHandler Removes the bucket metadata entry from peer memory when bucket metadata is deleted.
Backstop refresh cmd/bucket-metadata-sys.go:refreshBucketsMetadataLoop Periodically reloads bucket metadata, so a missed notification is eventually corrected.

This is cluster-local cache synchronization. Cross-site bucket metadata replication is a separate site-replication path; it is not the same thing as globalNotificationSys.LoadBucketMetadata.


Glossary

Term Meaning
xl.meta Per-object metadata file stored on every disk. It records versions, DataDir, erasure layout, checksums, user metadata, and delete markers.
DataDir UUID-named directory under bucket/object/ that holds shard files for one object version. New PUTs get new DataDirs.
part.1 Shard file for object part 1 on one disk. Each disk has its own part.1 for the same object part. Multipart uploads also create part.2, part.3, etc.
Shard One per-disk shard file for one object part, such as part.1 on disk 7.
Stripe One row of blocks processed together during erasure coding. PUT appends each stripe's data/parity blocks to the shard files.
Block One fixed-size cell inside a stripe. Buckit stores a bitrot hash per block.
EC:N Storage-class shorthand for N parity shards. EC:4 means 4 parity shards.
ErasureIndex The shard number for this disk in a version's erasure layout. It tells a disk which shard it stores.
VersionID UUID identifying a specific object version. Required to read or delete a non-latest version explicitly.
Delete marker Metadata-only version that hides older versions from default GET/List calls in a versioned bucket.
Read quorum Minimum agreement/read availability needed to serve a read. Usually M for a live object.
Write quorum Minimum successful disk writes needed before a PUT is committed. Usually M; raised to M+1 when M == N.
Delete quorum Majority quorum (D/2 + 1) used for delete metadata updates.
RenameData Storage operation that commits PUT output by moving temp data into the final DataDir and replacing xl.meta.
.minio.sys Internal metadata namespace used for format, config, IAM, bucket metadata, multipart state, and operational state.
format.json Per-disk erasure format file under .minio.sys; records disk identity and erasure-set membership.
config.json Server-wide configuration file under .minio.sys/config/.
globalBucketMetadataSys In-memory cache of bucket metadata on each node. It is loaded at startup and refreshed when bucket config changes.
globalNotificationSys Peer notification fan-out used for cluster-local metadata/cache refresh RPCs.
Endpoint One configured disk path or disk URL. It records which pool/set/disk slot it belongs to and whether it is local or remote.
EndpointServerPools In-memory topology of all configured pools and disk endpoints.
GridHost() Endpoint method that returns the peer host string used to find the grid connection for a remote endpoint.
storageRESTClient Remote implementation of StorageAPI; forwards disk operations to another node.
xlStorage Local implementation of StorageAPI; reads/writes directly to a mounted disk path.
grid Multiplexed WebSocket transport lib used for small metadata/control RPCs between nodes.

How to render

These diagrams are Mermaid. They render automatically in:

  • GitHub (preview this .md in the repo UI)
  • VS Code with the "Markdown Preview Mermaid Support" extension
  • Any Mermaid live editor (https://mermaid.live) — paste a single block at a time