Repository navigation
atepg: Switch to Serializable isolation level for transactions by default - #3475
Taahir Ahmed (ahmedtd) wants to merge 1 commit into
Conversation
a3308af to
4bd9d99
Compare
| defer mapSerializationFailure(&err) | ||
| if err := precondition.Validate(); err != nil { | ||
| return nil, err | ||
| return nil, fmt.Errorf("in precondition.Validate", err) |
There was a problem hiding this comment.
should fix the Errorf without format. I assume this is meant to be:
| return nil, fmt.Errorf("in precondition.Validate", err) | |
| return nil, fmt.Errorf("in precondition.Validate: %w", err) |
There was a problem hiding this comment.
Fixed
|
|
4bd9d99 to
cb67583
Compare
| // write. Exported methods defer it on their named error result. | ||
| func mapSerializationFailure(err *error) { | ||
| if pgErrCode(*err) == "40001" { | ||
| *err = fmt.Errorf("%w: %v", store.ErrVersionConflict, *err) |
There was a problem hiding this comment.
🤖 blocking 🔴 – Only store.ErrVersionConflict is wrapped here. The %v drops the *pgconn.PgError, so the new TestMapSerializationFailure fails on the serialization row ("no longer wraps"). Use %w for both: fmt.Errorf("%w: %w", store.ErrVersionConflict, *err).
There was a problem hiding this comment.
You can't wrap two errors, can you?
There was a problem hiding this comment.
Huh, you can. TIL
| if err != nil { | ||
| // The snapshot's node is gone, and atelet found no upload of it. | ||
| if ctx.Err() == nil && status.Code(err) == codes.NotFound && localElsewhere(snap, assignment.GetNodeName()) { | ||
| if ctx.Err() == nil && apierror.Code(err) == codes.NotFound && localElsewhere(snap, assignment.GetNodeName()) { |
There was a problem hiding this comment.
🤖 blocking 🔴 – apierror.Code never reads a gRPC status, so an atelet NotFound from client.Restore now maps to Internal and this branch never fires. TestResumeActor_NodeGoneWithPendingUpload fails because of it. Please keep status.Code(err) here.
| return true, nil | ||
| } | ||
| if errors.Is(attemptErr, store.ErrVersionConflict) || errors.Is(attemptErr, errWorkerFilledUp) { | ||
| if apierror.Code(err) == codes.Aborted || errors.Is(err, store.ErrVersionConflict) || errors.Is(err, errWorkerFilledUp) { |
There was a problem hiding this comment.
🤖 blocking 🔴 – Retrying on codes.Aborted also retries terminal errors. assignWorkerAttempt returns Aborted for "actor is X and can no longer be resumed", and that should end the resume. With this change it retries 12 times and then reports a version conflict.
| default: | ||
| // assignWorkerAttempt recorded this one itself. | ||
| return nil, nil, err | ||
| if wait.Interrupted(err) || apierror.Code(err) == codes.Aborted || errors.Is(err, store.ErrVersionConflict) { |
There was a problem hiding this comment.
🤖 blocking 🔴 – wait.Interrupted also matches context.Canceled and DeadlineExceeded. A cancelled or timed-out resume now returns Aborted wrapping only ErrVersionConflict, and the context error is lost. ctxErr is never set any more, so recordExhaustedAssignment always gets nil. A terminal Aborted is also recorded twice, once in the attempt and again here. The existing TestEnsureWorkerAssigned_Cancelled*, _ContextEndsInsideAttemptRecordsOne and _ExhaustedBackoffRecordsOneError tests fail. The removed exhausted/ctxErr/attempted handling covered these cases.
There was a problem hiding this comment.
This code was crazy complex, bordering on un-understandable. I will see if I can clean up so that recordExhaustedAssignment is only done in one place.
| Factor: 2.0, | ||
| Jitter: 1.0, | ||
| Cap: 250 * time.Millisecond, | ||
| //Cap: 30000 * time.Millisecond, |
There was a problem hiding this comment.
🤖 should-fix 🟡 – Please remove the commented-out Cap. Without a cap, 12 steps from 1ms with jitter can sleep for about 4 to 8 s. The comment above still says "reaches Cap after 5 attempts" and "about 0.5 to 1 s". finalizeRunning reuses this backoff too, so the comment should cover both uses.
There was a problem hiding this comment.
A cap is unneeded. I will fix up the comments.
| return nil, false, fmt.Errorf("while ensuring atelet is restored: %w", err) | ||
| } | ||
| var running *ateapi.Actor | ||
| // TODO(ahmedtd): We are failing with concurrent update conflict here. |
There was a problem hiding this comment.
🤖 should-fix 🟡 – This debugging note should go before merge. If finalize still conflicts, please fix it or file an issue.
There was a problem hiding this comment.
Done.
| return false, apierror.Internal("worker %s reported epoch %d, above its registered epoch %d", workerName, workerEpoch, registered) | ||
| } | ||
|
|
||
| storedActor, err = w.store.UpdateActor(ctx, actorRef, store.PreconditionFrom(latestActor), func(toUpdate *ateapi.Actor) error { |
There was a problem hiding this comment.
🤖 should-fix 🟡 – Each retry re-reads the actor and sets it RUNNING without checking its state. Before this change a conflict aborted the resume. Now a concurrent write that moved the actor out of RESUMING, such as a crash, gets overwritten. Please check that latestActor is still RESUMING on the expected worker before updating.
| if err != nil { | ||
| if errors.Is(err, store.ErrVersionConflict) { | ||
| return nil, apierror.Aborted("concurrent update conflict, please retry") | ||
| return nil, apierror.Aborted("concurrent update conflict, please retry: in store.UpdateActor: %w", err) |
There was a problem hiding this comment.
🤖 should-fix 🟡 – apierror messages go to the client as-is, so %w here sends the internal error chain, including PostgreSQL error text, to API callers. The same applies to the NotFound and InvalidArgument cases below. Please keep the client message fixed, or log the cause instead.
There was a problem hiding this comment.
We need to improve the api error handling for this. Right now, this wrapping is necessary for the full error to reach the RPC log interceptor's logging of the error, which is sacred (it is very difficult to operate the system if you cannot actually determine what caused an error)/
If we want to hide error details from callers, that's fine, but it needs to be done in a way that does not erase the full error context for the RPC logging interceptor.
There was a problem hiding this comment.
Discussed with Julian --- post freeze we will make some changes to the apierror package so that it can separately track the full error chain and an "opaque" message that will be presented to the caller.
| w.recordExhaustedAssignment(ctx, lastAttempt, actor, ctxErr) | ||
| return nil, nil, apierror.Aborted("while running retry loop for assignment: concurrent update conflict, please retry: %w", store.ErrVersionConflict) | ||
| } else if err != nil { | ||
| return nil, nil, fmt.Errorf("while running retry loop for assigment: %w", err) |
There was a problem hiding this comment.
🤖 nit 🟢 – Typos: "assigment" here and "ufpdating" at line 547.
There was a problem hiding this comment.
Fixed.
|
I have a few concerns about necessity, timing, and cost. Necessity I couldn't find a concrete anomaly that Read Committed causes in
I didn't find any transaction that reads with a plain Timing I don't think this needs to ship in v1.0. It changes no protos, CRDs, flags, or migrations. Measuring the impact Serializable adds aborts and predicate-lock overhead. Clients can now get
In addition to |
|
I agree with haiyanmeng here. At very least we need to quantify the performance impact of a change like this. We have Isolate now to benchmark and compare. WRT timing, I would like to have time to go deeper into the correctness concerns. I feel like we should not rush this and revisit it after v1. |
I don't believe this framing is correct. We have started by doing something wildly unsafe (assuming we can properly code against Read Committed). Benchmarking should not block switching to a safer model. |
The idea of this PR is current and future looking. Concurrency race conditions are very hard to debug in production as more code (most of it is AI generated) gets added in different paths. It's better to follow safe practice before rather than having an issue in production and then changing it. In addition to that, we shouldn't be compromising correctness for performance, every single time a new feature is added. We should be delivering feature and then optimizing. |
b91c6cd to
97c0470
Compare
As discussed in the community meeting, and in issue #3044, we need to switch to using Serializable isolation by default before 1.0.
Summarizing the arguments so that they are available in the commit history:
Individual code paths can use downgraded consistency levels once we have audited their use of locking or other correctness strategies. Maintaining this auditing will be a heavy ongoing commitment, so we should only do it in places where it truly matters for performance (which might end up being nowhere).
The primary side-effect of upgrading from Read Committed to Serializable is that, when there are n racing transactions that touch the same rows, it is possible for all but one of them to fail with a conflict error. The losing clients must then retry their read-modify-write loops.
Most of the complication in this change comes from ResumeActor, which is covered by a test (TestResumeActor_ConcurrentOntoOneWorker) that checks that, of 128 concurrently resumed actors targeting a single worker, none fail with a conflict error. This test seems like a bad idea, it will always be possible for it to flake. And as a matter of system/production hygiene, retry loops should almost always be driven from the outermost client (the client calling the Substrate API in this case). However, I've preserved the existing behavior for now, which required adding some additional retry loops in the workflow, and cleaning up some error wrapping so that store-layer conflict errors properly reach the workflow code.
Fixes #3044
AI use: AI-assisted, I reviewed, heavily edited, tested, and debugged.
This change is not breaking. Enjoy the same great Substrate flavor you know and love, with fewer heart-stopping data-loss incidents.