Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Bolt's Journal

## 2026-03-08 - GCP Logging Client Caching & Connection Longevity
**Learning:** Establishing the GCP Stackdriver Logging client requires repeated Google credential discovery and connection establishment, causing high latency (~300ms) inside a reactive TUI interface. Caching `logadmin.Client` instances via a project-aware map with thread-safe `sync.Mutex` ensures subsequent streaming and log extraction operations are instantaneous. Crucially, calling `Close()` on individual stream terminations must be a no-op to prevent premature teardown of connection pools shared across other active streaming views.
**Action:** Keep GCP Logging clients cached globally by project and handle connection termination via a no-op `Close` method, while adding test-isolation resets in unit tests.

## 2026-03-07 - GCP Execution & Project Client Caching
**Learning:** Incomplete caching of GCP client wrappers in remaining packages (`job/execution` and `project`) meant that loading job execution tables or searching for GCP projects still suffered from repetitive credential discovery and connection establishment, degrading TUI interactivity. Standardizing stateful `GCPClient` caching via thread-safe lazy-initialization with `sync.Mutex` completely eliminates this overhead.
**Action:** Ensure all Cloud SDK APIs used in the application leverage the stateful lazy-initialization cached client pattern with proper thread-safety.
Expand Down
42 changes: 35 additions & 7 deletions internal/run/api/log/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ package log
import (
"context"
"fmt"
"sync"

"cloud.google.com/go/logging"
"cloud.google.com/go/logging/logadmin"
"github.com/JulienBreux/run-cli/internal/run/api/client"
"golang.org/x/oauth2/google"
"google.golang.org/api/option"
)

Expand Down Expand Up @@ -70,24 +72,49 @@ func (w *RealLogAdminClient) Close() error {
return w.client.Close()
}

// Global cache for thread-safe GCP Logging Clients to eliminate repetitive
// credentials discovery and client creation overhead (~300ms latency per call).
var (
logClients = make(map[string]*GCPClient)
logClientCreds *google.Credentials
logClientMu sync.Mutex
)

// GCPClient is the Google Cloud Platform implementation of Client.
type GCPClient struct {
client LogAdminClientWrapper
}

