lease: one S3 client per region and inputs, not one per wake
Every lease built its S3 client from a fresh AWS config load and credentials lookup (part of a 130 ms lease step on a dev wake). The client is now cached, keyed by everything a fresh load reads: the region, the AWS_* and CARLOS_S3_* environment, uid and home, and the identity of every credentials or config file the load could open. Expiring credentials are refreshed by the SDK's cache inside the client.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+190
−0
internal/lease/clientcache.go+66 −0internal/lease/clientcache_test.go+106 −0internal/lease/endpoint.go+18 −0
diff --git a/internal/lease/clientcache.go b/internal/lease/clientcache.go| new file mode 100644 |
| index 000000000..d5f2d4601 |
| --- /dev/null |
| +++ b/internal/lease/clientcache.go |
| @@ -0,0 +1,66 @@ |
| +package lease |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "sort" |
| + "strings" |
| + "sync" |
| + |
| + awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| +) |
| + |
| +// Every wake takes a lease, and building the lease's S3 client was a fresh |
| +// AWS config load and a fresh credentials lookup each time: part of the |
| +// 130 ms the lease step took on a dev wake (2026-10-24). One client per set |
| +// of inputs is kept instead. An S3 client is safe for concurrent use, and |
| +// credentials that expire (an instance profile's) are refreshed by the |
| +// SDK's own cache inside it, so sharing changes nothing a fresh load would |
| +// decide. Anything a fresh load reads is part of the key, so a change to |
| +// it builds a new client: the region asked for, the environment the SDK |
| +// and s3compat read (AWS_*, CARLOS_S3_*), the effective uid and home |
| +// directory, and the identity of every credentials or config file the |
| +// load could open (rootaws's /etc/carlos/credentials for root, the shared |
| +// files otherwise). |
| +type leaserClient struct { |
| + client *awss3.Client |
| + region string |
| +} |
| + |
| +var leaserClients sync.Map // key string -> leaserClient |
| + |
| +func resetLeaserClients() { leaserClients.Clear() } |
| + |
| +func leaserClientKey(region string) string { |
| + var env []string |
| + for _, kv := range os.Environ() { |
| + if strings.HasPrefix(kv, "AWS_") || strings.HasPrefix(kv, "CARLOS_S3_") { |
| + env = append(env, kv) |
| + } |
| + } |
| + sort.Strings(env) |
| + home, _ := os.UserHomeDir() |
| + files := []string{"/etc/carlos/credentials", os.Getenv("AWS_SHARED_CREDENTIALS_FILE"), os.Getenv("AWS_CONFIG_FILE")} |
| + if home != "" { |
| + files = append(files, filepath.Join(home, ".aws", "credentials"), filepath.Join(home, ".aws", "config")) |
| + } |
| + h := sha256.New() |
| + fmt.Fprintf(h, "region=%s\x00uid=%d\x00home=%s\x00", region, os.Geteuid(), home) |
| + for _, kv := range env { |
| + fmt.Fprintf(h, "env=%s\x00", kv) |
| + } |
| + for _, f := range files { |
| + if f == "" { |
| + continue |
| + } |
| + if st, err := os.Stat(f); err == nil { |
| + fmt.Fprintf(h, "file=%s:%d:%d:%v\x00", f, st.Size(), st.ModTime().UnixNano(), st.Mode()) |
| + } else { |
| + fmt.Fprintf(h, "file=%s:absent\x00", f) |
| + } |
| + } |
| + return hex.EncodeToString(h.Sum(nil)) |
| +} |
diff --git a/internal/lease/clientcache_test.go b/internal/lease/clientcache_test.go| new file mode 100644 |
| index 000000000..2cc6725db |
| --- /dev/null |
| +++ b/internal/lease/clientcache_test.go |
| @@ -0,0 +1,106 @@ |
| +package lease |
| + |
| +import ( |
| + "context" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func cacheTestEnv(t *testing.T) { |
| + t.Helper() |
| + t.Setenv("AWS_ACCESS_KEY_ID", "test-key") |
| + t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret") |
| + t.Setenv("AWS_SESSION_TOKEN", "") |
| + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") |
| + t.Setenv("CARLOS_S3_ENDPOINT", "") |
| + resetLeaserClients() |
| + t.Cleanup(resetLeaserClients) |
| +} |
| + |
| +// A wake builds its lease client once per region and endpoint, not once per |
| +// wake: the config load and the credentials lookup are paid by the first. |
| +func TestLeaserClientIsReusedForTheSameRegion(t *testing.T) { |
| + cacheTestEnv(t) |
| + a, ra, err := newLeaserClient(context.Background(), "eu-west-1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b, rb, err := newLeaserClient(context.Background(), "eu-west-1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if a != b || ra != rb { |
| + t.Fatalf("second call built a new client: %p %p (%q %q)", a, b, ra, rb) |
| + } |
| + c, _, err := newLeaserClient(context.Background(), "us-east-1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if c == a { |
| + t.Fatal("another region shared the client") |
| + } |
| +} |
| + |
| +// Anything that changes what a fresh load would build gives a fresh client: |
| +// the endpoint override, the environment's keys, a shared credentials file. |
| +func TestLeaserClientIsRebuiltWhenItsInputsChange(t *testing.T) { |
| + cacheTestEnv(t) |
| + first, _, err := newLeaserClient(context.Background(), "eu-west-1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for name, change := range map[string]func(t *testing.T){ |
| + "the endpoint": func(t *testing.T) { t.Setenv("CARLOS_S3_ENDPOINT", "https://fsn1.your-objectstorage.com") }, |
| + "the keys": func(t *testing.T) { t.Setenv("AWS_ACCESS_KEY_ID", "other-key") }, |
| + "the s3 quirks": func(t *testing.T) { t.Setenv("CARLOS_S3_QUIRKS", "unquoted-if-match") }, |
| + "the env region": func(t *testing.T) { t.Setenv("AWS_DEFAULT_REGION", "eu-central-1") }, |
| + "the creds file": func(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "credentials") |
| + if err := os.WriteFile(path, []byte("[default]\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", path) |
| + }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + change(t) |
| + c, _, err := newLeaserClient(context.Background(), "eu-west-1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if c == first { |
| + t.Fatalf("%s changed but the client was reused", name) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A credentials file rewritten in place (same path) is a new input too. |
| +func TestLeaserClientIsRebuiltWhenTheCredentialsFileChanges(t *testing.T) { |
| + cacheTestEnv(t) |
| + path := filepath.Join(t.TempDir(), "credentials") |
| + if err := os.WriteFile(path, []byte("[default]\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", path) |
| + first, _, err := newLeaserClient(context.Background(), "eu-west-1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + later := time.Now().Add(time.Minute) |
| + if err := os.WriteFile(path, []byte("[default]\nregion = eu-west-1\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chtimes(path, later, later); err != nil { |
| + t.Fatal(err) |
| + } |
| + c, _, err := newLeaserClient(context.Background(), "eu-west-1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if c == first { |
| + t.Fatal("the credentials file changed but the client was reused") |
| + } |
| +} |
diff --git a/internal/lease/endpoint.go b/internal/lease/endpoint.go| index 4ec0156a9..58047f041 100644 |
| --- a/internal/lease/endpoint.go |
| +++ b/internal/lease/endpoint.go |
| @@ -101,6 +101,24 @@ func regionFor(explicit, chainResolved string) string { |
| // carry that exact value onto the Held for Replicate's replica client rather |
| // than letting litestream substitute a different default of its own. |
| func newLeaserClient(ctx context.Context, region string) (*awss3.Client, string, error) { |
| + // One client per set of inputs (clientcache.go): the config load and |
| + // credentials lookup are a wake's, once, not every wake's. |
| + key := leaserClientKey(region) |
| + if c, ok := leaserClients.Load(key); ok { |
| + lc := c.(leaserClient) |
| + return lc.client, lc.region, nil |
| + } |
| + client, resolved, err := buildLeaserClient(ctx, region) |
| + if err != nil { |
| + return nil, "", err |
| + } |
| + c, _ := leaserClients.LoadOrStore(key, leaserClient{client: client, region: resolved}) |
| + lc := c.(leaserClient) |
| + return lc.client, lc.region, nil |
| +} |
| + |
| +// buildLeaserClient is newLeaserClient's uncached body. |
| +func buildLeaserClient(ctx context.Context, region string) (*awss3.Client, string, error) { |
| var opts []func(*awsconfig.LoadOptions) error |
| if region != "" { |
| opts = append(opts, awsconfig.WithRegion(region)) |