Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions agent/agentsocket/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type Option func(*options)
type options struct {
path string
contextManager ContextManager
unitManager *unit.Manager
}

// WithPath sets the socket path. If not provided or empty, the client will
Expand All @@ -40,6 +41,16 @@ func WithContextManager(cm ContextManager) Option {
}
}

// WithUnitManager supplies the unit Manager the server uses for sync
// coordination, letting tests inject a Manager with a mock clock for
// deterministic event timestamps. If not provided, the server constructs
// its own. Server-only; ignored by the client.
func WithUnitManager(um *unit.Manager) Option {
return func(opts *options) {
opts.unitManager = um
}
}

// Client provides a client for communicating with the workspace agentsocket API.
type Client struct {
client proto.DRPCAgentSocketClient
Expand Down
7 changes: 6 additions & 1 deletion agent/agentsocket/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,18 @@ func NewServer(logger slog.Logger, opts ...Option) (*Server, error) {
opt(options)
}

unitManager := options.unitManager
if unitManager == nil {
unitManager = unit.NewManager()
}

logger = logger.Named("agentsocket-server")
server := &Server{
logger: logger,
path: options.path,
service: &DRPCAgentSocketService{
logger: logger,
unitManager: unit.NewManager(),
unitManager: unitManager,
contextManager: options.contextManager,
},
}
Expand Down
107 changes: 107 additions & 0 deletions agent/unit/timeline.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package unit

import "time"

// Phase is a derived stage of a unit's lifecycle. Phases are not stored
// by the Manager; they are reconstructed from the event log.
type Phase string

const (
// PhaseBlocked means the unit is registered but at least one of its
// dependencies is not at its required status.
PhaseBlocked Phase = "blocked"
// PhaseReady means the unit is registered and every declared
// dependency is at its required status.
PhaseReady Phase = "ready"
// PhaseRunning means the unit has started.
PhaseRunning Phase = "running"
// PhaseDone means the unit has completed.
PhaseDone Phase = "done"
)

// Interval is a derived span of a unit's lifecycle.
type Interval struct {
Unit ID
Phase Phase
Start time.Time
// End is the zero time if the interval is still open.
End time.Time
}

// DeriveIntervals replays events in Seq order and returns per-unit
// lifecycle intervals. Readiness is reconstructed from status_change and
// dependency_added events, mirroring the Manager's readiness semantics
// (recalculateReadinessUnsafe): a pending unit is ready when every
// declared dependency's current status equals its required status. This
// captures blocked-to-ready flips caused by other units' status changes
// and ready-to-blocked flips caused by dependency edges added after
// registration.
func DeriveIntervals(events []Event) map[ID][]Interval {
type edge struct {
dependsOn ID
requiredStatus Status
}
statuses := make(map[ID]Status)
deps := make(map[ID][]edge)
// dependents holds reverse edges so a status change can be folded
// into the phase of every unit that depends on the changed unit.
dependents := make(map[ID][]ID)
intervals := make(map[ID][]Interval)

// phaseOf derives the current phase of a registered unit from the
// replayed state. The second return is false for units that have
// not registered yet (for example, a dependency that only appears
// on the depends-on side of an edge).
phaseOf := func(u ID) (Phase, bool) {
switch statuses[u] {
case StatusNotRegistered:
return "", false
case StatusStarted:
return PhaseRunning, true
case StatusComplete:
return PhaseDone, true
}
for _, e := range deps[u] {
if statuses[e.dependsOn] != e.requiredStatus {
return PhaseBlocked, true
}
}
return PhaseReady, true
}

// setPhase closes the unit's open interval and opens a new one if
// the phase changed.
setPhase := func(u ID, phase Phase, at time.Time) {
ivs := intervals[u]
if len(ivs) > 0 {
last := &ivs[len(ivs)-1]
if last.Phase == phase {
return
}
last.End = at
}
intervals[u] = append(ivs, Interval{Unit: u, Phase: phase, Start: at})
}

for _, ev := range events {
affected := []ID{ev.Unit}
switch ev.Kind {
case EventStatusChange:
statuses[ev.Unit] = ev.To
affected = append(affected, dependents[ev.Unit]...)
case EventDependencyAdded:
deps[ev.Unit] = append(deps[ev.Unit], edge{
dependsOn: ev.DependsOn,
requiredStatus: ev.RequiredStatus,
})
dependents[ev.DependsOn] = append(dependents[ev.DependsOn], ev.Unit)
}
for _, u := range affected {
if phase, ok := phaseOf(u); ok {
setPhase(u, phase, ev.Time)
}
}
}

return intervals
}
167 changes: 167 additions & 0 deletions agent/unit/timeline_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package unit_test

import (
"testing"
"time"

"github.com/stretchr/testify/require"

"github.com/coder/coder/v2/agent/unit"
"github.com/coder/quartz"
)

func TestDeriveIntervals(t *testing.T) {
t.Parallel()

t.Run("Empty", func(t *testing.T) {
t.Parallel()

require.Empty(t, unit.DeriveIntervals(nil))
})

t.Run("SingleUnitLifecycle", func(t *testing.T) {
t.Parallel()

clock := quartz.NewMock(t)
manager := unit.NewManager(unit.WithClock(clock))
start := clock.Now()

require.NoError(t, manager.Register(unitA))
clock.Advance(time.Second)
require.NoError(t, manager.UpdateStatus(unitA, unit.StatusStarted))
clock.Advance(time.Second)
require.NoError(t, manager.UpdateStatus(unitA, unit.StatusComplete))

intervals := unit.DeriveIntervals(manager.Events())
require.Equal(t, map[unit.ID][]unit.Interval{
unitA: {
{Unit: unitA, Phase: unit.PhaseReady, Start: start, End: start.Add(time.Second)},
{Unit: unitA, Phase: unit.PhaseRunning, Start: start.Add(time.Second), End: start.Add(2 * time.Second)},
{Unit: unitA, Phase: unit.PhaseDone, Start: start.Add(2 * time.Second)},
},
}, intervals)
})

t.Run("BlockedUntilDependencySatisfied", func(t *testing.T) {
t.Parallel()

clock := quartz.NewMock(t)
manager := unit.NewManager(unit.WithClock(clock))
start := clock.Now()

// unitB registers with an immediate dependency on unitA, which
// registers later and runs to completion.
require.NoError(t, manager.Register(unitB))
require.NoError(t, manager.AddDependency(unitB, unitA, unit.StatusComplete))
clock.Advance(time.Second)
require.NoError(t, manager.Register(unitA))
clock.Advance(time.Second)
require.NoError(t, manager.UpdateStatus(unitA, unit.StatusStarted))
clock.Advance(time.Second)
require.NoError(t, manager.UpdateStatus(unitA, unit.StatusComplete))
clock.Advance(time.Second)
require.NoError(t, manager.UpdateStatus(unitB, unit.StatusStarted))

intervals := unit.DeriveIntervals(manager.Events())
require.Equal(t, []unit.Interval{
// Registration opens a ready interval that the immediately
// following dependency_added event closes at the same instant.
{Unit: unitB, Phase: unit.PhaseReady, Start: start, End: start},
{Unit: unitB, Phase: unit.PhaseBlocked, Start: start, End: start.Add(3 * time.Second)},
{Unit: unitB, Phase: unit.PhaseReady, Start: start.Add(3 * time.Second), End: start.Add(4 * time.Second)},
{Unit: unitB, Phase: unit.PhaseRunning, Start: start.Add(4 * time.Second)},
}, intervals[unitB])
})

t.Run("LateDependencyFlipsReadyToBlocked", func(t *testing.T) {
t.Parallel()

clock := quartz.NewMock(t)
manager := unit.NewManager(unit.WithClock(clock))
start := clock.Now()

// unitA registers with no dependencies, so it is immediately
// ready. A dependency added afterwards flips it back to blocked
// with no status change. This is the edge case that motivates
// recording dependency_added events.
require.NoError(t, manager.Register(unitA))
clock.Advance(time.Second)
require.NoError(t, manager.AddDependency(unitA, unitB, unit.StatusComplete))

intervals := unit.DeriveIntervals(manager.Events())
require.Equal(t, []unit.Interval{
{Unit: unitA, Phase: unit.PhaseReady, Start: start, End: start.Add(time.Second)},
{Unit: unitA, Phase: unit.PhaseBlocked, Start: start.Add(time.Second)},
}, intervals[unitA])
})

t.Run("MultipleDependencies", func(t *testing.T) {
t.Parallel()

clock := quartz.NewMock(t)
manager := unit.NewManager(unit.WithClock(clock))
start := clock.Now()

// unitC depends on both unitA and unitB completing. It only
// becomes ready when the last dependency is satisfied.
require.NoError(t, manager.Register(unitC))
require.NoError(t, manager.AddDependency(unitC, unitA, unit.StatusComplete))
require.NoError(t, manager.AddDependency(unitC, unitB, unit.StatusComplete))
require.NoError(t, manager.Register(unitA))
require.NoError(t, manager.Register(unitB))
clock.Advance(time.Second)
require.NoError(t, manager.UpdateStatus(unitA, unit.StatusStarted))
require.NoError(t, manager.UpdateStatus(unitA, unit.StatusComplete))
clock.Advance(time.Second)
require.NoError(t, manager.UpdateStatus(unitB, unit.StatusStarted))
require.NoError(t, manager.UpdateStatus(unitB, unit.StatusComplete))

intervals := unit.DeriveIntervals(manager.Events())
require.Equal(t, []unit.Interval{
{Unit: unitC, Phase: unit.PhaseReady, Start: start, End: start},
{Unit: unitC, Phase: unit.PhaseBlocked, Start: start, End: start.Add(2 * time.Second)},
{Unit: unitC, Phase: unit.PhaseReady, Start: start.Add(2 * time.Second)},
}, intervals[unitC])
})

t.Run("DependencyOnRunningStatus", func(t *testing.T) {
t.Parallel()

clock := quartz.NewMock(t)
manager := unit.NewManager(unit.WithClock(clock))
start := clock.Now()

// A dependency requiring StatusStarted is satisfied while the
// dependency runs and unsatisfied again once it completes.
require.NoError(t, manager.Register(unitB))
require.NoError(t, manager.AddDependency(unitB, unitA, unit.StatusStarted))
require.NoError(t, manager.Register(unitA))
clock.Advance(time.Second)
require.NoError(t, manager.UpdateStatus(unitA, unit.StatusStarted))
clock.Advance(time.Second)
require.NoError(t, manager.UpdateStatus(unitA, unit.StatusComplete))

intervals := unit.DeriveIntervals(manager.Events())
require.Equal(t, []unit.Interval{
{Unit: unitB, Phase: unit.PhaseReady, Start: start, End: start},
{Unit: unitB, Phase: unit.PhaseBlocked, Start: start, End: start.Add(time.Second)},
{Unit: unitB, Phase: unit.PhaseReady, Start: start.Add(time.Second), End: start.Add(2 * time.Second)},
{Unit: unitB, Phase: unit.PhaseBlocked, Start: start.Add(2 * time.Second)},
}, intervals[unitB])
})

t.Run("UnregisteredDependencyHasNoIntervals", func(t *testing.T) {
t.Parallel()

manager := unit.NewManager(unit.WithClock(quartz.NewMock(t)))

// unitB never registers; it only appears on the depends-on side
// of an edge, so it must not get intervals of its own.
require.NoError(t, manager.Register(unitA))
require.NoError(t, manager.AddDependency(unitA, unitB, unit.StatusComplete))

intervals := unit.DeriveIntervals(manager.Events())
require.Contains(t, intervals, unitA)
require.NotContains(t, intervals, unitB)
})
}
4 changes: 4 additions & 0 deletions cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ func TestCommandHelp(t *testing.T) {
Name: "coder exp sync list --help",
Cmd: []string{"exp", "sync", "list", "--help"},
},
clitest.CommandHelpCase{
Name: "coder exp sync timeline --help",
Cmd: []string{"exp", "sync", "timeline", "--help"},
},
))
}

Expand Down
1 change: 1 addition & 0 deletions cli/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ func (r *RootCmd) syncCommand() *serpent.Command {
r.syncComplete(&socketPath),
r.syncStatus(&socketPath),
r.syncList(&socketPath),
r.syncTimeline(&socketPath),
},
Options: serpent.OptionSet{
{
Expand Down
Loading
Loading