@@ -2,6 +2,7 @@ package agentapi
22
33import (
44"context"
5+ "database/sql"
56"io"
67"net"
78"net/url"
@@ -21,6 +22,7 @@ import (
2122"github.com/coder/coder/v2/coderd/appearance"
2223"github.com/coder/coder/v2/coderd/connectionlog"
2324"github.com/coder/coder/v2/coderd/database"
25+ "github.com/coder/coder/v2/coderd/database/dbauthz"
2426"github.com/coder/coder/v2/coderd/database/pubsub"
2527"github.com/coder/coder/v2/coderd/externalauth"
2628"github.com/coder/coder/v2/coderd/notifications"
@@ -36,6 +38,35 @@ import (
3638"github.com/coder/quartz"
3739)
3840
41+ const workspaceCacheRefreshInterval = 5 * time .Minute
42+
43+ // CachedWorkspaceFields contains workspace data that is safe to cache for the
44+ // duration of an agent connection. These fields are used to reduce database calls
45+ // in high-frequency operations like stats reporting and metadata updates.
46+ //
47+ // IMPORTANT: ACL fields (GroupACL, UserACL) are NOT cached because they can be
48+ // modified in the database and we must use fresh data for authorization checks.
49+ //
50+ // Prebuild Safety: When a prebuild is claimed, the owner_id changes in the database
51+ // but the agent connection persists. Currently we handle this by periodically refreshing
52+ // the cached fields (every 5 minutes) to pick up changes like prebuild claims.
53+ type CachedWorkspaceFields struct {
54+ // Identity fields
55+ ID uuid.UUID
56+ OwnerID uuid.UUID
57+ OrganizationID uuid.UUID
58+ TemplateID uuid.UUID
59+
60+ // Display fields for logging/metrics
61+ Name string
62+ OwnerUsername string
63+ TemplateName string
64+
65+ // Lifecycle fields needed for stats reporting
66+ AutostartSchedule sql.NullString
67+ DormantAt sql.NullTime
68+ }
69+
3970// API implements the DRPC agent API interface from agent/proto. This struct is
4071// instantiated once per agent connection and kept alive for the duration of the
4172// session.
@@ -54,7 +85,7 @@ type API struct {
5485* SubAgentAPI
5586* tailnet.DRPCService
5687
57- cachedWorkspace database. Workspace
88+ cachedWorkspaceFields CachedWorkspaceFields
5889
5990mu sync.Mutex
6091}
@@ -100,9 +131,25 @@ func New(opts Options, workspace database.Workspace) *API {
100131}
101132
102133api := & API {
103- opts :opts ,
104- cachedWorkspace :workspace ,
105- mu : sync.Mutex {},
134+ opts :opts ,
135+ cachedWorkspaceFields :CachedWorkspaceFields {
136+ ID :workspace .ID ,
137+ OwnerID :workspace .OwnerID ,
138+ OrganizationID :workspace .OrganizationID ,
139+ TemplateID :workspace .TemplateID ,
140+ Name :workspace .Name ,
141+ OwnerUsername :workspace .OwnerUsername ,
142+ TemplateName :workspace .TemplateName ,
143+ AutostartSchedule : sql.NullString {
144+ String :workspace .AutostartSchedule .String ,
145+ Valid :workspace .AutostartSchedule .Valid ,
146+ },
147+ DormantAt : sql.NullTime {
148+ Time :workspace .DormantAt .Time ,
149+ Valid :workspace .DormantAt .Valid ,
150+ },
151+ },
152+ mu : sync.Mutex {},
106153}
107154
108155api .ManifestAPI = & ManifestAPI {
@@ -166,10 +213,11 @@ func New(opts Options, workspace database.Workspace) *API {
166213}
167214
168215api .MetadataAPI = & MetadataAPI {
169- AgentFn :api .agent ,
170- Database :opts .Database ,
171- Pubsub :opts .Pubsub ,
172- Log :opts .Log ,
216+ AgentFn :api .agent ,
217+ RBACContextFn :api .rbacContext ,
218+ Database :opts .Database ,
219+ Pubsub :opts .Pubsub ,
220+ Log :opts .Log ,
173221}
174222
175223api .LogsAPI = & LogsAPI {
@@ -209,6 +257,10 @@ func New(opts Options, workspace database.Workspace) *API {
209257Database :opts .Database ,
210258}
211259
260+ // Start background cache refresh loop to handle workspace changes
261+ // like prebuild claims where owner_id and other fields may be modified in the DB.
262+ go api .startCacheRefreshLoop (opts .Ctx )
263+
212264return api
213265}
214266
@@ -258,8 +310,67 @@ func (a *API) agent(ctx context.Context) (database.WorkspaceAgent, error) {
258310return agent ,nil
259311}
260312
261- func (a * API )workspace () (database.Workspace ,error ) {
262- return a .cachedWorkspace ,nil
313+ func (a * API )workspace () (database.Workspace ) {
314+ a .mu .Lock ()
315+ defer a .mu .Unlock ()
316+
317+ return database.Workspace {
318+ ID :a .cachedWorkspaceFields .ID ,
319+ OwnerID :a .cachedWorkspaceFields .OwnerID ,
320+ OrganizationID :a .cachedWorkspaceFields .OrganizationID ,
321+ TemplateID :a .cachedWorkspaceFields .TemplateID ,
322+ Name :a .cachedWorkspaceFields .Name ,
323+ OwnerUsername :a .cachedWorkspaceFields .OwnerUsername ,
324+ TemplateName :a .cachedWorkspaceFields .TemplateName ,
325+ AutostartSchedule :a .cachedWorkspaceFields .AutostartSchedule ,
326+ DormantAt :a .cachedWorkspaceFields .DormantAt ,
327+ }
328+ }
329+
330+ func (a * API )rbacContext (ctx context.Context ) context.Context {
331+ workspace := a .workspace ()
332+ return dbauthz .WithWorkspaceRBAC (ctx ,workspace .RBACObject ())
333+ }
334+
335+ // refreshCachedWorkspace periodically updates the cached workspace fields.
336+ // This ensures that changes like prebuild claims (which modify owner_id, name, etc.)
337+ // are eventually reflected in the cache without requiring agent reconnection.
338+ func (a * API )refreshCachedWorkspace (ctx context.Context ) {
339+ a .mu .Lock ()
340+ defer a .mu .Unlock ()
341+
342+ ws ,err := a .opts .Database .GetWorkspaceByID (ctx ,a .cachedWorkspaceFields .ID )
343+ if err != nil {
344+ a .opts .Log .Warn (ctx ,"failed to refresh cached workspace fields" ,slog .Error (err ))
345+ return
346+ }
347+
348+ // Update fields that can change during workspace lifecycle (e.g., prebuild claim)
349+ a .cachedWorkspaceFields .OwnerID = ws .OwnerID
350+ a .cachedWorkspaceFields .Name = ws .Name
351+ a .cachedWorkspaceFields .OwnerUsername = ws .OwnerUsername
352+ a .cachedWorkspaceFields .AutostartSchedule = ws .AutostartSchedule
353+ a .cachedWorkspaceFields .DormantAt = ws .DormantAt
354+
355+ a .opts .Log .Debug (ctx ,"refreshed cached workspace fields" ,
356+ slog .F ("workspace_id" ,ws .ID ),
357+ slog .F ("owner_id" ,ws .OwnerID ),
358+ slog .F ("name" ,ws .Name ))
359+ }
360+
361+ // startCacheRefreshLoop runs a background goroutine that periodically refreshes
362+ // the cached workspace fields. This is primarily needed to handle prebuild claims
363+ // where the owner_id and other fields change while the agent connection persists.
364+ func (a * API )startCacheRefreshLoop (ctx context.Context ) {
365+ // Refresh every 5 minutes. This provides a reasonable balance between:
366+ // - Keeping cache fresh for prebuild claims and other workspace updates
367+ // - Minimizing unnecessary database queries
368+ _ = a .opts .Clock .TickerFunc (ctx ,workspaceCacheRefreshInterval ,func ()error {
369+ a .refreshCachedWorkspace (ctx )
370+ return nil
371+ },"cache_refresh" )
372+
373+ <- ctx .Done ()
263374}
264375
265376func (a * API )publishWorkspaceUpdate (ctx context.Context ,agent * database.WorkspaceAgent ,kind wspubsub.WorkspaceEventKind )error {