Skip to content

Make idle pantsd lease extension cheap - #23739

Merged
benjyw merged 3 commits into
pantsbuild:mainfrom
frekw:fix/skip-fresh-lease-writes
Nov 3, 2026
Merged

benjyw merged 3 commits into
pantsbuild:mainfrom
frekw:fix/skip-fresh-lease-writes

Conversation

@frekw

@frekw frekw commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Closes #23694.

Every 72 seconds, pantsd re-expands every digest its graph references and rewrites each one's lease, even when no command has run. With several daemons sharing one store, that kept disk IO busy on an otherwise idle machine.

This makes two changes:

  • ShardedLmdb::lease and ShardedFSDB::lease skip the write when the current lease has more than half the lease time left.
  • Store::lease_all_recursively skips a digest, and everything below it, if it leased that digest within the last quarter of the lease time.

Together they guarantee a skipped digest is renewed with about 30 minutes to spare.

frekw added 3 commits October 27, 2026 23:43
pantsd's `StoreGCService` re-leases every digest its graph references
every `LOCAL_STORE_LEASE_TIME_SECS / 100` (72s), and each pass wrote a
new expiry for every entry: one LMDB write transaction per digest, or an
`mtime` update per large file, whether or not anything had run. LMDB
copies each touched page on write, so an idle daemon with a large graph
keeps rewriting a substantial part of the store. On a copy-on-write
filesystem such as btrfs every rewritten page also costs a new extent
and checksum, and several idle daemons sharing one store (one per git
worktree) were enough to keep IO pressure above 40% on an otherwise idle
machine.

`ShardedLmdb::lease` now reads the existing lease first and skips the
write when it still has more than half of the lease time left; the
FSDB does the same with the file's mtime. With the 2h lease time an
entry is now rewritten about once an hour instead of every pass, and is
still renewed at least an hour before it could expire, so garbage
collection is unaffected.

This addresses the write cost reported in pantsbuild#23694; the CPU cost of
walking the graph on every pass, also described there, is unchanged.
… time

Every 72s, pantsd's `StoreGCService` passes every digest its graph
references to `Store::lease_all_recursively`, which looks up each
digest's entry type in LMDB (and `stat`s the FSDB), expands every
directory, and leases everything it finds. That work is the same on
every pass of an idle daemon and grows with everything the daemon has
loaded, so a long-lived daemon spends a steady share of a core on it
(pantsbuild#23694).

`lease_all_recursively` now remembers when it leased each digest it was
given, and skips a digest (and everything below it) for a quarter of the
lease time after that. This is safe because of the previous commit: a
lease pass leaves every lease it touches with at least half of the lease
time left, so a skipped digest still has at least a quarter left (30
minutes with the default 2h) when it is next renewed.

Digests the graph newly references are not in the memo, so they are
still leased on the next pass. That matters because storing content
that already exists does not renew its lease. The pass interval is
unchanged, and the walk over the graph to collect digests still runs on
every pass; it is in memory.
@tdyas
tdyas requested a review from benjyw November 2, 2026 05:02

@benjyw benjyw 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.

Looks good to me Thanks!

@benjyw

benjyw commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

One request: Please make the PR description more succinct, and move any detail (including the LLM disclosure) to a comment. The PR description becomes the commit description, and we want those to be short-ish. Once done I will merge.

@frekw

frekw commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Moving the detail out of the description, as requested.

Why idle passes were expensive

StoreGCService extends leases 72 seconds after its previous pass finishes (LOCAL_STORE_LEASE_TIME_SECS / 100). Each pass does three things.

  1. Scheduler::all_digests collects every digest the graph references.
  2. Store::expand_local_digests looks up each digest's entry type in LMDB, stats the FSDB, and expands every directory.
  3. ByteStore::lease_all writes a new expiry for every entry it found. That is one LMDB write transaction per digest, or one mtime update per large file.

The cost grows with everything the daemon has loaded, and the writes hurt most. Every pass stores a new timestamp, so it dirties every lease page. LMDB copies each page it writes, and on a copy-on-write filesystem like btrfs each rewritten page becomes a new extent with new checksums. I run one daemon per git worktree, all sharing ~/.cache/pants/lmdb_store. Three idle daemons kept my machine at about 45% "full" IO pressure in /proc/pressure/io with no Pants command running.

Why skipping is safe

After any lease call, the entry has at least lease_time / 2 left, whether or not it was rewritten. The memo in lease_all_recursively then skips that digest for at most lease_time / 4 plus the wait for the next pass. With the default 2h lease, it gets renewed with about 30 minutes to spare.

Storing content that already exists doesn't renew its lease, because ShardedLmdb::store and store_bytes_batch return early on KeyExist. Once the graph references such an entry, the periodic pass is the only thing protecting it. That still works. A digest the graph just started referencing isn't in the memo yet, so the next pass leases it exactly as before.

Only pantsd's lease extension calls lease and lease_all_recursively. No other caller depends on a lease call resetting the full lease time.

What I didn't do

#23694 suggests renewing less often, for example every lease_time / 2, and skipping passes when no session has run. Both are cheap, and both widen the window in which an entry the graph just started referencing has no lease. With several daemons sharing a store, another daemon's GC can delete the entry inside that window. I kept the 72s pass and made it cheap instead, so the window stays the size it is today.

Each pass still walks the graph with visit_live to collect digests. That walk is in memory and accounts for the 1s-or-less passes below.

Measurement

I built Pants 2.33.0a0 from source twice, once as released and once with this PR cherry-picked. For each build I started a fresh daemon in our monorepo and ran the same workload: check on two Go apps, then test on one plugin's Python tests. Then I left the daemon idle for 12 minutes. CPU and bytes written come from the daemon's own /proc/<pid>/stat and /proc/<pid>/io. Pass durations come from the Extending leases and Done extending leases log lines. The machine runs Linux 6.18 on btrfs and NVMe.

12 idle minutes 2.33.0a0 This PR
CPU 101.8s (13.9% of a core) 26.4s (3.6% of a core)
CPU spent extending leases ~85s ~8s
Bytes written 26.7 GB (36.5 MB/s) 0.3 MB
Lease passes 5, taking 38s, 35s, 40s, 44s and 109s 9, the first 8.3s and the rest 1s or less

The "CPU spent extending leases" row subtracts an idle floor of about 2.4% of a core. I measured that floor between passes. It was the same in both builds, so it comes from pantsd's other idle work.

With this PR, the first pass leases everything in 8.3s but writes nothing, since the workload had just created those leases. Every later pass finds its digests in the memo. The 12-minute window ends before the 30-minute re-expansion. I expect that pass to cost about as much as the first one, and it only rewrites leases with less than an hour left.

LLM disclosure

I wrote this PR with Claude (via Claude Code) and reviewed the change myself.

@frekw

frekw commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

@benjyw ok, I've shortened the description and moved the original to a comment. Hope it looks better! 🙏

@benjyw
benjyw merged commit 710fa92 into pantsbuild:main Nov 3, 2026
53 of 56 checks passed
@benjyw

benjyw commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Thanks for the contribution!

jgranstrom added a commit that referenced this pull request Nov 5, 2026
`ShardedLmdb::store_bytes_batch` skips writing leases for content
already stored. `CreateDigest` stores the same files on every run, so
they keep their lease of their first ever write. The lease extension
does not cover them, as they are rule results not a node output. GC can
delete them while being actively in-use.

With this PR
- `Store::store_file_bytes_batch`, which only `CreateDigest` calls, now
renews the lease of an already stored file under 512 KiB when it is
missing or has at most half its time left, in the write transaction the
batch already opens. Other writes are unchanged.
- `ShardedLmdb::lease_is_stale` decides staleness in the caller's
transaction. `lease_outlasts` (#23739) now calls it, and both take the
half-lease threshold from `renew_before`.

On an 18,000-process build that creates 5,758 files per run, this costs
about 9 ms per run, and 0.08 s on a run after an idle hour.

This does not cover files of 512 KiB or more, they are leased by mtime.
And CreateDigest results that are kept memoized until they become old
enough to become GCed (generally quite a long time since they now get
leased when the rule runs if old).

Disclosure: Implemented and verified with Claude with human direction
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.

Idle pantsd spends significant CPU re-extending store leases every 72s

2 participants