Skip to content

Commit 06ebfe1

Browse files
committed
feat(storage): Add MySQL backed target signature store
1 parent 5b5e9d2 commit 06ebfe1

13 files changed

Lines changed: 603 additions & 2 deletions

File tree

‎doc/rfc/service-scoped-extensions.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Separating the services onto their own databases already worked — the orchestr
2525
1. An extension whose **aggregate** is used by exactly one service of a multi-service domain puts that aggregate, its implementations, its mocks and its schema at `{domain}/{service}/extension/{ext}/`. The store contracts themselves stay at `{domain}/extension/{ext}/`: the aggregate is what decides reachability, so moving the contracts adds no enforcement while forcing domain-level callers to import a service package.
2626
2. An extension shared between a domain's services stays wholly at `{domain}/extension/{ext}/` — including one used by a single service today but expected in both. `submitqueue/extension/queueconfig/` is gateway-only now and stays where it is on that basis. A single-service domain is unaffected: its domain root is its service root, so nothing moves.
2727
3. Storage is the extension this split applied to. `changeprovider`, `validator`, `conflict`, `buildrunner` and `speculation` are orchestrator-only and would qualify under Decision 1, but none has a schema and none was the reason for this change; they stay at `{domain}/extension/` until a later RFC takes them. The trigger for moving storage was a schema that a separate-database deployment must not over-provision.
28-
4. SubmitQueue's storage aggregate splits in two: `submitqueue/gateway/extension/storage/` declares a `Factory` and a four-accessor `Storage`, `submitqueue/orchestrator/extension/storage/` a `Factory` and a nine-accessor `Storage`. Neither can resolve the other's stores.
28+
4. SubmitQueue's storage aggregate splits in two: `submitqueue/gateway/extension/storage/` declares a `Factory` and a four-accessor `Storage`, `submitqueue/orchestrator/extension/storage/` a `Factory` and a ten-accessor `Storage`. Neither can resolve the other's stores.
2929
5. `submitqueue/extension/storage/` keeps the fifteen store contracts, their mocks, the error vocabulary, `Config`, and the package README, which documents the contracts. Its import path does not change, so callers that name only a contract or an error are untouched.
3030
6. The contract package is not promoted to `platform/`. It is not cross-domain — `stovepipe/extension/storage/storage.go` already declares its own verbatim copy of the same symbols, so a per-domain error vocabulary is the existing pattern rather than something this RFC introduces.
3131
7. Each half owns its own MySQL schema directory, so the tables a service creates are the tables it reads. A union target over both stays available for deployments that colocate the services — the end-to-end tests and the local compose stack take it and name no service directory. Split schemas are the opt-in, not the default.
@@ -52,6 +52,7 @@ Every store, verified against actual usage rather than intent.
5252
| `BuildStore` | `build` |
5353
| `SpeculationPathSetStore` | `speculation_path_set` |
5454
| `PathBuildStore` | `path_build` |
55+
| `TargetSignatureStore` | `target_signature` |
5556

5657
`counter` is not in either list. It is its own shared extension and each service keeps its own counter table.
5758

‎submitqueue/orchestrator/extension/storage/mock/storage_mock.go‎

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

