Skip to content

Commit ff53ce6

Browse files
committed
address review comments: queue-leading PK, NOT NULL metadata, cleaner API
- schema: PRIMARY KEY (queue, uri, request_id) — queue-scoped lookups become PK-prefix scans and the table is shardable by queue. Comment in the schema explains why request_id stays in the PK (concurrent claims by different requests coexist as distinct rows; same-request retries collide on the PK). - schema: metadata JSON NOT NULL. The mysql impl writes '{}' for empty metadata so callers don't need to know about the column constraint. - interface: drop excludeRequestID from FindOverlapping. Callers that want to skip self filter the result by RequestID themselves. Documented Create's batch atomicity (single multi-row INSERT, all-or-nothing). - mysql: FindOverlapping query now leads with `WHERE queue = ?` to align with the new PK order. - entity: expanded RequestID/Queue field comments to explain their PK roles. - README: documents the new key shape and metadata semantics. - integration tests: drop the 4th arg, replace TestFindOverlapping_ExcludesSelf with a test that asserts the store does NOT exclude self, and add an assertion that empty metadata round-trips as '{}'.
1 parent cb3306f commit ff53ce6

7 files changed

Lines changed: 91 additions & 52 deletions

File tree

entity/change_record.go

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,31 @@
1515
package entity
1616

