🌐 US-Proxy
class="logged-out env-production page-responsive" style="word-wrap: break-word;" >
Skip to content

feat: implement the refactored chatd stream loop - #26110

Merged
hugodutka merged 1 commit into
hugodutka/chatd-refactorfrom
hugodutka/chatd-refactor-4
Jun 11, 2026
Merged

feat: implement the refactored chatd stream loop#26110
hugodutka merged 1 commit into
hugodutka/chatd-refactorfrom
hugodutka/chatd-refactor-4

Conversation

@hugodutka

@hugodutka hugodutka commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

PR 4 of the chatd refactor. Implements the /chats/$id/stream and /chats/$id/stream/parts endpoints.

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

@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown

Docs preview

📖 View docs preview for docs/admin/integrations/prometheus.md

@hugodutka
hugodutka force-pushed the hugodutka/chatd-refactor-4 branch from eebc445 to 38dc20b Compare June 5, 2026 18:05
@hugodutka
hugodutka force-pushed the hugodutka/chatd-refactor-3 branch from 0e10473 to 0840442 Compare June 5, 2026 18:05
@hugodutka hugodutka changed the title pr 4 feat: implement the refactored chatd stream loop Jun 8, 2026
@hugodutka
hugodutka marked this pull request as ready for review June 8, 2026 12:39
@hugodutka
hugodutka requested review from johnstcn and mafredri June 8, 2026 12:39
@hugodutka
hugodutka force-pushed the hugodutka/chatd-refactor-3 branch from 0840442 to 965ee76 Compare June 8, 2026 13:07
@hugodutka
hugodutka force-pushed the hugodutka/chatd-refactor-4 branch from 38dc20b to 8bd7ca4 Compare June 8, 2026 13:07
@hugodutka
hugodutka force-pushed the hugodutka/chatd-refactor-3 branch from 965ee76 to 3bbda07 Compare June 9, 2026 12:03
@hugodutka
hugodutka force-pushed the hugodutka/chatd-refactor-4 branch from 8bd7ca4 to 8b3e9f7 Compare June 9, 2026 12:03

@mafredri mafredri left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Could use WSWatcher directly instead of this old method.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred.


func (t streamPartsChannelClientTransport) WriteControl(ctx context.Context, control streamPartsControl) error {
select {
case <-ctxDone(ctx):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's unusual to design API's that accept nil ctx. Why are we doing it here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Address now. That's slop

Comment thread coderd/x/chatd/stream_relay.go Outdated
Comment on lines +78 to +81
select {
case <-f.configure:
default:
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is inherently racy anyway, so why do we do the select twice with depeltion in-between vs. depleting first and selecting once?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Address now

func (f *streamRelayForwarder) Close() {
if f == nil {
return
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Closing a nil forwarder seems like a bug, and this masks it. Is there any reason to keep these types of checks?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred.

events := make(chan codersdk.ChatStreamEvent, 128)
logger := p.logger.With(slog.F("chat_id", chatID))

updateCh := make(chan streamSyncHint, 32)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred.

Comment thread coderd/x/chatd/stream_subscribe.go Outdated
}
ch := make(chan streamSyncHint)
close(ch)
return ch, func() {}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Address now

Comment thread coderd/x/chatd/stream_subscribe.go Outdated
Comment on lines +192 to +195
clock := p.clock
if clock == nil {
clock = quartz.NewReal()
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Masking a bug in server startup?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Address now

func (p *streamSyncPoller) Start() {
if p == nil {
return
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred.

hint := streamSyncHintFromPollRow(row)
for _, subscriber := range subscribers[row.ID] {
select {
case subscriber.hints <- hint:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred.

return uuid.New()
}
return id
return api.AGPL.ID

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this change correct?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Address now

Comment on lines +160 to +189
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
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred.

Comment thread coderd/x/chatd/stream_relay.go Outdated
slog.F("worker_id", target.workerID.UUID),
slog.Error(err),
)
scheduleRetry()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should check the kind of dialer error - is it something that a retry might fix or not?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Address now

Comment thread coderd/x/chatd/stream_subscribe.go Outdated
Comment on lines +62 to +65
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))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to call loop.syncDB immediately here?

  • stream_subscribe.go:62-65 creates one streamLoop per subscriber and immediately syncs DB
  • stream_subscribe.go:93-103 every subscriber processes pubsub/poller hints
  • stream_subscribe.go:137-173 every subscriber runs syncStreamWithRetry
  • stream_loop.go:98-112 every subscriber's streamLoop calls syncDB
  • stream_loop.go:141+ syncDB performs actual DB snapshot reads

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Address now

@hugodutka
hugodutka force-pushed the hugodutka/chatd-refactor-3 branch from 3bbda07 to c48520d Compare June 9, 2026 16:32
@hugodutka
hugodutka force-pushed the hugodutka/chatd-refactor-4 branch from 8b3e9f7 to 44cbf6c Compare June 9, 2026 16:32

@mafredri mafredri left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've gone over all the files now, this is my final review on this PR.

retryTimer = nil
retryC = nil
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
}
}
defer stopRetry()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Address now

session = nil
sessionParts = nil
connected = streamRelayTarget{}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
}
}
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Address now

Comment thread coderd/x/chatd/stream_relay.go Outdated
Comment on lines +206 to +210
stopRetry()
if !target.needsRelay() {
closeSession()
continue
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already part of connect, why the repetition?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Address now

}
}
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function seems fragile and I'd love to see it refactored.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred.