‎submitqueue/orchestrator/extension/storage/mysql/BUILD.bazel‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ go_library(
1313
"request_store.go",
1414
"speculation_path_set_store.go",
1515
"storage.go",
16+
"target_signature_codec.go",
17+
"target_signature_store.go",
1618
],
1719
importpath = "github.com/uber/submitqueue/submitqueue/orchestrator/extension/storage/mysql",
1820
visibility = ["//visibility:public"],
@@ -38,6 +40,8 @@ go_test(
3840
"request_store_test.go",
3941
"speculation_path_set_store_test.go",
4042
"storage_test.go",
43+
"target_signature_codec_test.go",
44+
"target_signature_store_test.go",
4145
],
4246
embed = [":go_default_library"],
4347
deps = [

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Queue-leading primary keys
44

5-
Every table leads its primary key with `queue`: `request` and `batch` on `(queue, id)`, `build` on `(queue, id)`, `batch_dependent` on `(queue, batch_id)`, `request_batch` on `(queue, request_id, batch_id)`, `change` on `(queue, uri, request_id)`, `queue_batch_state` on `(queue, state, batch_id)`, and `speculation_path_set` on `(queue, head)`. A queue-bound store instance prefixes every read and stamps every write with its bound queue, so one queue's rows are unreachable through another queue's binding and every table is shardable by queue. `//tool/linter/queueshard` enforces this, and also rejects any secondary index that does not itself lead with `queue`, since such an index would reintroduce a cross-queue access path.
5+
Every table leads its primary key with `queue`: `request` and `batch` on `(queue, id)`, `build` on `(queue, id)`, `batch_dependent` on `(queue, batch_id)`, `request_batch` on `(queue, request_id, batch_id)`, `change` on `(queue, uri, request_id)`, `queue_batch_state` on `(queue, state, batch_id)`, `speculation_path_set` on `(queue, head)`, and `target_signature` on `(queue, batch_id)`. A queue-bound store instance prefixes every read and stamps every write with its bound queue, so one queue's rows are unreachable through another queue's binding and every table is shardable by queue. `//tool/linter/queueshard` enforces this, and also rejects any secondary index that does not itself lead with `queue`, since such an index would reintroduce a cross-queue access path.
66

77
The `build` key also removes a cross-queue uniqueness assumption: build IDs are runner-minted, so two queues sharing one CI pipeline may legitimately mint the same identifier. `speculation_path_set` relies on the same property for its head: a batch ID is unique only within its queue.
88

@@ -27,3 +27,7 @@ Terminal-state records (and their batches) accumulate as the queue processes wor
2727
### Composite primary key: `(queue, uri, request_id)`
2828

2929
The `change` table records per-URI claims by in-flight requests. `request_id` is part of the primary key so that concurrent claims on the same URI by different requests coexist as distinct rows — a same-request retry collides on the PK and is a no-op (`INSERT IGNORE`), while a different-request claim is a new row that `GetByURI` surfaces for overlap detection. `queue` leads the key so queue-scoped lookups are primary-key-prefix scans and the table is shardable by queue.
30+
31+
## target_signature table
32+
33+
`target_signature` holds one row per batch: the base revision the signature was computed at and the batch's affected targets as sorted `(id, distance)` entries (see `entity.TargetSignature`). Rows are written once and never updated. `targets` is a flat binary encoding, per entry an 8-byte big-endian ID followed by a signed varint distance, stored as `LONGBLOB` because the largest signatures run to tens of megabytes. The encoding lives in `target_signature_codec.go`. MySQL's `max_allowed_packet` is a separate limit and must be sized for the largest signature a deployment expects.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
-- targets is the binary encoding of the signature's sorted (id, distance)
2+
-- entries. LONGBLOB because the largest signatures run to tens of MB. The row
3+
-- is written once and never updated.
4+
CREATE TABLE IF NOT EXISTS target_signature (
5+
queue VARCHAR(255) NOT NULL,
6+
batch_id VARCHAR(255) NOT NULL,
7+
base_revision VARCHAR(255) NOT NULL,
8+
targets LONGBLOB NOT NULL,
9+
created_at BIGINT NOT NULL,
10+
PRIMARY KEY (queue, batch_id)
11+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

‎submitqueue/orchestrator/extension/storage/mysql/storage.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ func (s *Storage) For(queueName string) (storage.Storage, error) {
6060
buildStore: NewBuildStore(s.db, s.scope.SubScope("build_store"), queueName),
6161
speculationPathSetStore: NewSpeculationPathSetStore(s.db, s.scope.SubScope("speculation_path_set_store"), queueName),
6262
pathBuildStore: NewPathBuildStore(s.db, s.scope.SubScope("path_build_store"), queueName),
63+
targetSignatureStore: NewTargetSignatureStore(s.db, s.scope.SubScope("target_signature_store"), queueName),
6364
}, nil
6465
}
6566

@@ -79,6 +80,7 @@ type boundStorage struct {
7980
buildStore basestorage.BuildStore
8081
speculationPathSetStore basestorage.SpeculationPathSetStore
8182
pathBuildStore basestorage.PathBuildStore
83+
targetSignatureStore basestorage.TargetSignatureStore
8284
}
8385

8486
// Verify boundStorage implements the queue-scoped aggregate at compile time.
@@ -128,3 +130,8 @@ func (f *boundStorage) GetSpeculationPathSetStore() basestorage.SpeculationPathS
128130
func (f *boundStorage) GetPathBuildStore() basestorage.PathBuildStore {
129131
return f.pathBuildStore
130132
}
133+
134+
// GetTargetSignatureStore returns the bound MySQL-backed TargetSignatureStore.
135+
func (f *boundStorage) GetTargetSignatureStore() basestorage.TargetSignatureStore {
136+
return f.targetSignatureStore
137+
}

‎submitqueue/orchestrator/extension/storage/mysql/storage_test.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ func TestNewStorage(t *testing.T) {
4646
assert.NotNil(t, bound.GetQueueBatchStateStore())
4747
assert.NotNil(t, bound.GetBuildStore())
4848
assert.NotNil(t, bound.GetSpeculationPathSetStore())
49+
assert.NotNil(t, bound.GetTargetSignatureStore())
4950

5051
_, err = s.For("")
5152
assert.Error(t, err, "resolving an empty queue name must fail")
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright (c) 2026 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package mysql
16+
17+
import (
18+
"encoding/binary"
19+
"fmt"
20+
"math"
21+
22+
"github.com/uber/submitqueue/submitqueue/entity"
23+
)
24+
25+
// idBytes is the width of an encoded target ID.
26+
const idBytes = 8
27+
28+
// encodeSignatureTargets packs targets as a flat byte string: per target, the ID
29+
// as 8 big-endian bytes followed by the distance as a signed varint. The result
30+
// is never nil, because a nil slice would be written as NULL into a NOT NULL
31+
// column.
32+
func encodeSignatureTargets(targets []entity.SignatureTarget) []byte {
33+
encoded := make([]byte, 0, len(targets)*(idBytes+1))
34+
for _, target := range targets {
35+
encoded = binary.BigEndian.AppendUint64(encoded, target.ID)
36+
encoded = binary.AppendVarint(encoded, int64(target.Distance))
37+
}
38+
return encoded
39+
}
40+
41+
// decodeSignatureTargets reverses encodeSignatureTargets. It returns an error on
42+
// truncated input or a distance outside the int32 range.
43+
func decodeSignatureTargets(encoded []byte) ([]entity.SignatureTarget, error) {
44+
var targets []entity.SignatureTarget
45+
for offset := 0; offset < len(encoded); {
46+
if len(encoded)-offset < idBytes {
47+
return nil, fmt.Errorf("truncated target ID at offset %d of %d bytes", offset, len(encoded))
48+
}
49+
id := binary.BigEndian.Uint64(encoded[offset:])
50+
offset += idBytes
51+
52+
distance, read := binary.Varint(encoded[offset:])
53+
if read <= 0 {
54+
return nil, fmt.Errorf("invalid distance varint at offset %d of %d bytes", offset, len(encoded))
55+
}
56+
offset += read
57+
if distance < math.MinInt32 || distance > math.MaxInt32 {
58+
return nil, fmt.Errorf("distance %d for target ID %d is outside the int32 range", distance, id)
59+
}
60+
61+
targets = append(targets, entity.SignatureTarget{ID: id, Distance: int32(distance)})
62+
}
63+
return targets, nil
64+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Copyright (c) 2026 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package mysql
16+
17+
import (
18+
"encoding/binary"
19+
"math"
20+
"testing"
21+
22+
"github.com/stretchr/testify/assert"
23+
"github.com/stretchr/testify/require"
24+
25+
"github.com/uber/submitqueue/submitqueue/entity"
26+
)
27+
28+
func TestSignatureTargetsCodec_RoundTrip(t *testing.T) {
29+
tests := []struct {
30+
name string
31+
targets []entity.SignatureTarget
32+
}{
33+
{
34+
name: "empty",
35+
targets: nil,
36+
},
37+
{
38+
name: "single target",
39+
targets: []entity.SignatureTarget{{ID: 1, Distance: 0}},
40+
},
41+
{
42+
name: "several targets in ascending ID order",
43+
targets: []entity.SignatureTarget{
44+
{ID: 0x735cc8dafbb93f02, Distance: 0},
45+
{ID: 0x90afc9166e08f639, Distance: 1},
46+
{ID: 0xfcf7cdab318e139e, Distance: 2},
47+
},
48+
},
49+
{
50+
name: "extreme IDs and distances",
51+
targets: []entity.SignatureTarget{
52+
{ID: 0, Distance: math.MaxInt32},
53+
{ID: math.MaxUint64, Distance: 63},
54+
{ID: math.MaxUint64 - 1, Distance: 64},
55+
},
56+
},
57+
{
58+
name: "negative distance",
59+
targets: []entity.SignatureTarget{{ID: 7, Distance: -1}},
60+
},
61+
}
62+
for _, tt := range tests {
63+
t.Run(tt.name, func(t *testing.T) {
64+
got, err := decodeSignatureTargets(encodeSignatureTargets(tt.targets))
65+
require.NoError(t, err)
66+
assert.Equal(t, tt.targets, got)
67+
})
68+
}
69+
}
70+
71+
func TestEncodeSignatureTargets_EmptyIsNotNil(t *testing.T) {
72+
assert.NotNil(t, encodeSignatureTargets(nil))
73+
assert.Empty(t, encodeSignatureTargets(nil))
74+
}
75+
76+
func TestEncodeSignatureTargets_Layout(t *testing.T) {
77+
got := encodeSignatureTargets([]entity.SignatureTarget{{ID: 0x0102030405060708, Distance: 2}})
78+
79+
assert.Equal(t, []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x04}, got)
80+
}
81+
82+
func TestDecodeSignatureTargets_Invalid(t *testing.T) {
83+
idOnly := binary.BigEndian.AppendUint64(nil, 1)
84+
85+
tests := []struct {
86+
name string
87+
encoded []byte
88+
}{
89+
{
90+
name: "truncated ID",
91+
encoded: []byte{0x01, 0x02, 0x03},
92+
},
93+
{
94+
name: "ID without a distance",
95+
encoded: idOnly,
96+
},
97+
{
98+
name: "unterminated distance varint",
99+
encoded: append(append([]byte{}, idOnly...), 0x80),
100+
},
101+
{
102+
name: "distance outside int32",
103+
encoded: binary.AppendVarint(append([]byte{}, idOnly...), math.MaxInt32+1),
104+
},
105+
{
106+
name: "valid target followed by a truncated one",
107+
encoded: append(binary.AppendVarint(append([]byte{}, idOnly...), 0), 0x01, 0x02),
108+
},
109+
}
110+
for _, tt := range tests {
111+
t.Run(tt.name, func(t *testing.T) {
112+
_, err := decodeSignatureTargets(tt.encoded)
113+
assert.Error(t, err)
114+
})
115+
}
116+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Copyright (c) 2026 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package mysql
16+
17+
import (
18+
"context"
19+
"database/sql"
20+
"errors"
21+
"fmt"
22+
23+
"github.com/go-sql-driver/mysql"
24+
"github.com/uber-go/tally"
25+
26+
"github.com/uber/submitqueue/platform/metrics"
27+
"github.com/uber/submitqueue/submitqueue/entity"
28+
"github.com/uber/submitqueue/submitqueue/extension/storage"
29+
)
30+
31+
type targetSignatureStore struct {
32+
db *sql.DB
33+
scope tally.Scope
34+
// queue is the queue name this store instance is bound to; every read and
35+
// write is scoped to it.
36+
queue string
37+
}
38+
39+
// NewTargetSignatureStore creates a new MySQL-backed TargetSignatureStore.
40+
func NewTargetSignatureStore(db *sql.DB, scope tally.Scope, queue string) storage.TargetSignatureStore {
41+
return &targetSignatureStore{db: db, scope: scope, queue: queue}
42+
}
43+
44+
// Create inserts a signature. Returns ErrAlreadyExists if the batch already has one.
45+
func (s *targetSignatureStore) Create(ctx context.Context, signature entity.TargetSignature) (retErr error) {
46+
op := metrics.Begin(s.scope, "create", metrics.StorageLatencyBuckets)
47+
defer func() { op.Complete(retErr) }()
48+
49+
if signature.Queue != s.queue {
50+
return fmt.Errorf("target signature batch_id=%s queue %q does not match the store's bound queue %q", signature.BatchID, signature.Queue, s.queue)
51+
}
52+
53+
_, err := s.db.ExecContext(ctx,
54+
"INSERT INTO target_signature (queue, batch_id, base_revision, targets, created_at) VALUES (?, ?, ?, ?, ?)",
55+
signature.Queue, signature.BatchID, signature.BaseRevision, encodeSignatureTargets(signature.Targets), signature.CreatedAt,
56+
)
57+
if err != nil {
58+
var mysqlErr *mysql.MySQLError
59+
if errors.As(err, &mysqlErr) && mysqlErr.Number == mysqlErrDuplicateEntry {
60+
return fmt.Errorf("target signature batch_id=%s: %w", signature.BatchID, storage.ErrAlreadyExists)
61+
}
62+
return fmt.Errorf("failed to insert target signature batch_id=%s: %w", signature.BatchID, err)
63+
}
64+
return nil
65+
}
66+
67+
// Get returns the bound queue's signature for batchID. The bool is false when
68+
// the batch has none.
69+
func (s *targetSignatureStore) Get(ctx context.Context, batchID string) (ret entity.TargetSignature, found bool, retErr error) {
70+
op := metrics.Begin(s.scope, "get", metrics.StorageLatencyBuckets)
71+
defer func() { op.Complete(retErr) }()
72+
73+
signature := entity.TargetSignature{Queue: s.queue, BatchID: batchID}
74+
var encodedTargets []byte
75+
err := s.db.QueryRowContext(ctx,
76+
"SELECT base_revision, targets, created_at FROM target_signature WHERE queue = ? AND batch_id = ?",
77+
s.queue, batchID,
78+
).Scan(&signature.BaseRevision, &encodedTargets, &signature.CreatedAt)
79+
if errors.Is(err, sql.ErrNoRows) {
80+
return entity.TargetSignature{}, false, nil
81+
}
82+
if err != nil {
83+
return entity.TargetSignature{}, false, fmt.Errorf("failed to get target signature batch_id=%s from the database: %w", batchID, err)
84+
}
85+
86+
signature.Targets, err = decodeSignatureTargets(encodedTargets)
87+
if err != nil {
88+
return entity.TargetSignature{}, false, fmt.Errorf("failed to decode targets of target signature batch_id=%s: %w", batchID, err)
89+
}
90+
return signature, true, nil
91+
}

0 commit comments

Comments
 (0)