Skip to content

Add bulk execution-result persistence - #47

Open
BinaryFiddler wants to merge 4 commits into
mainfrom
chenyu/stored-result-bulk-write
Open

BinaryFiddler wants to merge 4 commits into
mainfrom
chenyu/stored-result-bulk-write

Conversation

@BinaryFiddler

@BinaryFiddler BinaryFiddler commented Aug 20, 2026 •

Copy link
Copy Markdown

Summary

Adds ordered bulk persistence for stored execution results, enabling the Smite coordinator to batch Bigtable writes. This PR only adds sink support; batching begins when a caller opts in.

Changes

  • Adds insert_many() / persist_writes(), returning one ordered outcome per input and falling back to insert() for non-bulk backends.
  • Adds a Bigtable path that reuses the single-row encoder, caps requests at 25,000 rows (100,000 mutations), and maps row statuses to typed errors.
  • Reports exact protobuf entry/request byte sizes plus batch, latency, and outcome metrics.
  • Uses Bigtable's mutation retry predicate with a 2-second retry window and effective 2-second RPC timeout. Read retries are unchanged.

Row encoding, idempotency, staged failure behavior, reads, and non-Bigtable backends are unchanged.

Rollback

Remove the Bigtable insert_many() override to restore the one-by-one fallback.

Verification

  • 20 targeted tests cover ordering, fallback, chunking, failures, retries/timeouts, metrics, and exact byte accounting.
  • Ruff and mypy pass for the changed files.

@BinaryFiddler
BinaryFiddler force-pushed the chenyu/stored-result-bulk-write branch 2 times, most recently from cbb5089 to 6ee287e Compare August 20, 2026 19:15
BinaryFiddler and others added 2 commits August 20, 2026 19:27
Stored execution results are written one row per action: at ~6.2k actions/s in
production that is ~6.2k Bigtable MutateRows calls/s, one per action. This adds
the ordered bulk contract a batching caller needs to collapse those into one
call per batch. Bigtable behaviour on the existing single-row path is unchanged
apart from the new metrics; the retry fix is the following commit.

- ExecutionResultStore.insert_many(): one outcome per write, aligned to input
  order, with a one-by-one fallback for backends that have no bulk path.
  StoredExecutionResultBigTable overrides it with a single MutateRows call per
  chunk of at most 25,000 rows (Bigtable's 100,000-mutation cap at four
  cells/row) and surfaces each returned row status independently.
- One row-format helper builds both the single and the bulk row, so the two
  paths cannot drift, and insert() stays self-contained so removing the bulk
  override falls back to one-by-one instead of recursing.
- Staged failure semantics: the bulk path returns typed error outcomes, while
  insert() counts a failed row status in row_outcome{outcome:row_error} without
  raising. The pre-bulk contract discarded these statuses, and raising costs
  three sink attempts plus a Sentry event per row at an unmeasured rate.
- Encoded size is measured before mutate_rows(), not after: the client clears
  the mutations of every row it commits, so bytes read afterwards report only
  the row keys.
- commit_ms and row_outcome carry path:single|bulk so the two write paths stay
  separable while both are live. row_outcome aggregates per distinct outcome,
  so a 25,000-row chunk failing one way is one datagram rather than 25,000.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Table.mutate_rows signals retryable work only by raising _BigtableRetryableError.
The read-path retry_policy's transient-error predicate never matches that, so no
client-side mutation retry has ever run on this path — a retryable row status
returned immediately and was discarded.

Mutations now use a DEFAULT_RETRY-derived policy, but bounded to a 2s deadline
rather than DEFAULT_RETRY's 120s. 120s outlasts every caller of this store: the
async stored-result sink abandons a write after 5s (its sync counterpart after
2s) and runs it in asyncio.to_thread(), so abandoning it cannot cancel the
thread. A 120s in-call retry would hold a pool slot for a result nobody reads,
and a broad Bigtable brownout would park every in-flight write there at once.
That sink already times out more than any other by two orders of magnitude, so
the path is well exercised. Longer recovery stays the sinks' job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@BinaryFiddler
BinaryFiddler force-pushed the chenyu/stored-result-bulk-write branch from 6ee287e to 6300cc3 Compare August 20, 2026 19:28
BinaryFiddler and others added 2 commits August 20, 2026 20:54
_encoded_request_size() ran outside the chunk's try, so a failure to measure
raised out of insert_many() before mutate_rows() was called: the batch was never
written, and the caller got an exception instead of the one-outcome-per-write
list the contract promises. A row-build failure is already contained as
batch_error outcomes; measurement — pure instrumentation — was not.

Building a MutateRowsRequest to measure has more ways to fail than the
arithmetic it replaced (proto field validation, table attribute access), so
guard it: log, drop the batch_bytes sample, and let the write proceed.
batch_rows still reports the completed call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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