Skip to content

Commit cbeae91

Browse files
authored
Merge branch 'main' into backup/redis-zset-encoder
2 parents c9d49e2 + f615443 commit cbeae91

15 files changed

Lines changed: 2749 additions & 6 deletions

.github/workflows/jepsen-test-scheduled.yml

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,28 @@ jobs:
229229
--host 127.0.0.1
230230
- name: Dump demo cluster log on failure
231231
if: failure()
232-
run: tail -n 500 /tmp/elastickv-demo.log || true
232+
# The previous `tail -n 500` truncated a 3-minute workload's
233+
# log down to startup-only lines, making it impossible to
234+
# correlate a Jepsen anomaly with the server-side state
235+
# (start_ts, commit_ts, raft term, write conflicts, lock-
236+
# resolver events). Print head + tail inline so the GH UI
237+
# still shows the most recent activity at-a-glance, then
238+
# upload the full log as an artifact for offline analysis.
239+
run: |
240+
echo "=== first 200 lines (startup) ==="
241+
head -n 200 /tmp/elastickv-demo.log || true
242+
echo "=== last 1000 lines (most recent activity) ==="
243+
tail -n 1000 /tmp/elastickv-demo.log || true
244+
echo "=== full log line count ==="
245+
wc -l /tmp/elastickv-demo.log || true
246+
- name: Upload demo cluster log on failure
247+
if: failure()
248+
uses: actions/upload-artifact@v4
249+
with:
250+
name: elastickv-demo-log
251+
path: /tmp/elastickv-demo.log
252+
retention-days: 14
253+
if-no-files-found: warn
233254
- name: Stop demo cluster
234255
if: always()
235256
run: |

.github/workflows/jepsen-test.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,33 @@ jobs:
194194
--partition-count 4 --group-count 6 \
195195
--drain-time 15 \
196196
--sqs-ports 63501,63502,63503 --host 127.0.0.1
197+
- name: Dump demo cluster logs on failure
198+
if: failure()
199+
# Same rationale as jepsen-test-scheduled.yml: inline summary
200+
# for the GH UI + full per-node logs uploaded as artifact so
201+
# any Jepsen anomaly can be traced to the server-side ts/raft
202+
# state without re-running the workflow.
203+
run: |
204+
for node in n1 n2 n3; do
205+
log=/tmp/elastickv-demo-${node}.log
206+
echo "=== ${node}: first 200 lines (startup) ==="
207+
head -n 200 "$log" || true
208+
echo "=== ${node}: last 500 lines (most recent activity) ==="
209+
tail -n 500 "$log" || true
210+
echo "=== ${node}: full log line count ==="
211+
wc -l "$log" || true
212+
done
213+
- name: Upload demo cluster logs on failure
214+
if: failure()
215+
uses: actions/upload-artifact@v4
216+
with:
217+
name: elastickv-demo-logs
218+
path: |
219+
/tmp/elastickv-demo-n1.log
220+
/tmp/elastickv-demo-n2.log
221+
/tmp/elastickv-demo-n3.log
222+
retention-days: 14
223+
if-no-files-found: warn
197224
- name: Stop demo cluster
198225
if: always()
199226
run: |

adapter/dynamodb_admin.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,16 @@ const (
8383
// future "delete-only" tier reads consistently across the package.
8484
func (r AdminRole) canWrite() bool { return r == AdminRoleFull }
8585

86+
// canRead reports whether the role authorises non-destructive but
87+
// sensitive reads — e.g. SQS AdminPeekQueue, which exposes message
88+
// bodies / attributes. Both AdminRoleReadOnly and AdminRoleFull
89+
// satisfy this gate; the zero value (unauthenticated / role-less
90+
// principal) does not. List / Describe paths use the looser
91+
// session-auth gate because their output is queue metadata already
92+
// shown on the SPA list page; peek is divergent because the payload
93+
// is the message bodies themselves.
94+
func (r AdminRole) canRead() bool { return r == AdminRoleReadOnly || r == AdminRoleFull }
95+
8696
// AdminPrincipal is the authentication context every admin write
8797
// entrypoint takes. The adapter re-evaluates authorisation against
8898
// this principal *itself* — it does not trust the caller to have

adapter/redis.go

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -355,12 +355,62 @@ func isTransientLeaderRedisError(err error) bool {
355355
if err == nil {
356356
return false
357357
}
358-
return errors.Is(err, ErrLeaderNotFound) ||
358+
if errors.Is(err, ErrLeaderNotFound) ||
359359
errors.Is(err, ErrNotLeader) ||
360360
errors.Is(err, kv.ErrLeaderNotFound) ||
361361
errors.Is(err, raftengine.ErrNotLeader) ||
362362
errors.Is(err, raftengine.ErrLeadershipLost) ||
363-
errors.Is(err, raftengine.ErrLeadershipTransferInProgress)
363+
errors.Is(err, raftengine.ErrLeadershipTransferInProgress) {
364+
return true
365+
}
366+
// Suffix fallback for gRPC-wrapped sentinels. When the coordinator
367+
// forwards a request to a remote leader via the operational gRPC
368+
// service and that leader returns ErrLeaderNotFound, the status
369+
// interceptor flattens the error to "rpc error: code = Unknown
370+
// desc = leader not found"; the typed sentinel chain is stripped
371+
// at the wire boundary, so errors.Is misses it. The Jepsen Redis
372+
// workload (scheduled run 26035515694) saw workers crash with
373+
// `:prefix :rpc` because the un-prefixed `"rpc error: …"` string
374+
// reached Carmine. Match the same closed phrase set
375+
// kv.hasTransientLeaderPhrase uses, with the same HasSuffix
376+
// guard: free-form Contains would misclassify a user-controlled
377+
// key like "key: not leader: write conflict" as transient.
378+
return hasTransientLeaderSuffix(err.Error())
379+
}
380+
381+
// redisLeaderErrorPhrases mirrors kv.leaderErrorPhrases (the kv
382+
// package keeps it unexported). Any new transient-leader phrase the
383+
// kv layer treats as retryable should also flip a NOTLEADER prefix
384+
// on the Redis wire so Carmine's with-exceptions catches it; keep
385+
// in lockstep.
386+
var redisLeaderErrorPhrases = []string{
387+
"not leader",
388+
"leader not found",
389+
"leadership lost",
390+
"leadership transfer in progress",
391+
}
392+
393+
// hasTransientLeaderSuffix is the suffix-match fallback for
394+
// isTransientLeaderRedisError. Suffix — not free-form Contains —
395+
// because cockroachdb/errors %w-prefix and gRPC status.Errorf's
396+
// "rpc error: code = X desc = <orig>" both leave the original
397+
// sentinel text at the END of the composed string; a Contains
398+
// match would tag a user-controlled key like "key: not leader:
399+
// conflict" as transient.
400+
//
401+
// strings.EqualFold on the trailing slice — rather than
402+
// strings.ToLower + HasSuffix — avoids allocating a copy of the
403+
// full message. cockroachdb/errors messages can be multi-KB when
404+
// they carry a serialized stack trace; this matters on the error
405+
// path under leader-loss storms.
406+
func hasTransientLeaderSuffix(msg string) bool {
407+
for _, phrase := range redisLeaderErrorPhrases {
408+
if len(msg) >= len(phrase) &&
409+
strings.EqualFold(msg[len(msg)-len(phrase):], phrase) {
410+
return true
411+
}
412+
}
413+
return false
364414
}
365415

366416
func (c *redisMetricsConn) reset(conn redcon.Conn) {

adapter/redis_error_prefix_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,28 @@ func TestIsTransientLeaderRedisError(t *testing.T) {
4343
{"unrelated error", io.EOF, false},
4444
{"nil", nil, false},
4545
{"wrong-type-looking error", errors.New("WRONGTYPE op"), false},
46+
// Suffix-fallback regression cases. These cover the gRPC-
47+
// boundary path: when the coordinator forwards to a remote
48+
// leader and the remote returns ErrLeaderNotFound, gRPC
49+
// flattens it to "rpc error: code = X desc = leader not
50+
// found"; the typed sentinel chain is gone. The Jepsen
51+
// Redis workload (scheduled run 26035515694) saw workers
52+
// crash with `:prefix :rpc` because this case was not
53+
// classified as transient and the raw "rpc error: …"
54+
// reached Carmine.
55+
{"grpc-wrapped leader not found",
56+
errors.New("rpc error: code = Unknown desc = leader not found"), true},
57+
{"grpc-wrapped not leader",
58+
errors.New("rpc error: code = FailedPrecondition desc = raft engine: not leader"), true},
59+
{"grpc-wrapped leadership lost",
60+
errors.New("rpc error: code = Aborted desc = raft engine: leadership lost"), true},
61+
{"grpc-wrapped leadership transfer",
62+
errors.New("rpc error: code = Aborted desc = raft engine: leadership transfer in progress"), true},
63+
// Suffix discipline: a user-controlled key in the middle
64+
// of the message must NOT trigger a false positive. The kv
65+
// suffix matcher pins this exact scenario; mirror it here.
66+
{"user key embedding 'not leader' in the middle",
67+
errors.New("key: not leader: write conflict"), false},
4668
}
4769
for _, tc := range cases {
4870
t.Run(tc.name, func(t *testing.T) {
@@ -74,6 +96,15 @@ func TestWriteRedisError(t *testing.T) {
7496
errors.New("WRONGTYPE op"), "WRONGTYPE op"},
7597
{"generic io.EOF untouched",
7698
io.EOF, io.EOF.Error()},
99+
// Suffix-fallback wire reply regression: the gRPC-wrapped
100+
// "rpc error: code = Unknown desc = leader not found" string
101+
// (the failure mode behind scheduled run 26035515694) must
102+
// gain a NOTLEADER prefix on the Redis wire so Carmine maps
103+
// it to `:prefix :notleader` and the upstream
104+
// jepsen-io/redis with-exceptions catch fires.
105+
{"grpc-wrapped leader-not-found gains NOTLEADER prefix",
106+
errors.New("rpc error: code = Unknown desc = leader not found"),
107+
"NOTLEADER rpc error: code = Unknown desc = leader not found"},
77108
// Regression: address-mapping gap errors (raft leader known
78109
// but raft→redis address missing in r.leaderRedis) must be
79110
// ERR-prefixed at the source so Carmine maps to :prefix :err
@@ -99,3 +130,45 @@ func TestWriteRedisError(t *testing.T) {
99130
})
100131
}
101132
}
133+
134+
// TestHasTransientLeaderSuffix_PinsSentinels closes the gap noted
135+
// at kv/coordinator.go:529 ("A symmetric pin lives in the adapter
136+
// test package"): the adapter's redisLeaderErrorPhrases set must
137+
// stay in sync with the actual .Error() text of every transient-
138+
// leader sentinel the suffix fallback is meant to catch. If a
139+
// sentinel ever gets renamed (e.g. raftengine.ErrLeadershipLost
140+
// becomes "raft engine: leadership lost (xyz)") the kv-side pin
141+
// fails first, but without this adapter-side pin the adapter's
142+
// phrase list could drift silently and the NOTLEADER classification
143+
// would regress to the pre-PR-789 worker-crash failure mode.
144+
//
145+
// Each case calls hasTransientLeaderSuffix(sentinel.Error()) and
146+
// asserts true. Wrapping (errors.Wrap / fmt.Errorf %w) is covered
147+
// by TestIsTransientLeaderRedisError; this test pins the raw
148+
// .Error() strings only.
149+
func TestHasTransientLeaderSuffix_PinsSentinels(t *testing.T) {
150+
t.Parallel()
151+
cases := []struct {
152+
name string
153+
msg string
154+
}{
155+
{"adapter.ErrLeaderNotFound", ErrLeaderNotFound.Error()},
156+
{"adapter.ErrNotLeader", ErrNotLeader.Error()},
157+
{"kv.ErrLeaderNotFound", kv.ErrLeaderNotFound.Error()},
158+
{"raftengine.ErrNotLeader", raftengine.ErrNotLeader.Error()},
159+
{"raftengine.ErrLeadershipLost", raftengine.ErrLeadershipLost.Error()},
160+
{"raftengine.ErrLeadershipTransferInProgress",
161+
raftengine.ErrLeadershipTransferInProgress.Error()},
162+
}
163+
for _, tc := range cases {
164+
t.Run(tc.name, func(t *testing.T) {
165+
t.Parallel()
166+
if !hasTransientLeaderSuffix(tc.msg) {
167+
t.Fatalf("hasTransientLeaderSuffix(%q) = false; "+
168+
"redisLeaderErrorPhrases is out of sync with %s — "+
169+
"a sentinel rename slipped through. Update the "+
170+
"phrase list in adapter/redis.go to match.", tc.msg, tc.name)
171+
}
172+
})
173+
}
174+
}

0 commit comments

Comments
 (0)