Skip to content

Commit 1cde2ef

Browse files
sbalabanov-zzsbalabanov
authored andcommitted
fix(storage): classify version mismatch as retryable
1 parent 4962674 commit 1cde2ef

19 files changed

Lines changed: 130 additions & 50 deletions

File tree

doc/rfc/stovepipe/steps/buildsignal.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ For a delivery carrying build id `B`:
4747
continue with the STORED status as authoritative (see Edge cases).
4848
- otherwise persist via BuildStore.Update(ctx, Build{...Status: status}, oldVersion, newVersion):
4949
- newVersion = Build.Version + 1; assign Build.Version = newVersion only on success.
50-
- ErrVersionMismatch -> retryable (a concurrent writer moved the row; reload and re-check).
50+
- ErrVersionMismatch -> return with its declaration-level retryable classification (a concurrent writer moved the row; reload and re-check).
5151
- with the write-once rule, accepted -> running -> {succeeded|failed|cancelled} is monotonic
5252
by mechanism, not by assumption about the backend.
5353
@@ -66,7 +66,7 @@ For a delivery carrying build id `B`:
6666

6767
**Why `record` hears only terminal signals**: `record` has no non-terminal work — by its own contract a non-terminal signal would be a pure no-op — and step 7 already branches on terminality to decide whether to keep polling, so gating the publish costs nothing and spares `record` a no-op delivery on every poll tick of every running build. Crash-safety is unaffected: a crash between the terminal `Update` and the publish redelivers the message; step 5 re-polls (the runner reports the same terminal status), step 6 no-ops, step 7 publishes. This is a deliberate divergence from SubmitQueue, whose buildsignal republishes to `speculate` on every tick — sound there because speculate is a state machine that may act on any signal; stovepipe has no such consumer.
6868

69-
**Why step 6 guards on status and makes terminal write-once**: an unchanged status skips the CAS write entirely, so a long build being polled every couple of seconds doesn't churn `Build.Version` on every tick — the version only advances on a real state transition. The write-once rule exists because CAS alone cannot provide it: optimistic locking defends against *concurrent* writers, but a later delivery that polls a flaky backend and sees a different terminal status would CAS cleanly against the current version and overwrite (see Edge cases). A given `Build` has a single poll partition (see [Partitioning](doc/rfc/stovepipe/steps/build.md#partitioning)), so the only writer racing the CAS is a redelivery of the same message (e.g. after a lapsed visibility lease); `ErrVersionMismatch` there is handled as retryable and converges.
69+
**Why step 6 guards on status and makes terminal write-once**: an unchanged status skips the CAS write entirely, so a long build being polled every couple of seconds doesn't churn `Build.Version` on every tick — the version only advances on a real state transition. The write-once rule exists because CAS alone cannot provide it: optimistic locking defends against *concurrent* writers, but a later delivery that polls a flaky backend and sees a different terminal status would CAS cleanly against the current version and overwrite (see Edge cases). A given `Build` has a single poll partition (see [Partitioning](doc/rfc/stovepipe/steps/build.md#partitioning)), so the only writer racing the CAS is a redelivery of the same message (e.g. after a lapsed visibility lease); `ErrVersionMismatch` carries a retryable classification and converges on redelivery.
7070

7171
## Status: shaped like SubmitQueue's, not shared code
7272

@@ -101,7 +101,7 @@ Per `platform/errs`'s non-retryable-by-default rule (see [platform/errs/README.m
101101
| Failure | Disposition | Why |
102102
|---|---|---|
103103
| `Status` call | raw error; classifier decides | Deliberately left open rather than fixed either way — runner timeout/connection is transient, "runner not deployed for this queue" is not, and only a backend classifier can tell them apart. |
104-
| `Update` CAS conflict (`ErrVersionMismatch`) | retryable | A concurrent (redelivered) writer moved the row; reload and re-check converges. |
104+
| `Update` CAS conflict (`ErrVersionMismatch`) | declaration-level retryable | A concurrent (redelivered) writer moved the row; reload and re-check converges. |
105105
| `PublishAfter` re-poll | retryable | The poll heartbeat; it runs only after status/persist/record all succeeded, so a transient enqueue blip is worth replaying to `MaxAttempts` before dead-lettering. |
106106

107107
`Build`/`Request` not found (`storage.ErrNotFound`) are **not** in this table: storage is required to be read-after-write consistent (see [storage README](stovepipe/extension/storage/README.md)), so a miss here is already the correct default (non-retryable, straight to DLQ) rather than a departure worth overriding.

