feat: implement the refactored chatd stream loop - #26110
Conversation
Docs preview📖 View docs preview for |
eebc445 to
38dc20b
Compare
0e10473 to
0840442
Compare
0840442 to
965ee76
Compare
38dc20b to
8bd7ca4
Compare
965ee76 to
3bbda07
Compare
8bd7ca4 to
8b3e9f7
Compare
mafredri
left a comment
There was a problem hiding this comment.
I have a couple of files left to review in this PR, but I've looked through most of it.
I will echo sentiment from previous PR review, lacking in documentation, comments and motivation behind choices.
|
|
||
| ctx, cancel := context.WithCancel(ctx) | ||
| defer cancel() | ||
| go httpapi.HeartbeatClose(ctx, e.logger, cancel, conn) |
There was a problem hiding this comment.
Suggestion: Could use WSWatcher directly instead of this old method.
|
|
||
| func (t streamPartsChannelClientTransport) WriteControl(ctx context.Context, control streamPartsControl) error { | ||
| select { | ||
| case <-ctxDone(ctx): |
There was a problem hiding this comment.
It's unusual to design API's that accept nil ctx. Why are we doing it here?
There was a problem hiding this comment.
Address now. That's slop
| select { | ||
| case <-f.configure: | ||
| default: | ||
| } |
There was a problem hiding this comment.
This is inherently racy anyway, so why do we do the select twice with depeltion in-between vs. depleting first and selecting once?
| func (f *streamRelayForwarder) Close() { | ||
| if f == nil { | ||
| return | ||
| } |
There was a problem hiding this comment.
Closing a nil forwarder seems like a bug, and this masks it. Is there any reason to keep these types of checks?
| events := make(chan codersdk.ChatStreamEvent, 128) | ||
| logger := p.logger.With(slog.F("chat_id", chatID)) | ||
|
|
||
| updateCh := make(chan streamSyncHint, 32) |
There was a problem hiding this comment.
I'd like to see motivations for the selected buffer values here (and in other places). To the reader 32 seems quite arbitrary considering we don't even drop any updates.
| } | ||
| ch := make(chan streamSyncHint) | ||
| close(ch) | ||
| return ch, func() {} |
There was a problem hiding this comment.
This feels weird. Is there any reason to runt he server without a streamSyncPoller? If this is for tets then a fakeStreamSyncPoller should be used instead.
| clock := p.clock | ||
| if clock == nil { | ||
| clock = quartz.NewReal() | ||
| } |
There was a problem hiding this comment.
Masking a bug in server startup?
| func (p *streamSyncPoller) Start() { | ||
| if p == nil { | ||
| return | ||
| } |
There was a problem hiding this comment.
This is the last time I'll flag these oddities for this PR, but it's very weird handle these in production code. This smells like production code being adapted for tests rather than restructuring so that tests set up for production.
| hint := streamSyncHintFromPollRow(row) | ||
| for _, subscriber := range subscribers[row.ID] { | ||
| select { | ||
| case subscriber.hints <- hint: |
There was a problem hiding this comment.
A reader might want to know why/if it's fine that hints may have stale data and that we're not trying to deplete it first and send an up-to-date hint.
| return uuid.New() | ||
| } | ||
| return id | ||
| return api.AGPL.ID |
| func (p *Server) syncStreamWithRetry( | ||
| ctx context.Context, | ||
| loop *streamLoop, | ||
| hint streamSyncHint, | ||
| ) ([]codersdk.ChatStreamEvent, streamRelayTarget, bool, error) { | ||
| var ( | ||
| syncEvents []codersdk.ChatStreamEvent | ||
| target streamRelayTarget | ||
| changed bool | ||
| err error | ||
| ) | ||
| for attempt := 1; attempt <= streamSyncRetryMaxAttempts; attempt++ { | ||
| //nolint:gocritic // The subscriber was authorized before the loop started; follow-up syncs need chatd-scoped reads for consistency. | ||
| syncEvents, target, changed, err = loop.sync(dbauthz.AsChatd(ctx), hint) | ||
| if err == nil || ctx.Err() != nil { | ||
| return syncEvents, target, changed, err | ||
| } | ||
| p.logger.Warn(ctx, "failed to sync chat stream", | ||
| slog.F("attempt", attempt), | ||
| slog.Error(err), | ||
| ) | ||
| if attempt == streamSyncRetryMaxAttempts { | ||
| break | ||
| } | ||
| if !p.waitBeforeStreamSyncRetry(ctx, attempt) { | ||
| return nil, loop.currentRelayTarget(), false, ctx.Err() | ||
| } | ||
| } | ||
| return nil, loop.currentRelayTarget(), false, err | ||
| } |
There was a problem hiding this comment.
suggestion: Could we use github.com/coder/retry here instead of the custom backoff loop, with Jitter set? The current syncStreamWithRetry retry schedule is deterministic across subscribers/replicas, so a DB hiccup can synchronize retries. retry.Retrier already supports jitter and context cancellation.
This would not solve the per-subscriber duplicate sync work, but it would reduce thundering-herd behavior during transient DB failures. I’d also add startup jitter to streamSyncPoller, since every chatd instance currently starts a fixed 10s ticker in New().
| slog.F("worker_id", target.workerID.UUID), | ||
| slog.Error(err), | ||
| ) | ||
| scheduleRetry() |
There was a problem hiding this comment.
We should check the kind of dialer error - is it something that a retry might fix or not?
| pollerCh, unregisterPoller := p.registerStreamSyncPoller(chatID) | ||
| loop := newStreamLoop(chat, p.db, logger, afterMessageID) | ||
| //nolint:gocritic // The HTTP route authorizes the chat before subscribing; the stream loop needs chatd-scoped reads for one consistent snapshot. | ||
| initial, target, _, err := loop.syncDB(dbauthz.AsChatd(ctx)) |
There was a problem hiding this comment.
Do we need to call loop.syncDB immediately here?
stream_subscribe.go:62-65creates one streamLoop per subscriber and immediately syncs DBstream_subscribe.go:93-103every subscriber processes pubsub/poller hintsstream_subscribe.go:137-173every subscriber runs syncStreamWithRetrystream_loop.go:98-112every subscriber's streamLoop calls syncDBstream_loop.go:141+syncDB performs actual DB snapshot reads
3bbda07 to
c48520d
Compare
8b3e9f7 to
44cbf6c
Compare
mafredri
left a comment
There was a problem hiding this comment.
I've gone over all the files now, this is my final review on this PR.
| retryTimer = nil | ||
| retryC = nil | ||
| } | ||
| } |
There was a problem hiding this comment.
| } | |
| } | |
| defer stopRetry() |
| session = nil | ||
| sessionParts = nil | ||
| connected = streamRelayTarget{} | ||
| } |
There was a problem hiding this comment.
| } | |
| } | |
| defer closeSession() |
Or alternatively, defer func() { stopRetry(); closeSession(); }() if you want to change the order of these calls.
This allows simplifying the for loop below and protects against leaks in future code changes.
| stopRetry() | ||
| if !target.needsRelay() { | ||
| closeSession() | ||
| continue | ||
| } |
There was a problem hiding this comment.
This is already part of connect, why the repetition?
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
This function seems fragile and I'd love to see it refactored.
c48520d to
2cf1f47
Compare
44cbf6c to
110e90b
Compare
2cf1f47 to
47d102f
Compare
110e90b to
1c07612
Compare
47d102f to
bb37788
Compare
1c07612 to
51648bc
Compare
bb37788 to
fd9d643
Compare
51648bc to
f7a987d
Compare
fd9d643 to
c9de302
Compare
f7a987d to
4f2ce0e
Compare
c9de302 to
b688bfb
Compare
4f2ce0e to
db66ec8
Compare
db66ec8 to
921fd30
Compare
PR 4 of the chatd refactor. Implements the
/chats/$id/streamand/chats/$id/stream/partsendpoints.The CI doesn't pass on purpose. Only the tip of the chatd refactor PR stack will pass CI.
Previous PR in the stack: #26109