Skip to content

Commit 49119c4

Browse files
albertywuclaude
andcommitted
refactor(storage): replace batch (queue,state) index with active_batch membership
Replace the secondary index over the batch table's mutable `state` column with an `active_batch` membership table that answers the only queue-scoped query the pipeline needs: "which batches in this queue are still active?" (the batch controller uses it to find conflict dependencies; the cancel controller uses it to find the batch holding a request). A row is intended to exist while its batch is non-terminal, so the table stays bounded by the live speculation window rather than growing with batch history. `queue` leads the PK so listing is a PK-prefix scan and the table is shardable by queue — an access pattern that ports cleanly to a key-value store (queue = partition key, batch_id = sort key), unlike a server-maintained secondary index over a mutable non-key column. Membership is best-effort, not an exact mirror of batch state, and is maintained without transactions: - Create writes the membership row before the batch row. This ordering is required for correctness: whenever a batch row is visible to a reader its membership row is already present, so a concurrent ListActive can never miss an active batch. INSERT IGNORE keeps the membership write idempotent across retries. - If the batch insert then fails, Create deliberately leaves the membership row in place. A returned error does not prove the row was not written (an ambiguous failure can commit the batch row and still return an error), so deleting would risk permanently orphaning a live, non-terminal batch from ListActive. A dangling membership is the safe direction. - ListActive resolves each member by primary key: a terminal batch's membership is best-effort removed (race-free — a terminal batch is fully committed and its id is never reused); a missing batch is skipped but NOT removed (it may belong to an in-flight Create that has written its membership but not yet its batch row). Cleanup failures are swallowed so a read never fails on index maintenance, and terminal-state writers (merge, speculate, dlq) need not touch the index. Genuinely dangling rows (failed/crashed creates) and batches stuck in a non-terminal state are left for a future reconcile/prune job, documented in schema/README.md. Integration tests cover the self-heal and membership invariants: - TestActiveBatch_SelfHealsTerminalMembership - TestActiveBatch_SkipsDanglingMembershipWithoutDeleting - TestActiveBatch_CreateKeepsMembershipOnDuplicate - TestActiveBatch_CreateKeepsMembershipOnFailedInsert Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3cebc2e commit 49119c4

13 files changed

Lines changed: 355 additions & 87 deletions

File tree

submitqueue/extension/storage/batch_store.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ type BatchStore interface {
4141
// Version arithmetic is owned by the caller; the store performs a pure conditional write.
4242
UpdateScoreAndState(ctx context.Context, id string, oldVersion, newVersion int32, score float64, newState entity.BatchState) error
4343

44-
// GetByQueueAndStates retrieves all batches that belong to the given queue and are in the given states.
45-
GetByQueueAndStates(ctx context.Context, queue string, states []entity.BatchState) ([]entity.Batch, error)
44+
// ListActive returns all active (non-terminal) batches in the given queue.
45+
// "Active" means the batch's persisted state is not terminal — see
46+
// entity.BatchState.IsTerminal. Callers that need a narrower set filter the
47+
// result by state in memory.
48+
//
49+
// The store tracks active membership internally (added on Create, self-healed
50+
// on read) so this is a key-prefix read rather than a secondary-index query;
51+
// see the implementation and extension/storage/mysql/schema/README.md.
52+
ListActive(ctx context.Context, queue string) ([]entity.Batch, error)
4653
}

submitqueue/extension/storage/mock/batch_store_mock.go

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

submitqueue/extension/storage/mysql/batch_store.go

Lines changed: 78 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import (
2020
"encoding/json"
2121
"errors"
2222
"fmt"
23-
"strings"
2423

2524
"github.com/go-sql-driver/mysql"
2625
"github.com/uber-go/tally"
@@ -87,6 +86,19 @@ func (s *batchStore) Create(ctx context.Context, batch entity.Batch) (retErr err
8786
return fmt.Errorf("failed to marshal dependencies=%v id=%s for Create batch entity: %w", batch.Dependencies, batch.ID, err)
8887
}
8988

89+
// Write membership before the batch row (no transaction) so a batch row is
90+
// never visible to ListActive without its membership. ON DUPLICATE KEY UPDATE is
91+
// an idempotent no-op on PK conflict (retry-safe) while still surfacing real
92+
// errors, unlike INSERT IGNORE which would swallow them and let Create proceed
93+
// without a valid membership. A create that fails after this point leaves a
94+
// dangling row, which ListActive skips and the reconcile job reclaims.
95+
if _, err = s.db.ExecContext(ctx,
96+
"INSERT INTO active_batch (queue, batch_id) VALUES (?, ?) ON DUPLICATE KEY UPDATE batch_id = batch_id",
97+
batch.Queue, batch.ID,
98+
); err != nil {
99+
return fmt.Errorf("failed to insert active_batch membership for batch entity id=%s queue=%s: %w", batch.ID, batch.Queue, err)
100+
}
101+
90102
_, err = s.db.ExecContext(ctx,
91103
"INSERT INTO batch (id, queue, contains, dependencies, score, state, version) VALUES (?, ?, ?, ?, ?, ?, ?)",
92104
batch.ID, batch.Queue, containsJSON, dependenciesJSON, batch.Score, batch.State, batch.Version,
@@ -96,6 +108,10 @@ func (s *batchStore) Create(ctx context.Context, batch entity.Batch) (retErr err
96108
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
97109
return fmt.Errorf("batch entity id=%s: %w", batch.ID, storage.ErrAlreadyExists)
98110
}
111+
// Leave the membership row in place: a returned error doesn't prove the
112+
// batch row was not written (an ambiguous failure can commit it and still
113+
// error), so deleting could permanently hide a live batch from ListActive.
114+
// A dangling row is the safe direction.
99115
return fmt.Errorf("failed to insert batch entity id=%s: %w", batch.ID, err)
100116
}
101117

@@ -174,52 +190,81 @@ func (s *batchStore) UpdateScoreAndState(ctx context.Context, id string, oldVers
174190
return nil
175191
}
176192

177-
// GetByQueueAndStates retrieves all batches that belong to the given queue and are in the given states.
178-
func (s *batchStore) GetByQueueAndStates(ctx context.Context, queue string, states []entity.BatchState) (ret []entity.Batch, retErr error) {
179-
op := metrics.Begin(s.scope, "get_by_queue_and_states")
193+
// ListActive returns all active (non-terminal) batches in the given queue.
194+
//
195+
// Membership is tracked in active_batch (queue leads the PK), so listing is a
196+
// PK-prefix scan that ports cleanly to a key-value store. Each member is fetched
197+
// by primary key: a terminal batch's membership is best-effort removed (race-free,
198+
// its id is never reused), while a missing batch is skipped but NOT removed (it
199+
// may belong to an in-flight Create that hasn't written its batch row yet).
200+
func (s *batchStore) ListActive(ctx context.Context, queue string) (ret []entity.Batch, retErr error) {
201+
op := metrics.Begin(s.scope, "list_active")
180202
defer func() { op.Complete(retErr) }()
181203

182-
if len(states) == 0 {
183-
return nil, nil
204+
// Read all membership rows and release the connection before resolving each
205+
// batch, since Get issues its own query.
206+
ids, err := s.activeBatchIDs(ctx, queue)
207+
if err != nil {
208+
return nil, err
184209
}
185210

186-
query := "SELECT id, queue, contains, dependencies, score, state, version FROM batch WHERE queue = ? AND state IN (?" + strings.Repeat(", ?", len(states)-1) + ")"
187-
188-
args := make([]any, 1+len(states))
189-
args[0] = queue
190-
for i, state := range states {
191-
args[i+1] = state
211+
var results []entity.Batch
212+
for _, id := range ids {
213+
batch, err := s.Get(ctx, id)
214+
if err != nil {
215+
if storage.IsNotFound(err) {
216+
// Missing batch: either an in-flight Create or a dangling row. We
217+
// can't tell them apart, so skip without deleting.
218+
continue
219+
}
220+
return nil, fmt.Errorf("failed to get active batch id=%q queue=%q: %w", id, queue, err)
221+
}
222+
if batch.State.IsTerminal() {
223+
// Stale membership: the batch has finished. Race-free to remove since
224+
// its id is never reused.
225+
s.removeActive(ctx, queue, id)
226+
continue
227+
}
228+
results = append(results, batch)
192229
}
193230

194-
rows, err := s.db.QueryContext(ctx, query, args...)
231+
return results, nil
232+
}
233+
234+
// activeBatchIDs reads the batch IDs recorded as active for the queue, owning the
235+
// result set's lifecycle so the caller can resolve each batch after it's closed.
236+
func (s *batchStore) activeBatchIDs(ctx context.Context, queue string) ([]string, error) {
237+
rows, err := s.db.QueryContext(ctx,
238+
"SELECT batch_id FROM active_batch WHERE queue = ?",
239+
queue,
240+
)
195241
if err != nil {
196-
return nil, fmt.Errorf("failed to query batches by queue=%q states=%v from the database: %w", queue, states, err)
242+
return nil, fmt.Errorf("failed to query active batch membership for queue=%q: %w", queue, err)
197243
}
198244
defer rows.Close()
199245

200-
var results []entity.Batch
246+
var ids []string
201247
for rows.Next() {
202-
var batch entity.Batch
203-
var containsJSON []byte
204-
var dependenciesJSON []byte
205-
206-
if err := rows.Scan(&batch.ID, &batch.Queue, &containsJSON, &dependenciesJSON, &batch.Score, &batch.State, &batch.Version); err != nil {
207-
return nil, fmt.Errorf("failed to scan batch entity by queue=%q states=%v from the database: %w", queue, states, err)
208-
}
209-
210-
if err := json.Unmarshal(containsJSON, &batch.Contains); err != nil {
211-
return nil, fmt.Errorf("failed to unmarshal contains for batch entity id=%s from the database: %w", batch.ID, err)
212-
}
213-
214-
if err := json.Unmarshal(dependenciesJSON, &batch.Dependencies); err != nil {
215-
return nil, fmt.Errorf("failed to unmarshal dependencies for batch entity id=%s from the database: %w", batch.ID, err)
248+
var id string
249+
if err := rows.Scan(&id); err != nil {
250+
return nil, fmt.Errorf("failed to scan active batch membership for queue=%q: %w", queue, err)
216251
}
217-
218-
results = append(results, batch)
252+
ids = append(ids, id)
219253
}
220254
if err := rows.Err(); err != nil {
221-
return nil, fmt.Errorf("failed to iterate batches by queue=%q states=%v from the database: %w", queue, states, err)
255+
return nil, fmt.Errorf("failed to iterate active batch membership for queue=%q: %w", queue, err)
222256
}
257+
return ids, nil
258+
}
223259

224-
return results, nil
260+
// removeActive best-effort deletes a single active_batch membership row, used by
261+
// ListActive to reclaim terminal batches' memberships. Failures are counted and
262+
// ignored — the row is harmless and the next read retries.
263+
func (s *batchStore) removeActive(ctx context.Context, queue, batchID string) {
264+
if _, err := s.db.ExecContext(ctx,
265+
"DELETE FROM active_batch WHERE queue = ? AND batch_id = ?",
266+
queue, batchID,
267+
); err != nil {
268+
metrics.NamedCounter(s.scope, "list_active", "self_heal_errors", 1)
269+
}
225270
}

submitqueue/extension/storage/mysql/schema/README.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,23 @@
22

33
## batch table
44

5-
### Secondary index: `idx_queue_state (queue, state)`
5+
The `batch` table is reachable only by its primary key (`id`). It carries no secondary index — every access pattern is expressed as a primary-key get or as a key-prefix scan over a companion membership table (see `active_batch` below). This keeps the access patterns portable to a key-value / document store, where a server-maintained secondary index over a mutable, non-key column (such as `state`) is not a primitive every backend offers cheaply.
66

7-
The `batch` table has a composite secondary index on `(queue, state)`. This index supports the `GetByQueueAndStates` query, which retrieves batches filtered by queue and one or more states. Without this index, the query would require a full table scan.
7+
## active_batch table
88

9-
#### Trade-offs
9+
`active_batch` is the membership index that answers "which batches in this queue are still active?" — the only queue-scoped query the pipeline needs (the batch controller uses it to find conflict dependencies; the cancel controller uses it to find the batch holding a request). A row is intended to exist per non-terminal batch, so the table stays bounded by the live speculation window rather than full batch history. The correspondence is best-effort, not exact: readers treat membership as a hint and resolve each batch by primary key — see *Maintenance and self-healing* below.
1010

11-
- **Write overhead**: Every `INSERT` and `UPDATE` to the `batch` table must also update the secondary index, adding latency to write operations.
12-
- **Storage cost**: The index consumes additional disk space proportional to the number of rows in the table.
13-
- **Lock contention**: Under high write concurrency, index maintenance can increase lock contention on the affected index pages.
11+
`queue` leads the composite primary key `(queue, batch_id)`, so listing a queue's active batches is a primary-key-prefix scan and the table is shardable by queue. On a key-value store the same shape maps directly onto a partition key (`queue`) and sort key (`batch_id`) with no secondary index.
1412

15-
#### Future: Prune job
13+
### Maintenance and self-healing
1614

17-
As the `batch` table grows, the secondary index will grow with it, increasing storage costs and degrading write performance. To mitigate this, a prune job should be introduced to periodically delete batches in terminal states (`succeeded`, `failed`, `cancelled`) that are older than a configurable retention period. This keeps the table and its indexes bounded in size, ensuring consistent query and write performance over time.
15+
`BatchStore.Create` writes the membership row before the batch row, so a batch row is never visible to `ListActive` without its membership. If the batch insert then fails, `Create` leaves the membership row in place: a returned error doesn't prove the row wasn't written (an ambiguous failure can commit it and still error), so deleting could permanently hide a live batch. A dangling row is the safe direction.
16+
17+
On read, `ListActive` resolves each member by primary key. A **terminal** batch's membership is best-effort removed (race-free — its id is never reused). A **missing** batch is skipped but not removed, since it may belong to an in-flight `Create` that hasn't written its batch row yet. Cleanup failures are swallowed, so reads never fail on index maintenance and terminal-state writers (merge, speculate, dlq) never touch the index. Because the two writes are independent (no transaction), the design tolerates partial failure via idempotent retries and read-time reconciliation.
18+
19+
### Future: prune / reconcile job
20+
21+
Read-time reconciliation only removes terminal memberships, so two kinds of stale row need a periodic sweep: dangling memberships whose batch never landed (a failed or crashed create), and memberships of batches that are stuck in a non-terminal state (e.g. an orphan stuck in `created` after a mid-process failure). A reconcile job should sweep both, keeping the table bounded independently of read traffic.
1822

1923
## change table
2024

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- active_batch is the membership index for "active (non-terminal) batches in a
2+
-- queue", keeping the set bounded by the live speculation window rather than batch
3+
-- history. queue leads the PK so listing is a PK-prefix scan (shardable by queue;
4+
-- portable to a key-value store with queue = partition key, batch_id = sort key).
5+
-- Membership is best-effort: it is added on Create (before the batch row) and
6+
-- removed on read by ListActive once a batch is terminal. A reconcile job reclaims
7+
-- rows left dangling by a failed or crashed create. See schema/README.md.
8+
CREATE TABLE IF NOT EXISTS active_batch (
9+
queue VARCHAR(255) NOT NULL,
10+
batch_id VARCHAR(255) NOT NULL,
11+
PRIMARY KEY (queue, batch_id)
12+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

submitqueue/extension/storage/mysql/schema/batch.sql

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@ CREATE TABLE IF NOT EXISTS batch (
44
contains JSON NOT NULL,
55
dependencies JSON NOT NULL,
66
score DOUBLE NOT NULL,
7-
state VARCHAR(255) NOT NUll,
7+
state VARCHAR(255) NOT NULL,
88
version INT NOT NULL,
9-
PRIMARY KEY (id),
10-
INDEX idx_queue_state (queue, state)
9+
PRIMARY KEY (id)
1110
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

submitqueue/orchestrator/controller/batch/batch.go

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -133,18 +133,22 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) (r
133133
Version: 1,
134134
}
135135

136-
// Get active batches for this queue and ask the conflict analyzer which
137-
// of them the new batch must serialize behind. The dependency set drives
138-
// the speculation graph downstream.
139-
activeBatches, err := c.store.GetBatchStore().GetByQueueAndStates(ctx, request.Queue, []entity.BatchState{
140-
entity.BatchStateCreated,
141-
entity.BatchStateSpeculating,
142-
entity.BatchStateMerging,
143-
})
136+
// Ask the conflict analyzer which active batches the new batch must serialize
137+
// behind. ListActive returns all non-terminal batches; we narrow to
138+
// Created/Speculating/Merging so the analyzer only sees batches that can still
139+
// acquire new conflicts.
140+
allActive, err := c.store.GetBatchStore().ListActive(ctx, request.Queue)
144141
if err != nil {
145142
metrics.NamedCounter(c.metricsScope, opName, "batch_store_errors", 1)
146143
return fmt.Errorf("failed to get active batches for queue=%s: %w", request.Queue, err)
147144
}
145+
activeBatches := make([]entity.Batch, 0, len(allActive))
146+
for _, b := range allActive {
147+
switch b.State {
148+
case entity.BatchStateCreated, entity.BatchStateSpeculating, entity.BatchStateMerging:
149+
activeBatches = append(activeBatches, b)
150+
}
151+
}
148152

149153
// Dedupe by batch ID since a single (analyzed, in-flight) pair may be
150154
// reported with multiple Conflict entries when different conflict types

0 commit comments

Comments
 (0)