Skip to content

gix-ref: fast-path packed-only ref resolution in transactions#2705

Open
Nik B (nikicat) wants to merge 3 commits into
GitoxideLabs:mainfrom
nikicat:pr-ref-tx-packed-resolution
Open

gix-ref: fast-path packed-only ref resolution in transactions#2705
Nik B (nikicat) wants to merge 3 commits into
GitoxideLabs:mainfrom
nikicat:pr-ref-tx-packed-resolution

Conversation

@nikicat

Copy link
Copy Markdown
Contributor

When verifying each edit's precondition, lock_ref_and_apply_change resolves the current value loose-first: ref_contents() (an open() syscall) with a packed fallback. On a freshly-packed store that probe is a futile ENOENT per ref, and a fetch on a ~155k-ref mirror does one per mapping — it dominated the wall time of applying the ref transaction.

prepare_inner now snapshots the packed buffer and the set of loose ref names once; names with no loose file resolve straight from packed-refs inside read_existing_ref, skipping the open(). Loose-over-packed precedence is preserved — any loose ref is in the snapshot and takes the original full lookup — and everything falls back to the full path when the snapshot is unavailable. This mirrors the fast paths already proposed for negotiate/update_refs (#2704).

A ref tx resolve detail span records the resolution time (find_ms); it compiles out unless the tracing-detail feature is enabled.

On the AUR mirror this roughly halves the post-fetch ref-update phase (~770ms → ~400ms, resolution ~640ms → ~240ms).

Tests: resolving_existing_values_reads_packed_only_refs_and_honors_loose_precedence asserts both the packed-only fast path's resolved value and loose-over-packed precedence, in the pass and fail directions.

Independent of #2704 and #2608 — applies directly to main.

🤖 Generated with Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2c445bda1c

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".

Comment on lines +33 to +34
if !loose.contains(name) {
return packed.try_find(name).map(|opt| opt.map(Into::into)).map_err(Error::from);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the CAS read under the ref lock

When a ref is absent from the precomputed loose_names, this branch returns the packed value without checking the loose file after the per-ref lock is acquired. If another process creates or updates the loose shadow for a packed ref after the snapshot but before this transaction obtains that ref's lock, the CAS check is performed against the stale packed value and can overwrite the concurrent loose update; the previous ref_contents() read happened after locking and avoided this race.

Useful? React with 👍 / 👎.

Comment on lines +407 to +409
let loose_names: Option<std::collections::HashSet<FullName>> = packed_buffer
.and(store.loose_iter().ok())
.map(|iter| iter.filter_map(Result::ok).map(|r| r.name).collect());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude pseudo refs from the packed-only shortcut

store.loose_iter() only walks the refs/ hierarchy, while pseudo refs such as HEAD are exposed by iter_pseudo() instead. In any mixed transaction that also creates a packed_buffer, an existing loose HEAD is therefore missing from loose_names, so the new fast path asks packed.try_find("HEAD") and gets None; this makes PreviousValue::MustExist/MustExistAndMatch fail for an existing HEAD and lets MustNotExist incorrectly pass.

Useful? React with 👍 / 👎.

@nikicat
Nik B (nikicat) force-pushed the pr-ref-tx-packed-resolution branch from 2c445bd to ebd6541 Compare July 9, 2026 22:13
@nikicat

Copy link
Copy Markdown
Contributor Author

Both review findings are addressed in the force-push (2c445bda1ebd654127):

  • CAS read under the lock: fixed structurally. prepare_inner now acquires all per-ref locks first, and only then takes the loose-name snapshot and resolves each edit's current value. A cooperative writer must hold the same per-ref lock to change a ref, so once every lock is held the snapshot cannot go stale for any edited ref — each CAS check again compares against a value that cannot change concurrently. lock_ref_and_apply_change is split into acquire_ref_lock / apply_change with a HeldLock enum carrying the Marker/File lock between the two passes.
  • Pseudo refs: the packed-only fast path is now guarded to refs/-prefixed names, so an existing loose HEAD always takes the full lookup. A new test asserts a large transaction still sees HEAD (MustExistAndMatch passes, MustNotExist fails) — it fails without the guard.

I also applied the same trade-off gate raised on #2704: the loose-name snapshot is only built for transactions with at least 256 edits, so a small transaction against a store with many loose refs keeps the old O(edits) probing. The existing fast-path tests are padded past the threshold with filler creations so they still exercise it.

All 181 gix-ref tests pass with sha1 and sha256 fixtures, clippy is clean.

When verifying each edit's precondition, the transaction resolved the
current value loose-first: `ref_contents()` (an `open()` syscall) then a
packed fallback. On a freshly-packed store that probe is a futile ENOENT
per ref, and a fetch on a ~155k-ref mirror does one per mapping — it
dominated the wall time of applying the ref transaction.

Acquire all per-ref locks first, then snapshot the packed buffer and the
set of loose ref names once; names under `refs/` with no loose file
resolve straight from packed-refs, skipping the `open()`. Taking the
snapshot after every lock is held is what makes it sound: a cooperative
writer must hold the same lock to change a ref, so the snapshot cannot go
stale for any edited ref, and each CAS check still compares against a
value that cannot change concurrently. Loose-over-packed precedence is
preserved: any loose ref is in the snapshot and takes the original full
lookup. Pseudo refs like `HEAD` live in the `.git` root rather than the
`refs/` hierarchy the snapshot enumerates, so they always take the full
lookup. Falls back to the full path when the snapshot is unavailable.
This mirrors the existing negotiate / update_refs fast paths.

The snapshot is only built for transactions with at least 256 edits:
enumerating loose names walks the entire `refs/` tree, so a small
transaction against a store with many loose refs would otherwise trade
O(edits) probes for an O(all loose refs) scan.

A `ref tx resolve` detail span records the resolution time (`find_ms`),
matching the split-phase spans on the other two ref passes; it compiles
out unless the `tracing-detail` feature is enabled.

On the AUR mirror this roughly halved the post-fetch ref-update phase
(~770ms -> ~400ms, resolution ~640ms -> ~240ms).

Adds tests asserting the packed-only fast path's resolved value and
loose-over-packed precedence in the pass and fail directions (padded
past the snapshot threshold with filler creations), plus one asserting
an existing `HEAD` is still seen by large transactions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The two-pass rework that locks all edits before resolving their current
values (the CAS-race fix) kept each update's `gix_lock::File` open
until the second pass wrote its content, so a preparing transaction held
one open file descriptor per edit. A fetch that updates thousands of
refs — a large mirror after hours of churn — exceeded the default
`ulimit -n 1024` and failed with EMFILE around the ~1000th lock.

The lock file's content is the edit's *new* target, which is known
before any resolution. Write it when the lock is acquired and close the
handle immediately, keeping only a `gix_lock::Marker`: the lock file
stays on disk for the whole preparation, so the lock-all-before-snapshot
ordering (and with it the race protection) is unchanged, while the
number of open file descriptors drops to O(1) instead of O(edits).

The regression test lowers `RLIMIT_NOFILE` and prepares a 300-edit
transaction; it fails with EMFILE before this change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@nikicat

Copy link
Copy Markdown
Contributor Author

Pushed 66fdea3, fixing an fd-exhaustion regression the two-pass prepare introduced: pass 1 held every update's lock File open until pass 2 wrote its content, so a transaction briefly held one open descriptor per edit. A real fetch of a few thousand changed refs at the default ulimit -n 1024 failed with EMFILE (Too many open files) around the ~1000th lock.

Since the lock file's content is the edit's new target — known before any resolution — it is now written at acquisition time and the handle closed immediately, keeping only a gix_lock::Marker. The lock file itself stays on disk for the whole preparation, so the lock-all-before-snapshot ordering this PR exists for is unchanged; open descriptors drop to O(1) instead of O(edits).

Regression test: large_transactions_hold_a_constant_number_of_file_descriptors lowers RLIMIT_NOFILE to 96 and prepares a 300-edit transaction — it fails with EMFILE without the fix.

Codex (@codex) review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 66fdea31e4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".

let (_dir, store) = empty_store()?;
let edits: Vec<RefEdit> = (0..300).map(|i| create_at(&format!("refs/heads/fd-{i:03}"))).collect();

let _guard = SoftLimitGuard::lower_to(96)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Isolate the process-wide file descriptor limit change

When this test is run under the default cargo test harness, tests in the same integration-test binary execute concurrently in threads, but setrlimit(RLIMIT_NOFILE) changes the limit for the entire process. During the prepare/commit window for these 300 refs, any sibling test that opens files can unexpectedly fail with EMFILE, making plain cargo test runs flaky; run this check in a subprocess or otherwise serialize it against the rest of the binary before lowering the limit.

Useful? React with 👍 / 👎.

let loose_names: Option<std::collections::HashSet<FullName>> = packed_buffer
.filter(|_| updates.len() >= MIN_EDITS_FOR_LOOSE_NAME_SNAPSHOT)
.and(store.loose_iter().ok())
.map(|iter| iter.filter_map(Result::ok).map(|r| r.name).collect());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't drop loose-ref I/O errors from the snapshot

For large transactions that use the packed fast path, this silently omits any loose ref whose iterator item is an I/O/traversal error. If the edited ref is present as a loose file but cannot be read, the subsequent read_existing_ref() sees it as absent from loose_names and resolves from packed-refs instead of surfacing the read error as the old per-ref lookup did, so a CAS check can be made against a stale packed value and overwrite an unreadable loose ref.

Useful? React with 👍 / 👎.

The loose-name snapshot that large transactions use to fast-path
packed-only resolution dropped error items from the loose-ref iterator
(`filter_map(Result::ok)`). A loose ref that exists but cannot be
enumerated — an unreadable file, or a directory-walk failure hiding a
whole subtree — was thereby missing from the snapshot and treated as
packed-only: its CAS check compared against the stale packed value and
could pass, silently overwriting the loose ref that the old per-edit
lookup would have refused to touch by surfacing the I/O error.

Any enumeration error now disables the snapshot for the whole
transaction, so every edit takes the plain per-edit lookup and I/O
errors surface for exactly the refs an edit touches, as before this
PR. Only stores with unreadable loose refs lose the fast path.

The regression test makes a loose ref unreadable (mode 000) and
asserts a 257-edit transaction fails with the permission error
instead of matching the ref's stale packed value.

Also isolate the fd-limit regression test's `RLIMIT_NOFILE` change:
lowering the limit affects the whole process, and under plain
`cargo test` sibling tests run as threads of that process and could
spuriously hit EMFILE. The test now re-executes itself as a child
process (marked by an environment variable) and only the disposable
child lowers the limit; the parent asserts the child ran exactly this
test and passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@nikicat

Copy link
Copy Markdown
Contributor Author

Both round-2 findings are addressed in 6968545:

  • Dropped loose-ref I/O errors: the snapshot no longer discards error items — any enumeration error (unreadable file, or a walk failure that could hide a whole subtree) disables the snapshot for the transaction, so every edit takes the per-edit lookup and I/O errors surface exactly where they did before this PR. Worth noting the scope: parse failures were never a behavior change, since the per-edit path already maps ReferenceDecode to "absent" and falls through to packed; the regression was confined to genuine I/O errors. A new test makes a loose ref unreadable (mode 000) and asserts a large transaction fails with the permission error instead of CAS-matching the ref's stale packed value — it fails without the fix.
  • Process-wide RLIMIT_NOFILE in the fd test: the test now re-executes itself as a child process marked by an environment variable, and only the disposable child lowers the limit — sibling tests under plain cargo test never see it. The parent asserts the child ran exactly this test and passed (guarding against a silent 0-tests-matched pass).

All 183 gix-ref tests pass with sha1 and sha256 fixtures under nextest, both changed tests also pass under plain cargo test, and clippy is clean.

Codex (@codex) review

@chatgpt-codex-connector

Copy link
Copy Markdown
Contributor

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 69685456c0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".

// so pseudo refs like `HEAD` (which live in the `.git` root and are
// never packed) must always take the full lookup.
if let (Some(loose), Some(packed)) = (loose_names, packed) {
if name.as_bstr().starts_with(b"refs/") && !loose.contains(name) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude worktree-private refs from packed-only shortcut

When a large transaction edits an existing loose worktree-private ref under refs/ (for example refs/worktree/foo, refs/bisect/foo, or refs/rewritten/foo), this condition can treat it as packed-only because store.loose_iter() is backed by LooseThenPacked::next, which skips names whose category is_worktree_private() in gix-ref/src/store/file/overlay_iter.rs. These categories are also not written to packed-refs by possibly_adjust_name_for_prefixes, so PreviousValue::MustExist* sees None and MustNotExist can incorrectly pass and overwrite the existing loose ref; route worktree-private categories through the full ref_contents() lookup like pseudo refs.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this one holds up — the is_worktree_private() skip in LooseThenPacked::next (advance_to_non_private) is applied only to the common-dir iterator; the git-dir iterator is peeked unfiltered on every path through peek_loose. And worktree-private categories are exactly the ones that resolve to git_dir: base_dir_and_rela_path maps Bisect | Rewritten | WorktreePrivate to (self.git_dir, name). So an existing loose refs/bisect/foo lives in the directory the unfiltered walk enumerates, lands in loose_names, and takes the full ref_contents() lookup — I verified with a probe test that a plain store with a loose refs/bisect/good yields it from store.loose_iter().

The linked-worktree case works out the same way: there git_dir is the worktree-private directory, so its refs/bisect/*/refs/worktree/*/refs/rewritten/* are yielded by the unfiltered walk. What advance_to_non_private skips are the main worktree's private refs in the common dir — which aren't addressable as refs/bisect/* from that store to begin with. And when a worktree-private ref doesn't exist loose, both paths converge: the fast path asks packed.try_find(name), and the slow path falls through to the same packed.try_find(name) after the loose miss — identical None, since these categories are never written to packed-refs (possibly_adjust_name_for_prefixes). Prefixed spellings like worktrees/X/refs/… or main-worktree/refs/… don't start with refs/, so the existing guard already routes them to the full lookup.

@nikicat

Copy link
Copy Markdown
Contributor Author

Codex (@codex) review

@chatgpt-codex-connector

Copy link
Copy Markdown
Contributor

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant