Skip to content

content-sqlite: add group commit to improve performance of bursty stores - #7702

Open
grondo wants to merge 6 commits into
flux-framework:masterfrom
grondo:sqlite-group-commit
Open

content-sqlite: add group commit to improve performance of bursty stores#7702
grondo wants to merge 6 commits into
flux-framework:masterfrom
grondo:sqlite-group-commit

Conversation

@grondo

@grondo grondo commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Problem: content-sqlite commits every blob in its own implicit transaction. Storing a large number of blobs in a burst — for example the offline flux restore of a restart dump with many inactive jobs — pays per-commit WAL overhead once per blob, which dominates the operation.

This PR adds opportunistic group commit to content-sqlite. When enabled, store INSERTs are grouped into a single explicit transaction, and a store request is not answered until that transaction is fully committed — preserving the guarantee that a store response implies the blob is durable.

A batch is committed as soon as no further store request is queued behind the current one, detected via flux_pollevents(): FLUX_POLLIN on the module's handle means another request is already waiting, so the burst has not drained yet and the batch keeps growing. This makes grouping of INSERTs self-tuning — a sustained burst piles into a large transaction, while an isolated store commits immediately with no added latency (no delay is ever introduced to wait for a batch to fill).

Two limits are introduced to ensure an ever growing batch has an explicit ceiling:

  • A batch-timeout (default 0.1s) puts an upper limit on the latency and growth of a batch
  • An internal BATCH_COUNT_MAX sets a hard limit of the total number of stores in a batch

The 0.1s default for batch-timeout shows no impact on job throughput, but gives a large return for the flux-restore phase (with some caveats, see below)

Results:

These results are for a restart dump with 64K inactive jobs → 587,272 objects. batch stats are stores coalesced per
committed transaction, reported by flux module stats content-sqlite.

restore config time commits mean/txn max/txn
group commit off (baseline) 50.3s
default (0.1s, cached path) 41.3s 3,773 156 256
default + --no-cache 43.2s 10,112 64 256
--no-cache --maxreqs=100000 17.8s 131 4,972 20,931

As shown above, simply enabling the group commit default already collapses 587K commits into a few thousand transactions on the normal path. On the cached path the content cache's flush window caps concurrency (max 256/txn). Bypassing the cache and using a large number of outstanding requests lets the backing store see the full burst and coalesce far more aggressively, improving the restore time by ~3x.

Therefore, in this PR, rc1 is adjusted to run flux restore with --no-cache --maxreqs=100000 to get the full speedup.

Note: This PR is probably just a nice-to-have, as we already introduced a 1.5-2x speedup with the sliding store window on flux restore in #7701. Perturbing the content-sqlite module for gains only in flux restore is probably slightly questionable, and I'm unsure if the grouping will have any measurable effects outside of this one use case (maybe noticed a benefit in the throughput test without real execution). So I won't be surprised if this one is met with some skepticism, especially if Flux won't need to restore on every restart with online GC.

@grondo
grondo force-pushed the sqlite-group-commit branch from e69ce2b to 5bceeb6 Compare July 2, 2026 02:38
@garlick

garlick commented Jul 2, 2026

Copy link
Copy Markdown
Member

This seems like a nice improvement. Would it be acceptable to defer consideration of this until after we get the next tag out? Assuming we can land online GC, if something goes wrong it may be better to have only one change on the store path to pin it on :-)

@grondo

grondo commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Good idea!

@grondo
grondo force-pushed the sqlite-group-commit branch from 5bceeb6 to 32787e5 Compare July 8, 2026 21:31
@garlick

garlick commented Jul 8, 2026

Copy link
Copy Markdown
Member

On a hunch I tried running flux python src/test/soak-gc.py and immediately hit:

2026-07-08T15:30:35.464114-07:00 content-sqlite.err[0]: mark: BEGIN: cannot start a transaction within a transaction(1)

I think the pending transaction needs to be flushed on entry to mark and sweep (new since this PR was created).

@grondo

grondo commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Ooh. Good catch! I just rebased and fixed conflicts and didn't examine the branch for other needed updates.

Let me ask: should we just hold off on this PR for now? It really only helps on the restore case I think.

@garlick

garlick commented Jul 8, 2026

Copy link
Copy Markdown
Member

I guess it's not urgent, but it does seem like a good change. Maybe we can return to it later in the new release cycle?

BTW, the error reporting was not awesome in that flux-gc failure so I'll submit a PR to return sqlite errors back to the RPC client in mark/sweep.

@grondo

grondo commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Well, FWIW, I added the missing batch_commit() calls to mark and sweep, and a new test that exercises the new call sites. Also ran gc-soak.py several times with no errors.

@grondo

grondo commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

group commit is causing an intermittent hang in t0090-content-enospc.t. Looking into that now.

@grondo
grondo force-pushed the sqlite-group-commit branch from 64cf1d9 to 09d97cf Compare July 9, 2026 22:45
@grondo

grondo commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

group commit is causing an intermittent hang in t0090-content-enospc.t. Looking into that now.

Ok, I think I found the cause of this, but someone please check carefully.

I think the group commit code made it more likely than on current master that a sql commit would return ENOSPC, but a subsequent, smaller commit would not. Therefore the cache could clear a pending flush_errno with dirty entries still in the cache from the previous failure. I guess the dirty entries are failed, not retried, so the flush then hangs indefinitely.

The fix (as suggested by Claude) is to only reset flush_errno on success if there are no dirty entries.

I put this commit first in the patch stack to avoid breaking the tests between commits.

@grondo

grondo commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

New hang in the kvs put --sync path. 😬

grondo added 6 commits July 10, 2026 07:28
Problem: The content flush error handling assumes each blob commits
to the backing store independently, so once stores begin failing they
keep failing until recovery. A backing store that batches commits
can break this: a batch may fail on `ENOSPC`, then the rollback
frees space so a later, smaller store succeeds while earlier failed
entries remain dirty.  That fools two flush paths into hanging: a
store success clears the sticky `flush_errno` so a new content.flush
misses the error, and a flush already parked on outstanding stores
is never answered when the last store succeeds with dirty entries
left over.

Clear `flush_errno` only when the cache is fully clean, answer parked
flush requests once no stores remain in progress, and report the
recorded backing-store errno so callers see the true cause.

Assisted-by: Claude:Opus-4.8
Problem: content-sqlite commits every blob in its own implicit
transaction. Storing a large number of blobs in a burst (e.g.
offline flux-restore of an instance with many inactive jobs) pays
per-commit WAL overhead once per blob, which dominates the operation.

Add opportunistic group commit. When batch-timeout is nonzero, store
inserts are grouped into an explicit transaction. Store requests are
not answered until that transaction commits to preserve the guarantee
that a store response implies the blob is durable.

Batches are committed as soon as no further store request is queued
behind the current one by checking flux_pollevents(), so bursts
accumulate a large transaction while isolated stores commit
immediately without added latency. A batch-timeout age ceiling and
an internal count cap bound the size of a batch that would otherwise
never drain.

The default batch-timeout is set to 0.1s, which had no measurable
impact on job throughput workloads, where stores are likely not
bursty enough to accumulate.

Assisted-by: Claude:Opus-4.8
Problem: There is no way to observe whether group commit is
engaging or how effectively it batches stores.

Report the current batch_timeout in the stats-get config object,
and add a batch_size tstat recorded at each committed transaction.
The tstat's count is the number of committed transactions and its
mean/max are the stores per transaction (min 1, or count 0 when
group commit is disabled).

Assisted-by: Claude:Opus-4.8
Problem: There is no test coverage for the new content-sqlite group
commit or the reported group commit stats.

Add tests to t0012-content-sqlite.t.

Assisted-by: Claude:Opus-4.8
Problem: The startup restore of a content dump goes through the content
cache, which caps concurrency to the backing store at its flush batch
limit and buffers every restored blob in rank 0 memory before draining.
Restoring a large dump is therefore much slower than it needs to be.

Restore with --no-cache and a deep request window (--maxreqs=100000) so
stores go directly to the backing store and arrive as a large burst.
Combined with content-sqlite group commit, this collapses hundreds
of thousands of per-blob commits into a small number of transactions.
These are backing-module-agnostic restore flags, so no module guard is
needed.

Assisted-by: Claude:Opus-4.8
Problem: The content-sqlite mark and sweep RPCs each open their own
transaction and must first flush any open group-commit batch (SQLite
has no nested transactions, and the GC pass must see committed table
state), but there is no test coverage for this interaction. The
one-shot rpc tool cannot reproduce it: each request drains the
module recv queue before the next arrives, so a batch is never open
when mark/sweep runs.

Add t/kvs/content-gc-race, which pipelines a burst of stores followed
immediately by a mark or sweep on a single handle, so the GC RPC lands
while the store batch is still open. Use timeouts so a reintroduced
bug fails promptly rather than hanging CI. Drive it from two new
cases in t0043-content-sqlite-gc.t.

Assisted-by: Claude:Opus-4.8
@grondo
grondo force-pushed the sqlite-group-commit branch from 09d97cf to 6df4a05 Compare July 10, 2026 14:30
@grondo

grondo commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Ok, Claude found the issue in the kvs commit --sync path:

A flush already parked waiting for in-flight stores is only completed when the cache goes clean or a store fails. If the last outstanding store instead succeeds while dirty entries linger, cache_resume_flush finds nothing to issue and the parked request is never answered.

Ensure stuck errors are reported correctly back to caller.

Note: the single-commit-per-blob assumption in the cache and tools makes this PR a bit risky IMO. The changes here should be carefully reviewed before being approved.

@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.67857% with 25 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.92%. Comparing base (bdf4f89) to head (6df4a05).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
src/modules/content-sqlite/content-sqlite.c 77.22% 23 Missing ⚠️
src/modules/content/cache.c 81.81% 2 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##           master    #7702    +/-   ##
========================================
  Coverage   83.91%   83.92%            
========================================
  Files         587      587            
  Lines       99979   100082   +103     
========================================
+ Hits        83901    83989    +88     
- Misses      16078    16093    +15     
Files with missing lines Coverage Δ
src/modules/content/cache.c 85.71% <81.81%> (+0.36%) ⬆️
src/modules/content-sqlite/content-sqlite.c 65.14% <77.22%> (+0.25%) ⬆️

... and 10 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@garlick

garlick commented Jul 10, 2026

Copy link
Copy Markdown
Member

I'll open an issue on that cache flush bug. I have a feeling that area of code might benefit from a thorough review.

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.

2 participants