// NewGCPClient creates a new GCPClient.
// NewGCPClient creates a new GCPClient, using cached clients and credentials
// where available to eliminate repetitive discovery and connection overhead (~300ms latency).
func NewGCPClient(ctx context.Context, projectID string) (Client, error) {
creds, err := client.FindDefaultCredentials(ctx, logging.ReadScope)
if err != nil {
return nil, fmt.Errorf("failed to find default credentials: %w", err)
logClientMu.Lock()
defer logClientMu.Unlock()

if logClientCreds == nil {
// Discover credentials using a background context to ensure they remain valid
// even if the calling request context is cancelled.
creds, err := client.FindDefaultCredentials(context.Background(), logging.ReadScope)
if err != nil {
return nil, fmt.Errorf("failed to find default credentials: %w", err)
}
logClientCreds = creds
}

c, err := createLogAdminClient(ctx, projectID, option.WithCredentials(creds))
if cachedClient, exists := logClients[projectID]; exists {
return cachedClient, nil
}

// Create new client using a background context to ensure connection pool longevity
c, err := createLogAdminClient(context.Background(), projectID, option.WithCredentials(logClientCreds))
if err != nil {
return nil, err
}

return &GCPClient{client: c}, nil
gcpClient := &GCPClient{client: c}
logClients[projectID] = gcpClient

return gcpClient, nil
}

func (c *GCPClient) Entries(ctx context.Context, opts ...interface{}) EntryIterator {
Expand All @@ -101,7 +128,8 @@ func (c *GCPClient) Entries(ctx context.Context, opts ...interface{}) EntryItera
}

func (c *GCPClient) Close() error {
return c.client.Close()
// Close is a no-op to avoid closing shared connections
return nil
}

// GCPEntryIterator wraps logadmin.EntryIterator.
Expand Down
50 changes: 50 additions & 0 deletions internal/run/api/log/log_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,19 +244,28 @@ func (m *MockLogAdminClientWrapper) Close() error {
return nil
}

func resetCache() {
logClientMu.Lock()
logClients = make(map[string]*GCPClient)
logClientCreds = nil
logClientMu.Unlock()
}

func TestGCPClient(t *testing.T) {
origFindCreds := client.FindDefaultCredentials
origCreateClient := createLogAdminClient
defer func() {
client.FindDefaultCredentials = origFindCreds
createLogAdminClient = origCreateClient
resetCache()
}()

client.FindDefaultCredentials = func(ctx context.Context, scopes ...string) (*google.Credentials, error) {
return &google.Credentials{}, nil
}

t.Run("NewGCPClient_Success", func(t *testing.T) {
resetCache()
createLogAdminClient = func(ctx context.Context, projectID string, opts ...option.ClientOption) (LogAdminClientWrapper, error) {
return &MockLogAdminClientWrapper{
EntriesFunc: func(ctx context.Context, opts ...logadmin.EntriesOption) EntryIterator {
Expand All @@ -280,6 +289,7 @@ func TestGCPClient(t *testing.T) {
})

t.Run("NewGCPClient_AuthError", func(t *testing.T) {
resetCache()
client.FindDefaultCredentials = func(ctx context.Context, scopes ...string) (*google.Credentials, error) {
return nil, errors.New("auth failed")
}
Expand All @@ -290,6 +300,7 @@ func TestGCPClient(t *testing.T) {
})

t.Run("NewGCPClient_ClientCreationError", func(t *testing.T) {
resetCache()
client.FindDefaultCredentials = func(ctx context.Context, scopes ...string) (*google.Credentials, error) {
return &google.Credentials{}, nil
}
Expand All @@ -301,6 +312,45 @@ func TestGCPClient(t *testing.T) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "creation failed")
})

t.Run("NewGCPClient_CachingAndReuse", func(t *testing.T) {
resetCache()

credsCallCount := 0
client.FindDefaultCredentials = func(ctx context.Context, scopes ...string) (*google.Credentials, error) {
credsCallCount++
return &google.Credentials{}, nil
}

creationCallCount := 0
createLogAdminClient = func(ctx context.Context, projectID string, opts ...option.ClientOption) (LogAdminClientWrapper, error) {
creationCallCount++
return &MockLogAdminClientWrapper{
CloseFunc: func() error { return nil },
}, nil
}

// First call
client1, err := NewGCPClient(context.Background(), "project-a")
assert.NoError(t, err)
assert.NotNil(t, client1)

// Second call for the SAME project should return cached client
client2, err := NewGCPClient(context.Background(), "project-a")
assert.NoError(t, err)
assert.Same(t, client1, client2)

// Third call for a DIFFERENT project should create a new client, but REUSE credentials
client3, err := NewGCPClient(context.Background(), "project-b")
assert.NoError(t, err)
assert.NotNil(t, client3)
assert.NotSame(t, client1, client3)

// Verify credentials were only discovered ONCE
assert.Equal(t, 1, credsCallCount)
// Verify clients were created EXACTLY TWICE (one for project-a, one for project-b)
assert.Equal(t, 2, creationCallCount)
})
}

func TestWrappers_Delegation(t *testing.T) {
Expand Down
37 changes: 34 additions & 3 deletions internal/run/tui/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,19 @@ package app

import (
"testing"
"time"

"github.com/JulienBreux/run-cli/internal/run/config"
"github.com/JulienBreux/run-cli/internal/run/model/common/info"
model_domainmapping "github.com/JulienBreux/run-cli/internal/run/model/domainmapping"
model_job "github.com/JulienBreux/run-cli/internal/run/model/job"
model_service "github.com/JulienBreux/run-cli/internal/run/model/service"
model_workerpool "github.com/JulienBreux/run-cli/internal/run/model/workerpool"
"github.com/JulienBreux/run-cli/internal/run/tui/app/describe"
"github.com/JulienBreux/run-cli/internal/run/tui/app/domainmapping"
"github.com/JulienBreux/run-cli/internal/run/tui/app/job"
"github.com/JulienBreux/run-cli/internal/run/tui/app/log"
"github.com/JulienBreux/run-cli/internal/run/tui/app/project"
"github.com/JulienBreux/run-cli/internal/run/tui/app/service"
service_scale "github.com/JulienBreux/run-cli/internal/run/tui/app/service/scale"
"github.com/JulienBreux/run-cli/internal/run/tui/app/workerpool"
Expand All @@ -48,6 +52,13 @@ func setupTestApp() {
mainLoader = loader.New(app)
currentConfig = &config.Config{Project: "test-project", Region: "us-central1"}

// Globally mock fetch and list functions to prevent them from contacting real GCP APIs in TUI unit tests
project.PreLoad = func() error { return nil }
service.Fetch = func(projectID, region string) ([]model_service.Service, error) { return nil, nil }
job.ListJobsFunc = func(project, region string) ([]model_job.Job, error) { return nil, nil }
workerpool.ListWorkerPoolsFunc = func(project, region string) ([]model_workerpool.WorkerPool, error) { return nil, nil }
domainmapping.ListDomainMappingsFunc = func(project, region string) ([]model_domainmapping.DomainMapping, error) { return nil, nil }

// Reset global state to avoid interference between tests
previousPageID = ""
currentPageID = ""
Expand Down Expand Up @@ -184,8 +195,25 @@ func TestInitializeApp(t *testing.T) {
go func() {
_ = app.Run()
}()
defer app.Stop()

defer func() {
app.Stop()
time.Sleep(150 * time.Millisecond)
}()

// Mock PreLoad and Fetch to avoid calling real GCP client/credentials APIs in tests
origPreLoad := project.PreLoad
origFetch := service.Fetch
defer func() {
project.PreLoad = origPreLoad
service.Fetch = origFetch
}()
project.PreLoad = func() error {
return nil
}
service.Fetch = func(projectID, region string) ([]model_service.Service, error) {
return []model_service.Service{}, nil
}

// Ensure rootPages is init
rootPages = tview.NewPages()
// Re-initialize mainLoader to be safe against race/overwrite in other tests
Expand Down Expand Up @@ -218,7 +246,10 @@ func TestSwitchTo(t *testing.T) {
buildLayout() // Inits footerPages, footerSpinner

go func() { _ = app.Run() }()
defer app.Stop()
defer func() {
app.Stop()
time.Sleep(150 * time.Millisecond)
}()

// Test Service List
switchTo(service.LIST_PAGE_ID)
Expand Down
4 changes: 2 additions & 2 deletions internal/run/tui/app/domainmapping/domainmapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const (
MODAL_PAGE_ID = "modal-dns-records"
)

var listDomainMappingsFunc = api_domainmapping.List
var ListDomainMappingsFunc = api_domainmapping.List

// List returns a list of domain mappings.
func List(app *tview.Application) *table.Table {
Expand Down Expand Up @@ -89,7 +89,7 @@ func ListReload(app *tview.Application, currentInfo info.Info, onResult func(err
go func() {
// Fetch real data
var err error
domainMappings, err = listDomainMappingsFunc(currentInfo.Project, currentInfo.Region)
domainMappings, err = ListDomainMappingsFunc(currentInfo.Project, currentInfo.Region)

app.QueueUpdateDraw(func() {
defer func() {
Expand Down
14 changes: 7 additions & 7 deletions internal/run/tui/app/domainmapping/domainmapping_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,14 @@ func TestListReload(t *testing.T) {

List(app)

// Mock listDomainMappingsFunc
originalListDomainMappingsFunc := listDomainMappingsFunc
defer func() { listDomainMappingsFunc = originalListDomainMappingsFunc }()
// Mock ListDomainMappingsFunc
originalListDomainMappingsFunc := ListDomainMappingsFunc
defer func() { ListDomainMappingsFunc = originalListDomainMappingsFunc }()

expectedDMs := []model_domainmapping.DomainMapping{
{Name: "reloaded.example.com"},
}
listDomainMappingsFunc = func(projectID, region string) ([]model_domainmapping.DomainMapping, error) {
ListDomainMappingsFunc = func(projectID, region string) ([]model_domainmapping.DomainMapping, error) {
return expectedDMs, nil
}

Expand Down Expand Up @@ -106,10 +106,10 @@ func TestListReload_Error(t *testing.T) {

List(app)

originalListDomainMappingsFunc := listDomainMappingsFunc
defer func() { listDomainMappingsFunc = originalListDomainMappingsFunc }()
originalListDomainMappingsFunc := ListDomainMappingsFunc
defer func() { ListDomainMappingsFunc = originalListDomainMappingsFunc }()

listDomainMappingsFunc = func(projectID, region string) ([]model_domainmapping.DomainMapping, error) {
ListDomainMappingsFunc = func(projectID, region string) ([]model_domainmapping.DomainMapping, error) {
return nil, errors.New("fetch error")
}

Expand Down
4 changes: 2 additions & 2 deletions internal/run/tui/app/job/job.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const (
LIST_PAGE_SHORTCUT = tcell.KeyCtrlJ
)

var listJobsFunc = api_job.List
var ListJobsFunc = api_job.List

// List returns a list of jobs.
func List(app *tview.Application) *table.Table {
Expand Down Expand Up @@ -87,7 +87,7 @@ func ListReload(app *tview.Application, currentInfo info.Info, onResult func(err
go func() {
// Fetch real data
var err error
jobs, err = listJobsFunc(currentInfo.Project, currentInfo.Region)
jobs, err = ListJobsFunc(currentInfo.Project, currentInfo.Region)

app.QueueUpdateDraw(func() {
defer func() {
Expand Down
14 changes: 7 additions & 7 deletions internal/run/tui/app/job/job_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,14 @@ func TestListReload(t *testing.T) {

List(app)

// Mock listJobsFunc
originalListJobsFunc := listJobsFunc
defer func() { listJobsFunc = originalListJobsFunc }()
// Mock ListJobsFunc
originalListJobsFunc := ListJobsFunc
defer func() { ListJobsFunc = originalListJobsFunc }()

expectedJobs := []model_job.Job{
{Name: "projects/p/locations/r/jobs/job-reloaded"},
}
listJobsFunc = func(projectID, region string) ([]model_job.Job, error) {
ListJobsFunc = func(projectID, region string) ([]model_job.Job, error) {
return expectedJobs, nil
}

Expand Down Expand Up @@ -111,10 +111,10 @@ func TestListReload_Error(t *testing.T) {

List(app)

originalListJobsFunc := listJobsFunc
defer func() { listJobsFunc = originalListJobsFunc }()
originalListJobsFunc := ListJobsFunc
defer func() { ListJobsFunc = originalListJobsFunc }()

listJobsFunc = func(projectID, region string) ([]model_job.Job, error) {
ListJobsFunc = func(projectID, region string) ([]model_job.Job, error) {
return nil, errors.New("fetch error")
}

Expand Down
2 changes: 1 addition & 1 deletion internal/run/tui/app/project/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ var (
)

// PreLoad fetches the projects and caches them.
func PreLoad() error {
var PreLoad = func() error {
var err error
CachedProjects, err = api_project.List()
return err
Expand Down
2 changes: 1 addition & 1 deletion internal/run/tui/app/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ const (
var listServicesFunc = api_service.List

// Fetch retrieves the list of services from the API.
func Fetch(projectID, region string) ([]model_service.Service, error) {
var Fetch = func(projectID, region string) ([]model_service.Service, error) {
return listServicesFunc(projectID, region)
}

Expand Down
4 changes: 2 additions & 2 deletions internal/run/tui/app/workerpool/workerpool.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ const (
SCALE_MODAL_PAGE_ID = "scale-workerpool"
)

var listWorkerPoolsFunc = api_workerpool.List
var ListWorkerPoolsFunc = api_workerpool.List

// List returns a list of workers.
func List(app *tview.Application) *table.Table {
Expand Down Expand Up @@ -92,7 +92,7 @@ func ListReload(app *tview.Application, currentInfo info.Info, onResult func(err
go func() {
// Fetch real data
var err error
workers, err = listWorkerPoolsFunc(currentInfo.Project, currentInfo.Region)
workers, err = ListWorkerPoolsFunc(currentInfo.Project, currentInfo.Region)

app.QueueUpdateDraw(func() {
defer func() {
Expand Down
Loading
Loading