platform/errs/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,11 +135,11 @@ The controller-override path is for the rare case where the controller has certa
135135

136136
In particular, **do not reach for `NewRetryableError` just because replaying the message would be convenient.** A failed queue publish, a failed enqueue, a "the hand-off that keeps this alive" step — these are *not* a license to mark the error retryable. Whether such a failure is transient is exactly what a classifier exists to decide: a transport-level classifier wraps genuine connection/timeout blips as retryable, while a malformed-request or permission failure stays non-retryable and dead-letters instead of replaying forever. Blanket `NewRetryableError` on a publish path defeats that and turns every permanent failure into an infinite retry loop.
137137

138-
## Extensions Return Plain Go Errors
138+
## Extensions Return Go Errors
139139

140-
Extension interfaces (`MergeChecker`, `Storage`, `Publisher`) return standard `error` values. They may define their own domain-specific sentinel errors (e.g. `storage.ErrNotFound`, `storage.ErrVersionMismatch`) but they do **not** classify errors as user or infra — that is the controller's (and the consumer's `ErrorProcessor`'s) job.
140+
Extension interfaces (`MergeChecker`, `Storage`, `Publisher`) return `error` values and may define domain-specific sentinels. Most sentinels remain unclassified because their meaning depends on the call site; for example, `storage.ErrNotFound` might be a user error in one controller and an infrastructure error in another. A sentinel whose classification is intrinsic in every context may carry that classification at its declaration. `storage.ErrVersionMismatch`, for example, is always a retryable infrastructure error because it reports a lost optimistic-concurrency race.
141141

142-
This separation keeps extensions reusable across contexts. The same `storage.ErrNotFound` might be a user error in one controller (user requested a non-existent resource) and an infra error in another (expected record is missing).
142+
Controllers should return intrinsically classified sentinels without adding another framework wrapper. The declaration remains reusable across implementations while every caller observes the same classification.
143143

144144
## Error Chain Compatibility
145145

stovepipe/controller/buildsignal/buildsignal.go

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ package buildsignal
2121