1717
// ChangeRecord represents a single URI's claim by a request, persisted in the change store.
18-
// The (URI, RequestID) pair is the identity and is immutable; Metadata may be updated over
19-
// time as additional information about the change (e.g., PR title, author, mergeability)
20-
// becomes available.
18+
// The (Queue, URI, RequestID) triple is the identity and is immutable; Metadata may be
19+
// updated over time as additional information about the change (e.g., PR title, author,
20+
// mergeability) becomes available.
2121
type ChangeRecord struct {
2222
// URI identifies the change (RFC 3986). Same scheme/format as entity.Change.URIs.
2323
// Example: "github://uber/submitqueue/pull/123/abc123def".
2424
URI string `json:"uri"`
2525

2626
// RequestID is the owning land request that claimed this URI.
2727
// Format matches entity.Request.ID: "<queue>/<counter_value>".
28+
//
29+
// RequestID participates in the change-store primary key so that concurrent claims
30+
// by different requests on the same URI coexist as distinct rows. Same-request
31+
// retries collide on the PK and are absorbed idempotently; different-request
32+
// collisions surface as additional rows that callers detect via FindOverlapping.
2833
RequestID string `json:"request_id"`
2934

30-
// Queue is the queue scope for the owning request. Denormalized from the request
31-
// to allow queue-scoped duplicate checks without a join.
35+
// Queue is the queue the owning request belongs to. It is the leading column of
36+
// the change-store primary key, so queue-scoped duplicate checks become PK-prefix
37+
// scans and the table is shardable by queue.
3238
Queue string `json:"queue"`
3339

3440
// Metadata is a JSON-encoded blob of provider-specific information about the change
35-
// (e.g., PR title, author, mergeable state). Empty when the record is first written;
36-
// populated and updated by downstream enrichment.
41+
// (e.g., PR title, author, mergeable state). Stored as `'{}'` when no metadata has
42+
// been populated yet; updated by downstream enrichment.
3743
Metadata string `json:"metadata,omitempty"`
3844

3945
// CreatedAt is the Unix milliseconds timestamp when this record was first created.

extension/changestore/README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ Each record asserts that a specific URI (e.g., a GitHub PR) was claimed by a spe
66

77
## Semantics
88

9-
- **Identity is immutable.** A record is keyed by `(URI, RequestID)`; once written, that pair is never mutated.
10-
- **Metadata is mutable.** The `Metadata` field is intended for provider-specific information about the change (PR title, author, mergeability, etc.) that may be enriched after the record is first written. `UpdatedAt` reflects the last metadata change; `CreatedAt` is fixed at write time.
11-
- **Idempotent writes.** `Create` ignores primary-key conflicts so queue-redelivery of the same request is a safe no-op.
12-
- **No liveness filter.** `FindOverlapping` returns records regardless of whether the owning request is still in flight. Callers must check liveness against `RequestStore` themselves — the store boundary is intentionally one query, one table, no joins.
9+
- **Identity is immutable.** A record is keyed by `(Queue, URI, RequestID)`; once written, that triple is never mutated.
10+
- **Queue leads the key.** Backends should make `Queue` the leading column of the primary key (or partition key, in shardable stores). All reads are queue-scoped, so this turns lookups into PK-prefix scans and keeps the table shardable.
11+
- **`RequestID` in the key is intentional.** Concurrent claims by different requests on the same URI coexist as distinct rows. Same-request retries collide on the PK and are absorbed idempotently; cross-request collisions show up as additional rows that callers detect via `FindOverlapping`.
12+
- **Metadata is required and mutable.** The `Metadata` field is JSON. The store treats `'{}'` as the canonical "no metadata yet" value — callers that pass an empty Go string get `'{}'` written. Downstream enrichment can update it; `UpdatedAt` reflects the last update.
13+
- **Idempotent writes, atomic batches.** `Create` ignores primary-key conflicts so queue-redelivery of the same request is a safe no-op. The whole batch is one underlying multi-row INSERT — partial success is not exposed.
14+
- **No filtering at the store layer.** `FindOverlapping` returns every matching row, including ones owned by the caller's own request. Callers that want to skip self filter the result by `RequestID` themselves. Liveness is also the caller's job — consult `RequestStore` to skip terminal owners. The store boundary is intentionally one query, one table, no joins.
1315
- **Append-only by design.** Records are not deleted when the owning request reaches a terminal state; the historical claim is preserved for audit. Duplicate detection filters terminals out at query time via the controller-side liveness check.
1416

1517
## Implementing a Backend

extension/changestore/change_store.go

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,22 +30,29 @@ import (
3030
// Liveness of an owning request is NOT tracked here — callers must consult RequestStore separately
3131
// to determine whether an owner is in a terminal state.
3232
type ChangeStore interface {
33-
// Create persists a batch of ChangeRecords. Conflicts on the (URI, RequestID) primary key
34-
// are silently ignored, making the call idempotent under queue redeliveries of the same request.
35-
// Records belonging to different requests do not conflict — overlap between requests is detected
33+
// Create persists a batch of ChangeRecords as a single atomic operation: either all
34+
// records are written or none are. The batch corresponds to one underlying multi-row
35+
// INSERT, so partial success is never observable.
36+
//
37+
// Primary-key conflicts on (queue, uri, request_id) are silently ignored, which makes
38+
// the call idempotent under queue redeliveries of the same request. Records belonging
39+
// to different requests do not conflict on the PK — cross-request overlap is detected
3640
// by FindOverlapping, not by Create.
3741
Create(ctx context.Context, records []entity.ChangeRecord) error
3842

39-
// FindOverlapping returns ChangeRecords whose URI is in the given set, scoped to queue,
40-
// excluding any records belonging to excludeRequestID (so callers can skip self when checking
41-
// for duplicates of a freshly-claimed request). Returns an empty slice when there is no overlap.
43+
// FindOverlapping returns ChangeRecords whose URI is in the given set, scoped to queue.
44+
// Returns an empty slice when there is no overlap.
45+
//
46+
// The store does NOT exclude any specific request_id — if the caller wants to skip
47+
// rows belonging to its own in-flight request (the common case when checking for
48+
// duplicates of a freshly-claimed request), it should filter the returned records by
49+
// RequestID itself.
4250
//
43-
// Liveness of the returned records' owning requests is NOT filtered here — the caller is
44-
// responsible for consulting RequestStore to skip terminal owners.
51+
// Liveness of the returned records' owning requests is also NOT filtered here — the
52+
// caller is responsible for consulting RequestStore to skip terminal owners.
4553
FindOverlapping(
4654
ctx context.Context,
4755
queue string,
4856
uris []string,
49-
excludeRequestID string,
5057
) ([]entity.ChangeRecord, error)
5158
}

extension/changestore/mock/change_store_mock.go

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

extension/changestore/mysql/change_store.go

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,10 @@ func NewChangeStore(db *sql.DB, scope tally.Scope) changestore.ChangeStore {
3737
return &changeStore{db: db, scope: scope}
3838
}
3939

40-
// Create inserts a batch of ChangeRecords. Primary-key conflicts on (uri, request_id)
41-
// are silently ignored via INSERT IGNORE so queue-redelivery of the same request is a no-op.
40+
// Create inserts a batch of ChangeRecords as a single multi-row INSERT IGNORE.
41+
// Primary-key conflicts on (queue, uri, request_id) are silently ignored so
42+
// queue-redelivery of the same request is a no-op. The whole batch is one
43+
// statement, so partial success is not observable.
4244
func (s *changeStore) Create(ctx context.Context, records []entity.ChangeRecord) (retErr error) {
4345
op := metrics.Begin(s.scope, "create")
4446
defer func() { op.Complete(retErr) }()
@@ -53,10 +55,12 @@ func (s *changeStore) Create(ctx context.Context, records []entity.ChangeRecord)
5355

5456
args := make([]any, 0, len(records)*cols)
5557
for _, r := range records {
56-
// Pass empty Metadata as NULL — JSON column rejects empty string but accepts NULL.
57-
var metadata any
58-
if r.Metadata != "" {
59-
metadata = r.Metadata
58+
// Use the empty JSON object as the canonical "no metadata yet" value.
59+
// metadata is NOT NULL in the schema, and an empty Go string would be
60+
// rejected by the JSON column type.
61+
metadata := r.Metadata
62+
if metadata == "" {
63+
metadata = "{}"
6064
}
6165
args = append(args, r.URI, r.RequestID, r.Queue, metadata, r.CreatedAt, r.UpdatedAt)
6266
}
@@ -68,13 +72,13 @@ func (s *changeStore) Create(ctx context.Context, records []entity.ChangeRecord)
6872
return nil
6973
}
7074

71-
// FindOverlapping returns ChangeRecords whose uri is in the given set, scoped to queue,
72-
// excluding any belonging to excludeRequestID.
75+
// FindOverlapping returns ChangeRecords whose uri is in the given set, scoped to queue.
76+
// The store does not filter by request_id; callers that want to skip self should do so
77+
// after the call. Liveness checks against the request store are also the caller's job.
7378
func (s *changeStore) FindOverlapping(
7479
ctx context.Context,
7580
queue string,
7681
uris []string,
77-
excludeRequestID string,
7882
) (ret []entity.ChangeRecord, retErr error) {
7983
op := metrics.Begin(s.scope, "find_overlapping")
8084
defer func() { op.Complete(retErr) }()
@@ -84,14 +88,16 @@ func (s *changeStore) FindOverlapping(
8488
}
8589

8690
uriPlaceholders := "?" + strings.Repeat(", ?", len(uris)-1)
91+
// queue leads the WHERE clause to align with the (queue, uri, request_id) PK,
92+
// so this is a PK-prefix scan.
8793
query := "SELECT uri, request_id, queue, metadata, created_at, updated_at FROM `change` " +
88-
"WHERE uri IN (" + uriPlaceholders + ") AND queue = ? AND request_id != ?"
94+
"WHERE queue = ? AND uri IN (" + uriPlaceholders + ")"
8995

90-
args := make([]any, 0, len(uris)+2)
96+
args := make([]any, 0, 1+len(uris))
97+
args = append(args, queue)
9198
for _, u := range uris {
9299
args = append(args, u)
93100
}
94-
args = append(args, queue, excludeRequestID)
95101

96102
rows, err := s.db.QueryContext(ctx, query, args...)
97103
if err != nil {
@@ -102,13 +108,9 @@ func (s *changeStore) FindOverlapping(
102108
var results []entity.ChangeRecord
103109
for rows.Next() {
104110
var rec entity.ChangeRecord
105-
var metadata sql.NullString
106-
if err := rows.Scan(&rec.URI, &rec.RequestID, &rec.Queue, &metadata, &rec.CreatedAt, &rec.UpdatedAt); err != nil {
111+
if err := rows.Scan(&rec.URI, &rec.RequestID, &rec.Queue, &rec.Metadata, &rec.CreatedAt, &rec.UpdatedAt); err != nil {
107112
return nil, fmt.Errorf("failed to scan change record for queue=%s: %w", queue, err)
108113
}
109-
if metadata.Valid {
110-
rec.Metadata = metadata.String
111-
}
112114
results = append(results, rec)
113115
}
114116
if err := rows.Err(); err != nil {
Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1+
-- request_id is part of the PK so concurrent claims by different requests on the
2+
-- same URI coexist as distinct rows. Same-request retry → PK conflict (no-op via
3+
-- INSERT IGNORE). Different-request collision → distinct row, surfaced by
4+
-- FindOverlapping. Queue leads the PK so queue-scoped lookups are PK-prefix scans
5+
-- and the table is shardable by queue.
16
CREATE TABLE IF NOT EXISTS `change` (
27
uri VARCHAR(255) NOT NULL,
38
request_id VARCHAR(255) NOT NULL,
49
queue VARCHAR(255) NOT NULL,
5-
metadata JSON,
10+
metadata JSON NOT NULL,
611
created_at BIGINT NOT NULL,
712
updated_at BIGINT NOT NULL,
8-
PRIMARY KEY (uri, request_id)
13+
PRIMARY KEY (queue, uri, request_id)
914
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

test/integration/extension/changestore/mysql/changestore_test.go

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ func (s *MySQLChangeStoreIntegrationSuite) TestCreateAndFind_NoOverlap() {
9494
{URI: "github://uber/x/pull/1/aaa", RequestID: "q/1", Queue: "q", CreatedAt: 1, UpdatedAt: 1},
9595
}))
9696

97-
got, err := s.store.FindOverlapping(s.ctx, "q", []string{"github://uber/x/pull/2/bbb"}, "q/2")
97+
got, err := s.store.FindOverlapping(s.ctx, "q", []string{"github://uber/x/pull/2/bbb"})
9898
require.NoError(t, err)
9999
assert.Empty(t, got)
100100
}
@@ -106,24 +106,26 @@ func (s *MySQLChangeStoreIntegrationSuite) TestCreateAndFind_Overlap() {
106106
{URI: uri, RequestID: "q/1", Queue: "q", CreatedAt: 1, UpdatedAt: 1},
107107
}))
108108

109-
got, err := s.store.FindOverlapping(s.ctx, "q", []string{uri}, "q/2")
109+
got, err := s.store.FindOverlapping(s.ctx, "q", []string{uri})
110110
require.NoError(t, err)
111111
require.Len(t, got, 1)
112112
assert.Equal(t, "q/1", got[0].RequestID)
113113
assert.Equal(t, uri, got[0].URI)
114114
assert.Equal(t, "q", got[0].Queue)
115115
}
116116

117-
func (s *MySQLChangeStoreIntegrationSuite) TestFindOverlapping_ExcludesSelf() {
117+
func (s *MySQLChangeStoreIntegrationSuite) TestFindOverlapping_ReturnsAllOwners() {
118+
// The store does not exclude any specific request_id; callers filter self if they wish.
118119
t := s.T()
119120
uri := "github://uber/x/pull/1/aaa"
120121
require.NoError(t, s.store.Create(s.ctx, []entity.ChangeRecord{
121122
{URI: uri, RequestID: "q/1", Queue: "q", CreatedAt: 1, UpdatedAt: 1},
122123
}))
123124

124-
got, err := s.store.FindOverlapping(s.ctx, "q", []string{uri}, "q/1")
125+
got, err := s.store.FindOverlapping(s.ctx, "q", []string{uri})
125126
require.NoError(t, err)
126-
assert.Empty(t, got, "FindOverlapping must not return rows for excludeRequestID")
127+
require.Len(t, got, 1, "store returns the row even when caller might consider it self")
128+
assert.Equal(t, "q/1", got[0].RequestID)
127129
}
128130

129131
func (s *MySQLChangeStoreIntegrationSuite) TestFindOverlapping_QueueScoped() {
@@ -133,7 +135,7 @@ func (s *MySQLChangeStoreIntegrationSuite) TestFindOverlapping_QueueScoped() {
133135
{URI: uri, RequestID: "qA/1", Queue: "qA", CreatedAt: 1, UpdatedAt: 1},
134136
}))
135137

136-
got, err := s.store.FindOverlapping(s.ctx, "qB", []string{uri}, "qB/1")
138+
got, err := s.store.FindOverlapping(s.ctx, "qB", []string{uri})
137139
require.NoError(t, err)
138140
assert.Empty(t, got, "FindOverlapping must not return rows from a different queue")
139141
}
@@ -158,7 +160,7 @@ func (s *MySQLChangeStoreIntegrationSuite) TestCreate_DifferentRequestSameURI()
158160
{URI: uri, RequestID: "q/2", Queue: "q", CreatedAt: 2, UpdatedAt: 2},
159161
}))
160162

