-
Notifications
You must be signed in to change notification settings - Fork 1.5k
feat: reinitialize agents when a prebuilt workspace is claimed #17475
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
c09c9b9
476fe71
8c8bca6
7ce4eea
52ac64e
362db7c
dcc7379
ff66b3f
efff5d9
cebd5db
2679138
9feebef
b117b5c
a22b414
9bbd2c7
5804201
7e8dcee
725f97b
a9b1567
21ee970
e54d7e7
2799858
1d93003
763fc12
0f879c7
61784c9
604eb27
bf4d2cf
38b4f0d
20df538
4bb3b68
83972db
146b158
5eb16cd
730d803
150adc0
b4ecf10
3fa3edf
7e45919
a632508
72125ec
b65eea7
e1339f3
c1a8ba6
5363dcc
7ad9b6d
394571d
890747b
b3870db
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
- Loading branch information
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,8 +2,8 @@ package prebuilds | |
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "net/http" | ||
| "sync" | ||
|
|
||
| "github.com/google/uuid" | ||
| "golang.org/x/xerrors" | ||
|
|
@@ -15,41 +15,81 @@ import ( | |
| "github.com/coder/coder/v2/codersdk/agentsdk" | ||
| ) | ||
|
|
||
| func PublishWorkspaceClaim(ctx context.Context, ps pubsub.Pubsub, workspaceID, userID uuid.UUID) error { | ||
| channel := agentsdk.PrebuildClaimedChannel(workspaceID) | ||
| if err := ps.Publish(channel, []byte(userID.String())); err != nil { | ||
| type WorkspaceClaimPublisher interface { | ||
| PublishWorkspaceClaim(agentsdk.ReinitializationEvent) | ||
| } | ||
|
|
||
| func NewPubsubWorkspaceClaimPublisher(ps pubsub.Pubsub) *PubsubWorkspaceClaimPublisher { | ||
| return &PubsubWorkspaceClaimPublisher{ps: ps} | ||
| } | ||
|
|
||
| type PubsubWorkspaceClaimPublisher struct { | ||
| ps pubsub.Pubsub | ||
| } | ||
|
|
||
| func (p PubsubWorkspaceClaimPublisher) PublishWorkspaceClaim(claim agentsdk.ReinitializationEvent) error { | ||
| channel := agentsdk.PrebuildClaimedChannel(claim.WorkspaceID) | ||
| if err := p.ps.Publish(channel, []byte(claim.UserID.String())); err != nil { | ||
| return xerrors.Errorf("failed to trigger prebuilt workspace agent reinitialization: %w", err) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func ListenForWorkspaceClaims(ctx context.Context, logger slog.Logger, ps pubsub.Pubsub, workspaceID uuid.UUID) (func(), <-chan agentsdk.ReinitializationEvent, error) { | ||
| reinitEvents := make(chan agentsdk.ReinitializationEvent, 1) | ||
| cancelSub, err := ps.Subscribe(agentsdk.PrebuildClaimedChannel(workspaceID), func(inner context.Context, id []byte) { | ||
| type WorkspaceClaimListener interface { | ||
| ListenForWorkspaceClaims(ctx context.Context, workspaceID uuid.UUID) (func(), <-chan agentsdk.ReinitializationEvent, error) | ||
| } | ||
|
|
||
| func NewPubsubWorkspaceClaimListener(ps pubsub.Pubsub, logger slog.Logger) *PubsubWorkspaceClaimListener { | ||
| return &PubsubWorkspaceClaimListener{ps: ps, logger: logger} | ||
| } | ||
|
|
||
| type PubsubWorkspaceClaimListener struct { | ||
| logger slog.Logger | ||
| ps pubsub.Pubsub | ||
| } | ||
|
|
||
| func (p PubsubWorkspaceClaimListener) ListenForWorkspaceClaims(ctx context.Context, workspaceID uuid.UUID) (func(), <-chan agentsdk.ReinitializationEvent, error) { | ||
| workspaceClaims := make(chan agentsdk.ReinitializationEvent, 1) | ||
|
SasSwart marked this conversation as resolved.
Outdated
|
||
| cancelSub, err := p.ps.Subscribe(agentsdk.PrebuildClaimedChannel(workspaceID), func(inner context.Context, id []byte) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As we talked about on a call --- the pubsub is not considered reliable transport, and we can miss events. This can lead to a situation where the agent misses the reinit signal and never reinitializes, even though it has been claimed. The deep problem here is that we are using the PubSub to send material information (who the new owner is), rather than just a kick that there is new information available (the workspace has a new owner). In the latter case, when there is an error, we can recover by re-querying the database to find the owner, and then decide whether we need to signal the agent with new information. This requires the handler to keep track of the last owner it sent, but that's a trivial amount of memory to keep. I don't necessarily think this needs to be fixed in this PR, since the plan is to move to a new "stream of manifest" architecture, but as we implement that, we need to keep error handling in mind on both sides: coderd recovers from a pubsub error by querying the database (or closing the connection if the database query fails), and then deciding whether there is something new to send. The agent recovers from a dropped connection by redialing, and then checking the new manifest against it's existing one to see if it needs to take any action.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for identifying this. Let's defer it beyond this PR to the next release if there are no objections. I'd like to get to this as part of the manifest streaming work. |
||
| claimantID, err := uuid.ParseBytes(id) | ||
| if err != nil { | ||
| p.logger.Error(ctx, "invalid prebuild claimed channel payload", slog.F("input", string(id))) | ||
| return | ||
| } | ||
| claim := agentsdk.ReinitializationEvent{ | ||
| UserID: claimantID, | ||
| WorkspaceID: workspaceID, | ||
| Reason: agentsdk.ReinitializeReasonPrebuildClaimed, | ||
| } | ||
| select { | ||
| case <-ctx.Done(): | ||
| return | ||
| case <-inner.Done(): | ||
| return | ||
| case workspaceClaims <- claim: | ||
| default: | ||
|
SasSwart marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| claimantID, err := uuid.ParseBytes(id) | ||
| if err != nil { | ||
| logger.Error(ctx, "invalid prebuild claimed channel payload", slog.F("input", string(id))) | ||
| return | ||
| } | ||
| // TODO: turn this into a <- uuid.UUID | ||
| reinitEvents <- agentsdk.ReinitializationEvent{ | ||
| Message: fmt.Sprintf("prebuild claimed by user: %s", claimantID), | ||
| Reason: agentsdk.ReinitializeReasonPrebuildClaimed, | ||
| } | ||
| }) | ||
|
|
||
| if err != nil { | ||
| close(workspaceClaims) | ||
| return func() {}, nil, xerrors.Errorf("failed to subscribe to prebuild claimed channel: %w", err) | ||
| } | ||
| defer cancelSub() | ||
| return func() { cancelSub() }, reinitEvents, nil | ||
|
|
||
| var once sync.Once | ||
| cancel := func() { | ||
| once.Do(func() { | ||
| cancelSub() | ||
|
SasSwart marked this conversation as resolved.
|
||
| close(workspaceClaims) | ||
| }) | ||
| } | ||
|
|
||
| go func() { | ||
| <-ctx.Done() | ||
|
SasSwart marked this conversation as resolved.
|
||
| cancel() | ||
| }() | ||
|
|
||
| return cancel, workspaceClaims, nil | ||
| } | ||
|
|
||
| func StreamAgentReinitEvents(ctx context.Context, logger slog.Logger, rw http.ResponseWriter, r *http.Request, reinitEvents <-chan agentsdk.ReinitializationEvent) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,191 @@ | ||
| package prebuilds_test | ||
|
|
||
| import ( | ||
| "context" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/google/uuid" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| "golang.org/x/xerrors" | ||
|
|
||
| "cdr.dev/slog/sloggers/slogtest" | ||
| "github.com/coder/coder/v2/coderd/database/pubsub" | ||
| "github.com/coder/coder/v2/coderd/prebuilds" | ||
| "github.com/coder/coder/v2/codersdk/agentsdk" | ||
| "github.com/coder/coder/v2/testutil" | ||
| ) | ||
|
|
||
| func TestPubsubWorkspaceClaimPublisher(t *testing.T) { | ||
| t.Parallel() | ||
| t.Run("publish claim", func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| ps := pubsub.NewInMemory() | ||
| publisher := prebuilds.NewPubsubWorkspaceClaimPublisher(ps) | ||
|
|
||
| workspaceID := uuid.New() | ||
| userID := uuid.New() | ||
|
|
||
| userIDCh := make(chan uuid.UUID, 1) | ||
| channel := agentsdk.PrebuildClaimedChannel(workspaceID) | ||
| cancel, err := ps.Subscribe(channel, func(ctx context.Context, message []byte) { | ||
| userIDCh <- uuid.MustParse(string(message)) | ||
| }) | ||
| require.NoError(t, err) | ||
| defer cancel() | ||
|
|
||
| claim := agentsdk.ReinitializationEvent{ | ||
| UserID: userID, | ||
| WorkspaceID: workspaceID, | ||
| Reason: agentsdk.ReinitializeReasonPrebuildClaimed, | ||
| } | ||
| err = publisher.PublishWorkspaceClaim(claim) | ||
| require.NoError(t, err) | ||
|
|
||
| // Verify the message was published | ||
| select { | ||
|
SasSwart marked this conversation as resolved.
Outdated
|
||
| case gotUserID := <-userIDCh: | ||
| require.Equal(t, userID, gotUserID) | ||
| case <-time.After(testutil.WaitShort): | ||
| t.Fatal("timeout waiting for claim") | ||
| } | ||
| }) | ||
|
|
||
| t.Run("fail to publish claim", func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| ps := &brokenPubsub{} | ||
|
|
||
| publisher := prebuilds.NewPubsubWorkspaceClaimPublisher(ps) | ||
| claim := agentsdk.ReinitializationEvent{ | ||
| UserID: uuid.New(), | ||
| WorkspaceID: uuid.New(), | ||
| Reason: agentsdk.ReinitializeReasonPrebuildClaimed, | ||
| } | ||
|
|
||
| err := publisher.PublishWorkspaceClaim(claim) | ||
| require.Error(t, err) | ||
|
SasSwart marked this conversation as resolved.
Outdated
|
||
| }) | ||
| } | ||
|
|
||
| func TestPubsubWorkspaceClaimListener(t *testing.T) { | ||
|
SasSwart marked this conversation as resolved.
|
||
| t.Parallel() | ||
| t.Run("stops listening if context canceled", func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| ps := pubsub.NewInMemory() | ||
| listener := prebuilds.NewPubsubWorkspaceClaimListener(ps, slogtest.Make(t, nil)) | ||
|
|
||
| ctx, cancel := context.WithCancel(context.Background()) | ||
| cancel() | ||
|
|
||
| cancelFunc, claims, err := listener.ListenForWorkspaceClaims(ctx, uuid.New()) | ||
| require.NoError(t, err) | ||
| defer cancelFunc() | ||
|
|
||
| // Channel should be closed immediately due to context cancellation | ||
| select { | ||
| case _, ok := <-claims: | ||
| assert.False(t, ok) | ||
|
SasSwart marked this conversation as resolved.
Outdated
|
||
| case <-time.After(testutil.WaitShort): | ||
| t.Fatal("timeout waiting for closed channel") | ||
| } | ||
| }) | ||
|
|
||
| t.Run("stops listening if cancel func is called", func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| ps := pubsub.NewInMemory() | ||
| listener := prebuilds.NewPubsubWorkspaceClaimListener(ps, slogtest.Make(t, nil)) | ||
|
|
||
| cancelFunc, claims, err := listener.ListenForWorkspaceClaims(context.Background(), uuid.New()) | ||
| require.NoError(t, err) | ||
|
|
||
| cancelFunc() | ||
| select { | ||
| case _, ok := <-claims: | ||
| assert.False(t, ok) | ||
|
SasSwart marked this conversation as resolved.
Outdated
|
||
| case <-time.After(testutil.WaitShort): | ||
| t.Fatal("timeout waiting for closed channel") | ||
| } | ||
| }) | ||
|
|
||
| t.Run("finds claim events for its workspace", func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| ps := pubsub.NewInMemory() | ||
| listener := prebuilds.NewPubsubWorkspaceClaimListener(ps, slogtest.Make(t, nil)) | ||
|
|
||
| workspaceID := uuid.New() | ||
| userID := uuid.New() | ||
| cancelFunc, claims, err := listener.ListenForWorkspaceClaims(context.Background(), workspaceID) | ||
| require.NoError(t, err) | ||
| defer cancelFunc() | ||
|
|
||
| // Publish a claim | ||
| channel := agentsdk.PrebuildClaimedChannel(workspaceID) | ||
| err = ps.Publish(channel, []byte(userID.String())) | ||
| require.NoError(t, err) | ||
|
|
||
| // Verify we receive the claim | ||
| select { | ||
| case claim := <-claims: | ||
| assert.Equal(t, userID, claim.UserID) | ||
| assert.Equal(t, workspaceID, claim.WorkspaceID) | ||
| assert.Equal(t, agentsdk.ReinitializeReasonPrebuildClaimed, claim.Reason) | ||
| case <-time.After(time.Second): | ||
| t.Fatal("timeout waiting for claim") | ||
| } | ||
| }) | ||
|
|
||
| t.Run("ignores claim events for other workspaces", func(t *testing.T) { | ||
|
SasSwart marked this conversation as resolved.
|
||
| t.Parallel() | ||
|
|
||
| ps := pubsub.NewInMemory() | ||
| listener := prebuilds.NewPubsubWorkspaceClaimListener(ps, slogtest.Make(t, nil)) | ||
|
|
||
| workspaceID := uuid.New() | ||
| otherWorkspaceID := uuid.New() | ||
| cancelFunc, claims, err := listener.ListenForWorkspaceClaims(context.Background(), workspaceID) | ||
| require.NoError(t, err) | ||
| defer cancelFunc() | ||
|
|
||
| // Publish a claim for a different workspace | ||
| channel := agentsdk.PrebuildClaimedChannel(otherWorkspaceID) | ||
| err = ps.Publish(channel, []byte(uuid.New().String())) | ||
| require.NoError(t, err) | ||
|
|
||
| // Verify we don't receive the claim | ||
| select { | ||
| case <-claims: | ||
| t.Fatal("received claim for wrong workspace") | ||
| case <-time.After(100 * time.Millisecond): | ||
| // Expected - no claim received | ||
| } | ||
| }) | ||
|
|
||
| t.Run("communicates the error if it can't subscribe", func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| ps := &brokenPubsub{} | ||
| listener := prebuilds.NewPubsubWorkspaceClaimListener(ps, slogtest.Make(t, nil)) | ||
|
|
||
| _, _, err := listener.ListenForWorkspaceClaims(context.Background(), uuid.New()) | ||
| require.Error(t, err) | ||
| assert.Contains(t, err.Error(), "failed to subscribe to prebuild claimed channel") | ||
|
SasSwart marked this conversation as resolved.
Outdated
|
||
| }) | ||
| } | ||
|
|
||
| type brokenPubsub struct { | ||
| pubsub.Pubsub | ||
| } | ||
|
|
||
| func (brokenPubsub) Subscribe(_ string, _ pubsub.Listener) (func(), error) { | ||
| return nil, xerrors.New("broken") | ||
| } | ||
|
|
||
| func (brokenPubsub) Publish(_ string, _ []byte) error { | ||
| return xerrors.New("broken") | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.