2222
import (
2323
"context"
24-
"errors"
2524
"fmt"
2625

2726
"github.com/uber-go/tally"
@@ -185,9 +184,6 @@ func (c *Controller) reconcile(ctx context.Context, build entity.Build, status e
185184
updated := build
186185
updated.Status = status
187186
if err := c.store.GetBuildStore().Update(ctx, updated, build.Version, newVersion); err != nil {
188-
if errors.Is(err, storage.ErrVersionMismatch) {
189-
return "", errs.NewRetryableError(fmt.Errorf("build %s version conflict: %w", build.ID, err))
190-
}
191187
return "", fmt.Errorf("failed to persist status for build %s: %w", build.ID, err)
192188
}
193189
return status, nil

stovepipe/extension/storage/BUILD.bazel

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
load("@rules_go//go:def.bzl", "go_library")
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
22

33
go_library(
44
name = "go_default_library",
@@ -11,5 +11,18 @@ go_library(
1111
],
1212
importpath = "github.com/uber/submitqueue/stovepipe/extension/storage",
1313
visibility = ["//visibility:public"],
14-
deps = ["//stovepipe/entity:go_default_library"],
14+
deps = [
15+
"//platform/errs:go_default_library",
16+
"//stovepipe/entity:go_default_library",
17+
],
18+
)
19+
20+
go_test(
21+
name = "go_default_test",
22+
srcs = ["storage_test.go"],
23+
embed = [":go_default_library"],
24+
deps = [
25+
"//platform/errs:go_default_library",
26+
"@com_github_stretchr_testify//assert:go_default_library",
27+
],
1528
)

stovepipe/extension/storage/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Storage
22

3-
Pluggable persistence interfaces for Stovepipe entities (`RequestStore`, `RequestURIStore`, `QueueStore`, `BuildStore`). Implementations live under `extension/storage/<impl>/`. This is a separate contract from `submitqueue/extension/storage` — same shape and conventions by design, but its own interfaces and its own `ErrNotFound`/`ErrAlreadyExists`/`ErrVersionMismatch` sentinels, since Stovepipe and SubmitQueue are independent domains.
3+
Pluggable persistence interfaces for Stovepipe entities (`RequestStore`, `RequestURIStore`, `QueueStore`, `BuildStore`). Implementations live under `extension/storage/<impl>/`. This is a separate contract from `submitqueue/extension/storage` — same shape and conventions by design, but its own interfaces and its own `ErrNotFound`/`ErrAlreadyExists`/`ErrVersionMismatch` sentinels, since Stovepipe and SubmitQueue are independent domains. `ErrVersionMismatch` is declared as a retryable infrastructure error so callers can return it without reclassifying it.
44

55
## Optimistic locking contract
66

stovepipe/extension/storage/storage.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ package storage
1919
import (
2020
"errors"
2121
"fmt"
22+
23+
"github.com/uber/submitqueue/platform/errs"
2224
)
2325

2426
// ErrNotFound is returned by storage implementations when the requested record is not found in the database.
@@ -39,8 +41,8 @@ var ErrAlreadyExists = errors.New("record already exists")
3941

4042
// ErrVersionMismatch is returned by storage implementations when a conditional (CAS) update finds that
4143
// the stored version does not match the expected version. It backs optimistic locking, letting callers
42-
// retry or converge instead of overwriting a concurrent change.
43-
var ErrVersionMismatch = errors.New("version mismatch")
44+
// retry or converge instead of overwriting a concurrent change. It is intrinsically a retryable infrastructure error.
45+
var ErrVersionMismatch = errs.NewRetryableError(errors.New("version mismatch"))
4446

4547
// Storage is a factory interface that aggregates all entity stores into a single injectable dependency.
4648
type Storage interface {
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) 2025 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 storage
16+
17+
import (
18+
"fmt"
19+
"testing"
20+
21+
"github.com/stretchr/testify/assert"
22+
"github.com/uber/submitqueue/platform/errs"
23+
)
24+
25+
func TestErrVersionMismatchClassification(t *testing.T) {
26+
err := fmt.Errorf("update request: %w", ErrVersionMismatch)
27+
28+
assert.ErrorIs(t, err, ErrVersionMismatch)
29+
assert.True(t, errs.IsRetryable(err))
30+
assert.False(t, errs.IsUserError(err))
31+
assert.False(t, errs.IsDependencyError(err))
32+
}

submitqueue/extension/storage/BUILD.bazel

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
load("@rules_go//go:def.bzl", "go_library")
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
22

33
go_library(
44
name = "go_default_library",
@@ -16,5 +16,18 @@ go_library(
1616
],
1717
importpath = "github.com/uber/submitqueue/submitqueue/extension/storage",
1818
visibility = ["//visibility:public"],
19-
deps = ["//submitqueue/entity:go_default_library"],
19+
deps = [
20+
"//platform/errs:go_default_library",
21+
"//submitqueue/entity:go_default_library",
22+
],
23+
)
24+
25+
go_test(
26+
name = "go_default_test",
27+
srcs = ["storage_test.go"],
28+
embed = [":go_default_library"],
29+
deps = [
30+
"//platform/errs:go_default_library",
31+
"@com_github_stretchr_testify//assert:go_default_library",
32+
],
2033
)

submitqueue/extension/storage/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Pluggable persistence interfaces for SubmitQueue entities (requests, batches, de
44

55
## Optimistic locking contract
66

7-
Entities that support concurrent mutation carry an `int32 Version` field. Updates are conditional on the version: the write only succeeds if the persisted version matches the caller's expected version. On mismatch, the implementation returns `storage.ErrVersionMismatch`.
7+
Entities that support concurrent mutation carry an `int32 Version` field. Updates are conditional on the version: the write only succeeds if the persisted version matches the caller's expected version. On mismatch, the implementation returns `storage.ErrVersionMismatch`, which is declared as a retryable infrastructure error so callers can return it without reclassifying it.
88

99
**Version arithmetic is owned by the controller, not the store.** Update methods take both `oldVersion` (the where-clause guard) and `newVersion` (the value to write):
1010

submitqueue/extension/storage/storage.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ package storage
1919
import (
2020
"errors"
2121
"fmt"
22+
23+
"github.com/uber/submitqueue/platform/errs"
2224
)
2325

2426
// ErrNotFound is returned by storage implementations when the requested record is not found in the database.
@@ -39,8 +41,8 @@ var ErrAlreadyExists = errors.New("record already exists")
3941

4042
// ErrVersionMismatch is returned by storage implementations when the expected entity version does not match the current version of the object.
4143
// This is used to implement an optimistic locking mechanism, allowing multiple clients to update the same entity concurrently
42-
// and either retry or implement idempotent operations.
43-
var ErrVersionMismatch = errors.New("version mismatch")
44+
// and either retry or implement idempotent operations. It is intrinsically a retryable infrastructure error.
45+
var ErrVersionMismatch = errs.NewRetryableError(errors.New("version mismatch"))
4446

4547
// Storage is a factory interface that aggregates all entity stores into a single injectable dependency.
4648
type Storage interface {

0 commit comments

Comments
 (0)