mirror of
https://github.com/coder/coder.git
synced 2026-09-22 21:22:17 +08:00
Implements the chatd stabilization RFC. Combines: - https://github.com/coder/coder/pull/25908 - https://github.com/coder/coder/pull/25923 - https://github.com/coder/coder/pull/26109 - https://github.com/coder/coder/pull/26110 - https://github.com/coder/coder/pull/26111 - https://github.com/coder/coder/pull/26112
256 lines
6.7 KiB
Go
256 lines
6.7 KiB
Go
package chatd
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
coderdpubsub "github.com/coder/coder/v2/coderd/pubsub"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/messagepartbuffer"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
const (
|
|
streamSyncRetryInitialBackoff = 100 * time.Millisecond
|
|
streamSyncRetryMaxBackoff = time.Second
|
|
streamSyncRetryMaxAttempts = 5
|
|
)
|
|
|
|
func (p *Server) subscribeStreamLoop(
|
|
ctx context.Context,
|
|
chat database.Chat,
|
|
requestHeader http.Header,
|
|
afterMessageID int64,
|
|
) ([]codersdk.ChatStreamEvent, <-chan codersdk.ChatStreamEvent, func(), bool) {
|
|
if p == nil || p.db == nil || p.pubsub == nil {
|
|
return nil, nil, nil, false
|
|
}
|
|
if p.messagePartBuffer == nil {
|
|
p.messagePartBuffer = messagepartbuffer.New(messagepartbuffer.Options{Clock: p.clock})
|
|
}
|
|
chatID := chat.ID
|
|
streamCtx, streamCancel := context.WithCancel(ctx)
|
|
events := make(chan codersdk.ChatStreamEvent, 128)
|
|
logger := p.logger.With(slog.F("chat_id", chatID))
|
|
|
|
updateCh := make(chan streamSyncHint, 32)
|
|
pubsubCancel, err := p.pubsub.SubscribeWithErr(
|
|
coderdpubsub.ChatStateUpdateChannel(chatID),
|
|
coderdpubsub.HandleChatStateUpdate(func(_ context.Context, payload coderdpubsub.ChatStateUpdateMessage, err error) {
|
|
if err != nil {
|
|
logger.Warn(streamCtx, "chat stream pubsub error", slog.Error(err))
|
|
return
|
|
}
|
|
select {
|
|
case updateCh <- streamSyncHintFromUpdate(payload):
|
|
case <-streamCtx.Done():
|
|
}
|
|
}),
|
|
)
|
|
if err != nil {
|
|
logger.Warn(ctx, "failed to subscribe to chat state updates", slog.Error(err))
|
|
streamCancel()
|
|
return subscribeWithInitialError(chatID, "failed to subscribe to chat updates")
|
|
}
|
|
|
|
pollerCh, unregisterPoller := p.streamSyncPoller.Register(chatID)
|
|
loop := newStreamLoop(chat, p.db, logger, afterMessageID)
|
|
// The immediate sync builds the initial snapshot returned to the caller
|
|
// and the relay target for the forwarder. Hints only fire on state
|
|
// changes, so without it an idle chat would never deliver a snapshot and
|
|
// an actively streaming chat would not relay parts until the next hint.
|
|
//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))
|
|
if err != nil {
|
|
logger.Error(ctx, "failed to load initial chat stream snapshot", slog.Error(err))
|
|
unregisterPoller()
|
|
pubsubCancel()
|
|
streamCancel()
|
|
return subscribeWithInitialError(chatID, "failed to load initial snapshot")
|
|
}
|
|
|
|
relay := newStreamRelayForwarder(
|
|
chatID,
|
|
requestHeader,
|
|
p.streamPartsDialer,
|
|
p.clock,
|
|
logger,
|
|
)
|
|
relay.Configure(streamCtx, target)
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
defer close(events)
|
|
defer relay.Close()
|
|
defer unregisterPoller()
|
|
for {
|
|
select {
|
|
case <-streamCtx.Done():
|
|
return
|
|
case hint := <-updateCh:
|
|
if !p.runStreamSync(streamCtx, loop, relay, events, hint) {
|
|
return
|
|
}
|
|
case hint, ok := <-pollerCh:
|
|
if !ok {
|
|
return
|
|
}
|
|
if !p.runStreamSync(streamCtx, loop, relay, events, hint) {
|
|
return
|
|
}
|
|
case part, ok := <-relay.Parts():
|
|
if !ok {
|
|
return
|
|
}
|
|
event, accepted, err := loop.part(part)
|
|
if err != nil {
|
|
logger.Error(streamCtx, "chat stream invariant violation", slog.Error(err))
|
|
return
|
|
}
|
|
if accepted {
|
|
sendStreamEvent(streamCtx, events, event)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
cancel := func() {
|
|
streamCancel()
|
|
pubsubCancel()
|
|
<-done
|
|
}
|
|
return initial, events, cancel, true
|
|
}
|
|
|
|
func (p *Server) runStreamSync(
|
|
ctx context.Context,
|
|
loop *streamLoop,
|
|
relay *streamRelayForwarder,
|
|
events chan<- codersdk.ChatStreamEvent,
|
|
hint streamSyncHint,
|
|
) bool {
|
|
syncEvents, target, changed, err := p.syncStreamWithRetry(ctx, loop, hint)
|
|
if err != nil {
|
|
p.logger.Error(ctx, "failed to sync chat stream after retries", slog.Error(err))
|
|
return false
|
|
}
|
|
for _, event := range syncEvents {
|
|
if !sendStreamEvent(ctx, events, event) {
|
|
return false
|
|
}
|
|
}
|
|
if changed {
|
|
relay.Configure(ctx, target)
|
|
}
|
|
return true
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (p *Server) waitBeforeStreamSyncRetry(ctx context.Context, attempt int) bool {
|
|
delay := streamSyncRetryInitialBackoff
|
|
for range attempt - 1 {
|
|
delay *= 2
|
|
if delay >= streamSyncRetryMaxBackoff {
|
|
delay = streamSyncRetryMaxBackoff
|
|
break
|
|
}
|
|
}
|
|
timer := p.clock.NewTimer(delay, "chatd", "stream-sync-retry")
|
|
defer timer.Stop()
|
|
select {
|
|
case <-timer.C:
|
|
return true
|
|
case <-ctx.Done():
|
|
return false
|
|
}
|
|
}
|
|
|
|
func sendStreamEvent(ctx context.Context, ch chan<- codersdk.ChatStreamEvent, event codersdk.ChatStreamEvent) bool {
|
|
select {
|
|
case ch <- event:
|
|
return true
|
|
case <-ctx.Done():
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (p *Server) Subscribe(
|
|
ctx context.Context,
|
|
chatID uuid.UUID,
|
|
requestHeader http.Header,
|
|
afterMessageID int64,
|
|
) (
|
|
[]codersdk.ChatStreamEvent,
|
|
<-chan codersdk.ChatStreamEvent,
|
|
func(),
|
|
bool,
|
|
) {
|
|
if p == nil {
|
|
return nil, nil, nil, false
|
|
}
|
|
|
|
chat, err := p.db.GetChatByID(ctx, chatID)
|
|
if err != nil {
|
|
if dbauthz.IsNotAuthorizedError(err) {
|
|
return nil, nil, nil, false
|
|
}
|
|
p.logger.Warn(ctx, "failed to load chat for stream subscription",
|
|
slog.F("chat_id", chatID),
|
|
slog.Error(err),
|
|
)
|
|
return subscribeWithInitialError(chatID, "failed to load initial snapshot")
|
|
}
|
|
return p.SubscribeAuthorized(ctx, chat, requestHeader, afterMessageID)
|
|
}
|
|
|
|
// SubscribeAuthorized subscribes an already-authorized chat to stream updates.
|
|
func (p *Server) SubscribeAuthorized(
|
|
ctx context.Context,
|
|
chat database.Chat,
|
|
requestHeader http.Header,
|
|
afterMessageID int64,
|
|
) (
|
|
[]codersdk.ChatStreamEvent,
|
|
<-chan codersdk.ChatStreamEvent,
|
|
func(),
|
|
bool,
|
|
) {
|
|
return p.subscribeStreamLoop(ctx, chat, requestHeader, afterMessageID)
|
|
}
|