161-
got, err := s.store.FindOverlapping(s.ctx, "q", []string{uri}, "q/3")
163+
got, err := s.store.FindOverlapping(s.ctx, "q", []string{uri})
162164
require.NoError(t, err)
163165
require.Len(t, got, 2)
164166

@@ -175,12 +177,27 @@ func (s *MySQLChangeStoreIntegrationSuite) TestCreate_PreservesMetadata() {
175177
{URI: uri, RequestID: "q/1", Queue: "q", Metadata: meta, CreatedAt: 1, UpdatedAt: 1},
176178
}))
177179

178-
got, err := s.store.FindOverlapping(s.ctx, "q", []string{uri}, "q/other")
180+
got, err := s.store.FindOverlapping(s.ctx, "q", []string{uri})
179181
require.NoError(t, err)
180182
require.Len(t, got, 1)
181183
assert.JSONEq(t, meta, got[0].Metadata)
182184
}
183185

186+
func (s *MySQLChangeStoreIntegrationSuite) TestCreate_EmptyMetadataStoredAsObject() {
187+
// metadata is NOT NULL in the schema. The impl substitutes '{}' for an empty
188+
// Metadata field so callers don't need to know about the column constraint.
189+
t := s.T()
190+
uri := "github://uber/x/pull/1/aaa"
191+
require.NoError(t, s.store.Create(s.ctx, []entity.ChangeRecord{
192+
{URI: uri, RequestID: "q/1", Queue: "q", CreatedAt: 1, UpdatedAt: 1},
193+
}))
194+
195+
got, err := s.store.FindOverlapping(s.ctx, "q", []string{uri})
196+
require.NoError(t, err)
197+
require.Len(t, got, 1)
198+
assert.JSONEq(t, "{}", got[0].Metadata)
199+
}
200+
184201
func (s *MySQLChangeStoreIntegrationSuite) TestCreate_EmptyIsNoOp() {
185202
t := s.T()
186203
require.NoError(t, s.store.Create(s.ctx, nil))
@@ -196,7 +213,7 @@ func (s *MySQLChangeStoreIntegrationSuite) TestFindOverlapping_EmptyURIsIsNoOp()
196213
{URI: "github://uber/x/pull/1/aaa", RequestID: "q/1", Queue: "q", CreatedAt: 1, UpdatedAt: 1},
197214
}))
198215

199-
got, err := s.store.FindOverlapping(s.ctx, "q", nil, "q/2")
216+
got, err := s.store.FindOverlapping(s.ctx, "q", nil)
200217
require.NoError(t, err)
201218
assert.Empty(t, got)
202219
}

0 commit comments

Comments
 (0)