Skip to content

Commit 6977604

Browse files
committed
Invalidate hosted pending caches after mutations
1 parent aaa8da1 commit 6977604

10 files changed

Lines changed: 162 additions & 16 deletions

cmd/wl/cmd_serve.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,11 @@ func runServeHosted(cmd *cobra.Command, stdout, _ io.Writer) error {
423423
// Build the API server with hosted workspace resolution.
424424
apiServer := api.NewHostedWorkspace(hosted.NewClientFunc(), hosted.NewWorkspaceFunc())
425425
apiServer.SetEnvironment(environment)
426+
apiServer.SetMutationInvalidator(func(ctx context.Context) {
427+
if connectionID, ok := hosted.ConnectionIDFromContext(ctx); ok {
428+
resolver.InvalidateConnection(connectionID)
429+
}
430+
})
426431

427432
// Public read-only RemoteDB against the canonical hosted upstream (no token needed).
428433
publicDB := newHostedPublicDB()

internal/api/handlers.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,9 @@ func canonicalBrowseKey(r *http.Request) string {
342342
// invalidateReadCaches busts browse caches for the active upstream and detail
343343
// caches for the affected item on that upstream.
344344
func (s *Server) invalidateReadCaches(r *http.Request, client *sdk.Client, wantedID string) {
345+
if s.mutationInvalidator != nil {
346+
s.mutationInvalidator(r.Context())
347+
}
345348
if s.hosted && !s.hasCanonicalHostedReadIdentity(r) {
346349
s.invalidateAllCaches()
347350
return
@@ -352,6 +355,9 @@ func (s *Server) invalidateReadCaches(r *http.Request, client *sdk.Client, wante
352355
}
353356

354357
func (s *Server) invalidateBrowseReadCaches(r *http.Request, client *sdk.Client) {
358+
if s.mutationInvalidator != nil {
359+
s.mutationInvalidator(r.Context())
360+
}
355361
if s.hosted && !s.hasCanonicalHostedReadIdentity(r) {
356362
s.invalidateAllCaches()
357363
return
@@ -375,6 +381,9 @@ func (s *Server) invalidateDetailCaches(upstream, wantedID string) {
375381
}
376382

377383
func (s *Server) invalidateUpstreamReadCaches(r *http.Request, client *sdk.Client) {
384+
if s.mutationInvalidator != nil {
385+
s.mutationInvalidator(r.Context())
386+
}
378387
if s.hosted && !s.hasCanonicalHostedReadIdentity(r) {
379388
s.invalidateAllCaches()
380389
return

internal/api/infrastructure_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,38 @@ func TestInvalidateReadCaches_TargetsOnlyMatchingUpstreamAndItem(t *testing.T) {
675675
}
676676
}
677677

678+
func TestInvalidateReadCaches_CallsMutationInvalidator(t *testing.T) {
679+
srv := NewHostedWorkspace(func(*http.Request) (*sdk.Client, error) {
680+
return sdk.New(sdk.ClientConfig{
681+
RigHandle: "alice",
682+
Upstream: "hop/wl-commons",
683+
Mode: "wild-west",
684+
}), nil
685+
}, nil)
686+
687+
var calls atomic.Int32
688+
srv.SetMutationInvalidator(func(context.Context) {
689+
calls.Add(1)
690+
})
691+
692+
req := httptest.NewRequest(http.MethodPost, "/api/wanted/w-1/reject-upstream", nil)
693+
req = req.WithContext(WithResolvedReadIdentity(req.Context(), ResolvedReadIdentity{
694+
Upstream: "hop/wl-commons",
695+
Viewer: "alice",
696+
}))
697+
client := sdk.New(sdk.ClientConfig{
698+
RigHandle: "alice",
699+
Upstream: "hop/wl-commons",
700+
Mode: "wild-west",
701+
})
702+
703+
srv.invalidateReadCaches(req, client, "w-1")
704+
705+
if got := calls.Load(); got != 1 {
706+
t.Fatalf("mutation invalidator calls = %d, want 1", got)
707+
}
708+
}
709+
678710
func TestInvalidateUpstreamReadCaches_TargetsOnlyMatchingUpstream(t *testing.T) {
679711
srv := NewHostedWorkspace(func(*http.Request) (*sdk.Client, error) {
680712
return sdk.New(sdk.ClientConfig{

internal/api/server.go

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
package api
66

77
import (
8+
"context"
89
"net/http"
910
"time"
1011

@@ -21,18 +22,19 @@ type WorkspaceFunc func(r *http.Request) (*sdk.Workspace, error)
2122

2223
// Server is the HTTP API server wrapping an SDK client.
2324
type Server struct {
24-
clientFunc ClientFunc
25-
workspaceFunc WorkspaceFunc
26-
pile pile.RowQuerier
27-
scoreboard *CachedEndpoint
28-
scoreboardDetail *CachedEndpoint
29-
scoreboardDump *CachedEndpoint
30-
publicClient *sdk.Client // anonymous fallback for public reads (hosted mode)
31-
browseCache *ReadCache // keyed by canonicalized query string
32-
detailCache *ReadCache // keyed by item ID
33-
environment string
34-
mux *http.ServeMux
35-
hosted bool // true when running in multi-tenant hosted mode
25+
clientFunc ClientFunc
26+
workspaceFunc WorkspaceFunc
27+
mutationInvalidator func(context.Context)
28+
pile pile.RowQuerier
29+
scoreboard *CachedEndpoint
30+
scoreboardDetail *CachedEndpoint
31+
scoreboardDump *CachedEndpoint
32+
publicClient *sdk.Client // anonymous fallback for public reads (hosted mode)
33+
browseCache *ReadCache // keyed by canonicalized query string
34+
detailCache *ReadCache // keyed by item ID
35+
environment string
36+
mux *http.ServeMux
37+
hosted bool // true when running in multi-tenant hosted mode
3638
}
3739

3840
// New creates a Server backed by the given SDK client.
@@ -115,6 +117,13 @@ func (s *Server) SetEnvironment(environment string) {
115117
s.environment = environment
116118
}
117119

120+
// SetMutationInvalidator registers a callback that runs after successful
121+
// mutations invalidate API read caches. Hosted mode uses this to evict
122+
// resolver-owned caches that live beneath the HTTP layer.
123+
func (s *Server) SetMutationInvalidator(fn func(context.Context)) {
124+
s.mutationInvalidator = fn
125+
}
126+
118127
// ScoreboardHandler returns an http.HandlerFunc for the scoreboard endpoint.
119128
func (s *Server) ScoreboardHandler() http.HandlerFunc {
120129
return s.handleScoreboard

internal/hosted/auth.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ import (
1313
type contextKey string
1414

1515
const (
16-
clientContextKey contextKey = "hosted-client"
17-
workspaceContextKey contextKey = "hosted-workspace"
16+
clientContextKey contextKey = "hosted-client"
17+
workspaceContextKey contextKey = "hosted-workspace"
18+
connectionContextKey contextKey = "hosted-connection-id"
1819
)
1920

2021
// ClientFromContext extracts the sdk.Client injected by auth middleware.
@@ -29,6 +30,13 @@ func WorkspaceFromContext(ctx context.Context) (*sdk.Workspace, bool) {
2930
return ws, ok
3031
}
3132

33+
// ConnectionIDFromContext extracts the active hosted connection ID injected by
34+
// auth middleware.
35+
func ConnectionIDFromContext(ctx context.Context) (string, bool) {
36+
connectionID, ok := ctx.Value(connectionContextKey).(string)
37+
return connectionID, ok && connectionID != ""
38+
}
39+
3240
// AuthMiddleware protects /api/* routes (excluding /api/auth/*).
3341
// It resolves the session cookie, looks up the Nango connection, and injects
3442
// the per-user sdk.Workspace and active sdk.Client into the request context.
@@ -152,6 +160,7 @@ func (s *Server) AuthMiddleware(next http.Handler) http.Handler {
152160

153161
// Inject both workspace and client into context.
154162
ctx := r.Context()
163+
ctx = context.WithValue(ctx, connectionContextKey, session.ConnectionID)
155164
ctx = context.WithValue(ctx, workspaceContextKey, workspace)
156165
ctx = context.WithValue(ctx, clientContextKey, client)
157166
ctx = api.WithResolvedReadIdentity(ctx, api.ResolvedReadIdentity{

internal/hosted/authservice_resolver.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,22 @@ func (wr *AuthServiceWorkspaceResolver) resolveFromConnection(_ context.Context,
184184
// InvalidateConnection evicts any cached workspace for the given connection.
185185
func (wr *AuthServiceWorkspaceResolver) InvalidateConnection(connectionID string) {
186186
wr.mu.Lock()
187-
defer wr.mu.Unlock()
188187
delete(wr.cache, connectionID)
188+
wr.mu.Unlock()
189+
190+
prefix := connectionID + ":"
191+
wr.pendingMu.Lock()
192+
caches := make([]*pendingUpstreamCache, 0, len(wr.pendingCache))
193+
for key, cache := range wr.pendingCache {
194+
if strings.HasPrefix(key, prefix) {
195+
caches = append(caches, cache)
196+
delete(wr.pendingCache, key)
197+
}
198+
}
199+
wr.pendingMu.Unlock()
200+
for _, cache := range caches {
201+
cache.Stop()
202+
}
189203
}
190204

191205
func (wr *AuthServiceWorkspaceResolver) cachedWorkspace(connectionID string) (*sdk.Workspace, bool) {
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package hosted
2+
3+
import (
4+
"testing"
5+
"time"
6+
)
7+
8+
func TestAuthServiceWorkspaceResolver_InvalidateConnectionClearsPendingCaches(t *testing.T) {
9+
resolver := NewAuthServiceWorkspaceResolver(nil, NewSessionStore())
10+
cache1 := newPendingUpstreamCache(nil, "hop", "wl-commons", time.Hour)
11+
cache2 := newPendingUpstreamCache(nil, "gastownhall", "gascity", time.Hour)
12+
defer cache1.Stop()
13+
defer cache2.Stop()
14+
15+
resolver.pendingCache["conn-1:hop/wl-commons"] = cache1
16+
resolver.pendingCache["conn-2:gastownhall/gascity"] = cache2
17+
18+
resolver.InvalidateConnection("conn-1")
19+
20+
if _, ok := resolver.pendingCache["conn-1:hop/wl-commons"]; ok {
21+
t.Fatal("expected conn-1 pending cache to be evicted")
22+
}
23+
if _, ok := resolver.pendingCache["conn-2:gastownhall/gascity"]; !ok {
24+
t.Fatal("expected unrelated pending cache to remain")
25+
}
26+
}

internal/hosted/authservice_server.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,7 @@ func (s *AuthServiceServer) AuthMiddleware(next http.Handler) http.Handler {
554554
Upstream: upstream,
555555
Viewer: workspace.RigHandle(),
556556
})
557+
ctx = withConnectionID(ctx, session.ConnectionID)
557558
ctx = withWorkspaceAndClient(ctx, workspace, client)
558559
next.ServeHTTP(w, r.WithContext(ctx))
559560
})
@@ -578,3 +579,7 @@ func withWorkspaceAndClient(ctx context.Context, workspace *sdk.Workspace, clien
578579
ctx = context.WithValue(ctx, clientContextKey, client)
579580
return ctx
580581
}
582+
583+
func withConnectionID(ctx context.Context, connectionID string) context.Context {
584+
return context.WithValue(ctx, connectionContextKey, connectionID)
585+
}

internal/hosted/resolver.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -388,8 +388,35 @@ func (wr *WorkspaceResolver) resolveFromMetadata(_ context.Context, connectionID
388388
// InvalidateConnection removes the cached workspace for a connection.
389389
func (wr *WorkspaceResolver) InvalidateConnection(connectionID string) {
390390
wr.mu.Lock()
391-
defer wr.mu.Unlock()
391+
var upstreams []string
392+
if cached, ok := wr.cache[connectionID]; ok && cached != nil && cached.workspace != nil {
393+
infos := cached.workspace.Upstreams()
394+
upstreams = make([]string, 0, len(infos))
395+
for _, info := range infos {
396+
if info.Upstream != "" {
397+
upstreams = append(upstreams, info.Upstream)
398+
}
399+
}
400+
}
392401
delete(wr.cache, connectionID)
402+
wr.mu.Unlock()
403+
404+
if len(upstreams) == 0 {
405+
return
406+
}
407+
408+
wr.pendingMu.Lock()
409+
caches := make([]*pendingUpstreamCache, 0, len(wr.pendingCache))
410+
for _, upstream := range upstreams {
411+
if cache, ok := wr.pendingCache[upstream]; ok {
412+
caches = append(caches, cache)
413+
delete(wr.pendingCache, upstream)
414+
}
415+
}
416+
wr.pendingMu.Unlock()
417+
for _, cache := range caches {
418+
cache.Stop()
419+
}
393420
}
394421

395422
func (wr *WorkspaceResolver) cachedWorkspace(connectionID string) (*sdk.Workspace, bool) {

internal/hosted/resolver_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,8 +439,18 @@ func TestWorkspaceResolver_InvalidateConnection(t *testing.T) {
439439
t.Fatalf("first resolve: %v", err)
440440
}
441441

442+
cache := resolver.getOrCreatePendingCache(nil, "wasteland", "wl-commons")
443+
defer cache.Stop()
444+
if _, ok := resolver.pendingCache["wasteland/wl-commons"]; !ok {
445+
t.Fatal("expected pending cache to be stored before invalidation")
446+
}
447+
442448
resolver.InvalidateConnection("conn-1")
443449

450+
if _, ok := resolver.pendingCache["wasteland/wl-commons"]; ok {
451+
t.Fatal("expected pending cache to be evicted for invalidated connection")
452+
}
453+
444454
ws2, err := resolver.Resolve(session)
445455
if err != nil {
446456
t.Fatalf("second resolve: %v", err)

0 commit comments

Comments
 (0)