| 1 | # 0005 — One commit point per push, and it is a compare-and-swap |
| 2 | |
| 3 | **Status:** adopted |
| 4 | |
| 5 | ## Decision |
| 6 | |
| 7 | A push has exactly one moment at which it becomes real: the conditional `PUT` |
| 8 | of `repos/<repoId>/index`. Before it, nothing happened. After it, everything |
| 9 | happened. |
| 10 | |
| 11 | ``` |
| 12 | 1. parse the receive-pack body → transitions + packfile |
| 13 | 2. PUT entries/<ulid>.pack → durable, unconditional, idempotent |
| 14 | 3. read index (etag) → current refs |
| 15 | 4. validate every oldOid against refs → reject non-fast-forward |
| 16 | 5. PUT index If-Match: <etag> → ← THE COMMIT |
| 17 | 6. update the local cache, ack the client |
| 18 | ``` |
| 19 | |
| 20 | ## Why this works without a transaction manager |
| 21 | |
| 22 | Two kinds of data, only one of which needs atomicity: |
| 23 | |
| 24 | - **Objects are content-addressed and immutable.** Uploading one twice is |
| 25 | harmless. Uploading one nobody references is garbage, not corruption. No |
| 26 | coordination required. |
| 27 | - **Refs are mutable and are the only thing that must be atomic.** |
| 28 | |
| 29 | So a push writes a lot of bytes with no coordination at all, then flips one |
| 30 | pointer atomically. There is no partial state to reason about. |
| 31 | |
| 32 | A multi-ref push is a single index write, so it is all-or-nothing — stronger |
| 33 | than local git, which is only atomic under `--atomic`. |
| 34 | |
| 35 | ## Failure taxonomy |
| 36 | |
| 37 | | Failure point | Result | |
| 38 | |---|---| |
| 39 | | Before the entry upload | Nothing changed | |
| 40 | | After the entry, before the CAS | Nothing changed; one orphaned entry | |
| 41 | | CAS returns 412 / 409 | Retry (see [0007]( |
| 42 | | CAS times out, outcome unknown | Resolved by ULID read-back | |
| 43 | | After CAS 200 | Committed; client retries are idempotent | |
| 44 | |
| 45 | ## S3 specifics that are easy to get wrong |
| 46 | |
| 47 | - **`ETag` |
| 48 | multipart objects it is not a content hash — never parse or compare it. |
| 49 | - **The index must never go multipart**, or the ETag stops being usable for |
| 50 | `If-Match`. It is a small `Buffer`, so it never will. |
| 51 | - **409 is not a failure.** |
| 52 | writes collided and the caller should retry. `412 PreconditionFailed` means |
| 53 | the race was genuinely lost. Catching only 412 produces 500s under |
| 54 | concurrency that look like storage flakiness. |
| 55 | - **`IfNoneMatch: '*'` |
| 56 | pushes to a new repository both observe "no index". |
| 57 | - **There is no fsync here.** The HTTP 200 |
| 58 | |
| 59 | ## Portability caveat |
| 60 | |
| 61 | `S3_ENDPOINT` is configurable. R2 supports both `If-Match` and `If-None-Match |
| 62 | on `PUT`. Older MinIO supports only `If-None-Match`. If the deployment target |
| 63 | lacks `If-Match`, the fallback is to make the index immutable too |
| 64 | (`index/<seq>` written with `If-None-Match`), which is the more portable shape. |
| 65 | Verify against the real endpoint before depending on `If-Match`. |