mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
ChatMachine.Update seeds the transaction with the row returned by LockChatAndBumpSnapshotVersion plus a queue count taken right after it. Callbacks read it with tx.Current() and the first transition validates against it, so neither re-reads the chat while the row lock is held. ChatMachine.UpdateReturning returns the row Update already reads after the callback for publication. updateChat (send, edit, promote, tool results, interrupt, compact, clear, reconcile), hook failure, family archive, and the worker's generation and task transitions use it instead of reloading the chat under the lock. Generated by Coder Agents on behalf of @cstyan.
370 lines
9.9 KiB
Go
370 lines
9.9 KiB
Go
package chatd
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
coderdpubsub "github.com/coder/coder/v2/coderd/pubsub"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
|
|
)
|
|
|
|
// chatWorker owns chat acquisition and runner lifecycle for one process.
|
|
type chatWorker struct {
|
|
server *Server
|
|
opts chatWorkerOptions
|
|
|
|
mu sync.Mutex
|
|
started bool
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
manager *runnerManager
|
|
unsubscribe func()
|
|
wakeCh chan struct{}
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// newChatWorker constructs a chat worker. The worker is idle until Start is
|
|
// called.
|
|
func newChatWorker(server *Server, opts chatWorkerOptions) (*chatWorker, error) {
|
|
if server == nil {
|
|
return nil, xerrors.New("chatworker: server is required")
|
|
}
|
|
withDefaults, err := opts.withDefaults()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &chatWorker{server: server, opts: withDefaults}, nil
|
|
}
|
|
|
|
// Start starts the acquisition and runner manager loops.
|
|
func (w *chatWorker) Start(ctx context.Context) error {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
if w.started {
|
|
return xerrors.New("chatworker: worker already started")
|
|
}
|
|
workerID := w.opts.WorkerID
|
|
workerCtx, cancel := context.WithCancel(ctx)
|
|
manager := newRunnerManager(workerCtx, w.server, w.opts)
|
|
if manager.opts.TaskStarter == nil {
|
|
starter, err := newTaskStarter(manager.server, manager.opts, manager.RouteStateHint, manager.requestCleanup)
|
|
if err != nil {
|
|
cancel()
|
|
return err
|
|
}
|
|
manager.opts.TaskStarter = starter
|
|
}
|
|
wakeCh := make(chan struct{}, w.opts.AcquisitionWakeChannelSize)
|
|
|
|
unsubscribe, err := w.opts.Pubsub.SubscribeWithErr(
|
|
coderdpubsub.ChatStateOwnershipChannel,
|
|
coderdpubsub.HandleChatStateOwnership(func(ctx context.Context, _ coderdpubsub.ChatStateOwnershipMessage, err error) {
|
|
if err != nil {
|
|
w.opts.Logger.Warn(ctx, "chatworker ownership hint decode failed", slogError(err))
|
|
return
|
|
}
|
|
wake(wakeCh)
|
|
}),
|
|
)
|
|
if err != nil {
|
|
cancel()
|
|
return xerrors.Errorf("subscribe ownership hints: %w", err)
|
|
}
|
|
|
|
w.started = true
|
|
w.ctx = workerCtx
|
|
w.cancel = cancel
|
|
w.manager = manager
|
|
w.unsubscribe = unsubscribe
|
|
w.wakeCh = wakeCh
|
|
|
|
manager.start()
|
|
w.wg.Go(func() {
|
|
w.acquisitionLoop(workerCtx, workerID, manager, wakeCh)
|
|
})
|
|
w.wg.Go(func() {
|
|
w.archiveLoop(workerCtx)
|
|
})
|
|
if w.opts.CapacityMetrics != nil {
|
|
w.wg.Go(func() {
|
|
w.capacityMetricsLoop(workerCtx)
|
|
})
|
|
}
|
|
wake(wakeCh)
|
|
return nil
|
|
}
|
|
|
|
// Wake requests an immediate acquisition pass.
|
|
func (w *chatWorker) Wake() {
|
|
w.mu.Lock()
|
|
wakeCh := w.wakeCh
|
|
w.mu.Unlock()
|
|
if wakeCh != nil {
|
|
wake(wakeCh)
|
|
}
|
|
}
|
|
|
|
// WaitIdle waits until the worker has no active or cleaning runners.
|
|
func (w *chatWorker) WaitIdle(ctx context.Context) error {
|
|
for {
|
|
w.mu.Lock()
|
|
manager := w.manager
|
|
w.mu.Unlock()
|
|
if manager == nil || manager.idle() {
|
|
return nil
|
|
}
|
|
timer := w.opts.Clock.NewTimer(10*time.Millisecond, "chatworker", "wait-idle")
|
|
select {
|
|
case <-timer.C:
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return ctx.Err()
|
|
}
|
|
timer.Stop()
|
|
}
|
|
}
|
|
|
|
// Close stops the worker and waits for its loops to exit.
|
|
func (w *chatWorker) Close() error {
|
|
w.mu.Lock()
|
|
if !w.started {
|
|
w.mu.Unlock()
|
|
return nil
|
|
}
|
|
cancel := w.cancel
|
|
unsubscribe := w.unsubscribe
|
|
manager := w.manager
|
|
w.started = false
|
|
w.cancel = nil
|
|
w.unsubscribe = nil
|
|
w.manager = nil
|
|
w.wakeCh = nil
|
|
w.mu.Unlock()
|
|
|
|
if unsubscribe != nil {
|
|
unsubscribe()
|
|
}
|
|
cancel()
|
|
w.wg.Wait()
|
|
if manager != nil {
|
|
manager.wait()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func wake(ch chan<- struct{}) {
|
|
select {
|
|
case ch <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (w *chatWorker) acquisitionLoop(
|
|
ctx context.Context,
|
|
workerID uuid.UUID,
|
|
manager *runnerManager,
|
|
wakeCh <-chan struct{},
|
|
) {
|
|
ticker := w.opts.Clock.NewTicker(w.opts.AcquisitionInterval, "chatworker", "acquisition")
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-wakeCh:
|
|
w.acquireOnce(ctx, workerID, manager)
|
|
case <-ticker.C:
|
|
w.acquireOnce(ctx, workerID, manager)
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (w *chatWorker) acquireOnce(ctx context.Context, workerID uuid.UUID, manager *runnerManager) {
|
|
// Fetch twice the budget so one full pool cannot hide candidates in the other.
|
|
rows, err := w.opts.Store.GetChatWorkerAcquisitionCandidates(ctx, database.GetChatWorkerAcquisitionCandidatesParams{
|
|
StaleSeconds: w.opts.HeartbeatStaleSeconds,
|
|
LimitCount: w.opts.AcquisitionBatchSize * 2,
|
|
})
|
|
if err != nil {
|
|
if ctx.Err() == nil {
|
|
w.opts.Logger.Warn(ctx, "chatworker acquisition query failed", slogError(err))
|
|
}
|
|
return
|
|
}
|
|
|
|
acquired := int32(0)
|
|
rootPoolRefused := false
|
|
subagentPoolRefused := false
|
|
for _, row := range rows {
|
|
if acquired >= w.opts.AcquisitionBatchSize {
|
|
return
|
|
}
|
|
// Every runnable status needs admission, so a refusal applies to
|
|
// the rest of the pool's candidates in this batch.
|
|
isSubagent := row.ParentChatID.Valid
|
|
if (isSubagent && subagentPoolRefused) || (!isSubagent && rootPoolRefused) {
|
|
continue
|
|
}
|
|
candidateAcquired, err := w.acquireCandidateSafely(ctx, workerID, manager, row.ID)
|
|
if errors.Is(err, errCapacityRefused) {
|
|
if isSubagent {
|
|
subagentPoolRefused = true
|
|
} else {
|
|
rootPoolRefused = true
|
|
}
|
|
continue
|
|
}
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
w.opts.Logger.Warn(ctx, "chatworker acquisition candidate failed", slogError(err))
|
|
continue
|
|
}
|
|
if candidateAcquired {
|
|
acquired++
|
|
}
|
|
}
|
|
}
|
|
|
|
var (
|
|
errSkipAcquire = xerrors.New("skip acquire")
|
|
errCapacityRefused = xerrors.New("capacity refused")
|
|
)
|
|
|
|
func (w *chatWorker) acquireCandidateSafely(
|
|
ctx context.Context,
|
|
workerID uuid.UUID,
|
|
manager *runnerManager,
|
|
chatID uuid.UUID,
|
|
) (acquired bool, err error) {
|
|
defer func() {
|
|
if recovered := recover(); recovered != nil {
|
|
err = xerrors.Errorf("chatworker acquisition panic: %v", recovered)
|
|
}
|
|
}()
|
|
return w.acquireCandidate(ctx, workerID, manager, chatID)
|
|
}
|
|
|
|
func (w *chatWorker) acquireCandidate(
|
|
ctx context.Context,
|
|
workerID uuid.UUID,
|
|
manager *runnerManager,
|
|
chatID uuid.UUID,
|
|
) (bool, error) {
|
|
runnerID := uuid.New()
|
|
var takenOver bool
|
|
machine := chatstate.NewChatMachine(w.opts.Store, w.opts.Pubsub, chatID)
|
|
err := machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error {
|
|
chat, state, err := tx.Current()
|
|
if err != nil {
|
|
return xerrors.Errorf("load chat: %w", err)
|
|
}
|
|
if !state.IsRunnable() || chat.Archived {
|
|
return errSkipAcquire
|
|
}
|
|
if chat.WorkerID.Valid && chat.RunnerID.Valid {
|
|
stale, err := store.IsChatHeartbeatStale(ctx, database.IsChatHeartbeatStaleParams{
|
|
ChatID: chat.ID,
|
|
RunnerID: chat.RunnerID.UUID,
|
|
StaleSeconds: w.opts.HeartbeatStaleSeconds,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("check heartbeat stale: %w", err)
|
|
}
|
|
if !stale {
|
|
return errSkipAcquire
|
|
}
|
|
takenOver = true
|
|
}
|
|
admitted, err := w.opts.AgentCapacityLimiter.Admit(ctx, store, chat)
|
|
if err != nil {
|
|
return xerrors.Errorf("agent admission: %w", err)
|
|
}
|
|
if !admitted {
|
|
// Roll back to suppress the ownership hint, which would wake every
|
|
// worker into an immediate retry of this unowned chat.
|
|
return errCapacityRefused
|
|
}
|
|
if takenOver {
|
|
// The lease was read before the admission lock, which heartbeat
|
|
// renewal also takes. A renewal that held the lock may have
|
|
// extended the lease since, so recheck it under the lock.
|
|
stillStale, err := w.leaseStaleUnderAdmissionLock(ctx, store, chat)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !stillStale {
|
|
return errSkipAcquire
|
|
}
|
|
}
|
|
_, err = tx.Acquire(chatstate.AcquireInput{WorkerID: workerID, RunnerID: runnerID})
|
|
return err
|
|
})
|
|
if errors.Is(err, errCapacityRefused) {
|
|
return false, errCapacityRefused
|
|
}
|
|
if errors.Is(err, errSkipAcquire) || errors.Is(err, chatstate.ErrChatNotFound) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if err := manager.Spawn(ctx, spawnRunnerRequest{ChatID: chatID, WorkerID: workerID, RunnerID: runnerID, TakenOver: takenOver}); err != nil {
|
|
if errAbandon := w.abandonAcquiredChat(ctx, workerID, runnerID, chatID); errAbandon != nil {
|
|
return false, errors.Join(err, errAbandon)
|
|
}
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// leaseStaleUnderAdmissionLock rechecks a takeover candidate's lease while
|
|
// holding the capacity admission lock. Admit already holds the lock when
|
|
// capacity is capped; advisory locks are reentrant, so taking it again is
|
|
// harmless and also covers uncapped deployments.
|
|
func (w *chatWorker) leaseStaleUnderAdmissionLock(ctx context.Context, store database.Store, chat database.Chat) (bool, error) {
|
|
if err := store.SetTransactionLockTimeout(ctx, admissionLockTimeout.Milliseconds()); err != nil {
|
|
return false, xerrors.Errorf("set lock timeout: %w", err)
|
|
}
|
|
if err := store.AcquireLock(ctx, database.LockIDChatCapacityAdmission); err != nil {
|
|
return false, xerrors.Errorf("acquire capacity admission lock: %w", err)
|
|
}
|
|
stale, err := store.IsChatHeartbeatStale(ctx, database.IsChatHeartbeatStaleParams{
|
|
ChatID: chat.ID,
|
|
RunnerID: chat.RunnerID.UUID,
|
|
StaleSeconds: w.opts.HeartbeatStaleSeconds,
|
|
})
|
|
if err != nil {
|
|
return false, xerrors.Errorf("recheck heartbeat stale: %w", err)
|
|
}
|
|
return stale, nil
|
|
}
|
|
|
|
func (w *chatWorker) abandonAcquiredChat(ctx context.Context, workerID uuid.UUID, runnerID uuid.UUID, chatID uuid.UUID) error {
|
|
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), shutdownCleanupTimeout)
|
|
defer cancel()
|
|
machine := chatstate.NewChatMachine(w.opts.Store, w.opts.Pubsub, chatID)
|
|
err := machine.Update(cleanupCtx, func(tx *chatstate.Tx, _ database.Store) error {
|
|
chat, _, err := tx.Current()
|
|
if err != nil {
|
|
return xerrors.Errorf("load chat: %w", err)
|
|
}
|
|
if !chat.WorkerID.Valid || chat.WorkerID.UUID != workerID || !chat.RunnerID.Valid || chat.RunnerID.UUID != runnerID {
|
|
return errSkipAcquire
|
|
}
|
|
_, err = tx.Abandon(chatstate.AbandonInput{})
|
|
return err
|
|
})
|
|
if errors.Is(err, errSkipAcquire) || errors.Is(err, chatstate.ErrChatNotFound) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|