@hugodutka
hugodutka force-pushed the hugodutka/chatd-refactor-3 branch from c48520d to 2cf1f47 Compare June 11, 2026 11:43
@hugodutka
hugodutka force-pushed the hugodutka/chatd-refactor-4 branch from 44cbf6c to 110e90b Compare June 11, 2026 11:43
@hugodutka
hugodutka force-pushed the hugodutka/chatd-refactor-3 branch from 2cf1f47 to 47d102f Compare June 11, 2026 12:41
@hugodutka
hugodutka force-pushed the hugodutka/chatd-refactor-4 branch from 110e90b to 1c07612 Compare June 11, 2026 12:41
@hugodutka
hugodutka force-pushed the hugodutka/chatd-refactor-3 branch from 47d102f to bb37788 Compare June 11, 2026 16:27
@hugodutka
hugodutka force-pushed the hugodutka/chatd-refactor-4 branch from 1c07612 to 51648bc Compare June 11, 2026 16:27
@hugodutka
hugodutka force-pushed the hugodutka/chatd-refactor-3 branch from bb37788 to fd9d643 Compare June 11, 2026 17:03
@hugodutka
hugodutka force-pushed the hugodutka/chatd-refactor-4 branch from 51648bc to f7a987d Compare June 11, 2026 17:03
@hugodutka
hugodutka force-pushed the hugodutka/chatd-refactor-3 branch from fd9d643 to c9de302 Compare June 11, 2026 17:04
@hugodutka
hugodutka force-pushed the hugodutka/chatd-refactor-4 branch from f7a987d to 4f2ce0e Compare June 11, 2026 17:04
@hugodutka hugodutka mentioned this pull request Jun 11, 2026
@hugodutka
hugodutka force-pushed the hugodutka/chatd-refactor-3 branch from c9de302 to b688bfb Compare June 11, 2026 17:11
@hugodutka
hugodutka force-pushed the hugodutka/chatd-refactor-4 branch from 4f2ce0e to db66ec8 Compare June 11, 2026 17:11
Base automatically changed from hugodutka/chatd-refactor-3 to hugodutka/chatd-refactor June 11, 2026 17:12
@hugodutka
hugodutka force-pushed the hugodutka/chatd-refactor-4 branch from db66ec8 to 921fd30 Compare June 11, 2026 17:13
@hugodutka
hugodutka merged commit ef45425 into hugodutka/chatd-refactor Jun 11, 2026
2 of 3 checks passed
@hugodutka
hugodutka deleted the hugodutka/chatd-refactor-4 branch June 11, 2026 17:13
@github-actions github-actions Bot locked and limited conversation to collaborators Jun 11, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants