Documentation
¶
Overview ¶
Package clientlifecycle resolves a value that is expensive to build, exactly once and race-free, without ever caching a failure.
It exists because a provider connection — an AWS configuration chain, an Azure credential, a Vault client — costs a network round trip to resolve and is then reused for the life of whatever holds it. Building it eagerly puts I/O on a constructor that has no context to bound it; building it per operation resolves the same chain over and over; and the obvious middle ground, sync.OnceValues, caches the first error forever, so one flap at startup wedges the process.
This package is the state machine that gets that right, written once so the modules that need it do not each write their own. It is specified as D-4 of the toolkit's adapter connection lifecycle spec (org 0002) and applied estate-wide by org 0003.
The contract ¶
Memoised gives you the shared, reused resolution:
- at most one build attempt is in flight at a time;
- each attempt derives a fresh, bounded context when the attempt starts — never one captured earlier, which matters when the resolver is constructed in package init where no application context exists;
- each caller waits on the attempt's result and on its own context, so a caller that leaves does not cancel the shared attempt and does not affect the callers still waiting;
- success is published and reused;
- every failure clears the in-flight state and is returned to the callers then waiting. No failure is cached, so a later call starts one new attempt. There is nothing to classify and no cache to reset.
PerCall gives you the opposite, for the caller that must not hold the value: it resolves afresh on every call and retains nothing. That is not a slower Memoised — it is a different posture. A signing backend that resolves credentials inside each operation does so deliberately, to avoid holding them longer than the operation requires, and a memoising resolver would quietly change that.
Invalidatable is the third point, for a value that is expensive to build and cannot be held indefinitely — one carrying a credential that does not renew itself. Memoise would serve it past its life with no recovery; PerCall would pay the expensive resolution every time. This holds and reuses, and re-resolves when told to. Read its documentation before wiring it: invalidating on the wrong signal is the one operation here that increases concurrency.
All three satisfy Resolver, so swapping strategy is a one-word change at the construction site and nothing else. Invalidatable additionally satisfies Invalidator.
Choosing a strategy ¶
Which one to take is the consumer's choice across three facts, not a property of the value that dictates one answer: does the value refresh itself, does the consumer want to hold the credential, and is construction expensive. A value that refreshes itself is safe to memoise but a consumer may still decline to hold it; a value that does not refresh is not safe to memoise indefinitely, whatever the consumer would prefer.
Errors ¶
A builder's error is returned unchanged — not wrapped, not annotated, not classified. This package has no opinion about how a wedged connection should read; the module that knows which provider it is presents it.
Dependencies ¶
None. This package imports only the standard library, and depfootprint_test.go asserts that its library graph contains no third-party module at all. That is deliberate and load-bearing: every provider module depends on this one, so a dependency here would be inherited estate-wide, and the one-line claim "it adds one module to the graph and nothing beneath it" is why depending on it is acceptable in the first place.
Index ¶
Examples ¶
Constants ¶
const DefaultBuildTimeout = 30 * time.Second
DefaultBuildTimeout bounds a single build attempt.
It is per attempt, not a total budget: a failed attempt caches nothing, so a later call gets a fresh allowance. Thirty seconds is long enough for an interactive credential round trip — an SSO redirect, an instance-metadata lookup — and short enough that a wedged endpoint does not hang the first caller indefinitely.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Builder ¶
Builder resolves the value.
It is called with a context bounded by the build timeout and MUST respect it. The bound is cooperative, because Go cannot preempt a running function: a builder that ignores its context runs to completion and the callers waiting on it wait that long. Every builder this was written for — the cloud SDKs' configuration loaders — honours its context, which is what makes the bound effective in practice rather than merely advisory.
type Invalidator ¶ added in v0.2.0
type Invalidator[T any] interface { Resolver[T] // Invalidate discards the held value. The next Get starts a new attempt. // // It does NOT dispose the discarded value: a caller may still be mid-operation // with one it obtained earlier, and nothing here can know. See [Invalidatable] // for what that means for closeable values. // // It never cancels an attempt already in flight. That attempt still returns its // result to the callers waiting on it — failing them would be worse than // serving a value that is merely about to be replaced — but its result will not // be held. // // It is safe to call concurrently with Get, repeatedly, and on a resolver that // has never built. Invalidate() }
Invalidator is a Resolver whose held value can be discarded, so the next Get resolves afresh.
It is a separate interface rather than a wider Resolver so that adding it broke no existing implementor or test fake.
func Invalidatable ¶ added in v0.2.0
func Invalidatable[T any](build Builder[T], opts ...Option) Invalidator[T]
Invalidatable returns an Invalidator: Memoised's sharing and reuse, plus the ability to discard the held value so the next Get resolves afresh.
It is the strategy for a value that is expensive to build but cannot be held indefinitely — one carrying a credential that does not renew itself. Memoise would serve it past its life with no recovery; PerCall would pay the expensive resolution on every operation. This holds and reuses, and re-resolves when told.
Invalidate only on asserted credential-invalidity ¶
Because single-flight is per generation, invalidation is the one operation here that can increase concurrency: attempts at different generations may overlap, and generations are created by the caller. A caller that invalidates on any failure therefore fans resolutions out at the failing provider rather than looping against one.
The deciding property is NOT whether the failure looks transient. A rate limit is transient, and invalidating on one aims concurrent resolutions at a provider that is already throttling — turning a throttle into a lockout, which is the worst outcome available here. Stated by what the error asserts rather than the code it carries, because not every provider speaks HTTP:
invalidate — the error asserts THIS CREDENTIAL is no longer valid do not — the credential is valid and the caller lacks permission (it would loop) do not — the caller is rate limited (back off) do not — "the call failed", whatever its status
Being able to draw that distinction is a PREREQUISITE for wiring invalidation, not an improvement to it. A caller whose errors only say "something failed" cannot use this safely, however sound the strategy is.
Closeable values ¶
Not for them, by composition rather than prohibition. A value that must be closed belongs to a consumer-owned type that holds and closes it; a value behind a memoising resolver has no such owner at the moment it is discarded, which is exactly why Invalidate cannot close it. A disposal hook here is not deferred but undecidable — it would have to answer "is anyone still using this?" with no information that could decide it.
Example ¶
ExampleInvalidatable shows the third strategy: the value is held and reused like clientlifecycle.Memoised, but a caller that learns the credential behind it is no longer valid can discard it, and the next Get resolves afresh.
Note what triggers the invalidation. It is not "the call failed" — it is an error asserting that this credential specifically is no longer valid. Anything looser turns a retry into concurrent resolutions against the failing provider.
package main
import (
"context"
"fmt"
"gitlab.com/phpboyscout/go/clientlifecycle"
)
// conn stands in for whatever a real builder resolves — an aws.Config, an Azure
// credential, a Vault client. The examples keep it a string so this module's
// graph stays empty.
type conn string
func main() {
builds := 0
c := clientlifecycle.Invalidatable(func(context.Context) (conn, error) {
builds++
return conn(fmt.Sprintf("connected #%d", builds)), nil
})
first, _ := c.Get(context.Background())
fmt.Println(first)
// Reused, exactly as Memoised would.
again, _ := c.Get(context.Background())
fmt.Println(again)
// The credential behind it lapsed, and the provider said so specifically.
c.Invalidate()
fresh, _ := c.Get(context.Background())
fmt.Println(fresh)
fmt.Println("builds:", builds)
}
Output: connected #1 connected #1 connected #2 builds: 2
type Option ¶
type Option func(*policy)
Option configures the build policy shared by all three strategies.
func WithBuildTimeout ¶
WithBuildTimeout overrides DefaultBuildTimeout. A non-positive duration is ignored, so a zero value from an unset field cannot silently remove the bound.
Example ¶
ExampleWithBuildTimeout shows the per-attempt bound. It is not a total budget: because a failure caches nothing, the call after a timeout gets a fresh allowance rather than inheriting an exhausted one.
package main
import (
"context"
"errors"
"fmt"
"time"
"gitlab.com/phpboyscout/go/clientlifecycle"
)
// conn stands in for whatever a real builder resolves — an aws.Config, an Azure
// credential, a Vault client. The examples keep it a string so this module's
// graph stays empty.
type conn string
func main() {
c := clientlifecycle.Memoised(
func(ctx context.Context) (conn, error) {
<-ctx.Done() // never resolves in time
return "", ctx.Err()
},
clientlifecycle.WithBuildTimeout(10*time.Millisecond),
)
_, err := c.Get(context.Background())
fmt.Println(errors.Is(err, context.DeadlineExceeded))
}
Output: true
func WithLifetimeContext ¶
WithLifetimeContext scopes a Memoised or Invalidatable resolver's build attempts to the life of the thing that owns it, so shutting that down abandons an attempt in flight.
It is deliberately not a call's context. A caller's cancellation must reach that caller alone; this is the separate, longer-lived scope, and holding it on the resolver is the only way to express "this whole source is finished" for a resolver that may be constructed in package init with no context to hand.
Invalidatable honours it exactly as Memoised does, and invalidation does not weaken it: the lifetime bounds every attempt at every generation, and a resolver whose lifetime is already done refuses to build however many times it has been invalidated.
PerCall ignores it: a resolution that is already scoped to its caller and retains nothing has no separate lifetime to scope.
type Resolver ¶
Resolver hands out the value, building it if and when the strategy says to.
Get blocks until the value is available, the attempt fails, or the caller's own context is done — whichever happens first. It is safe for concurrent use.
It is deliberately one method. A consumer declares its own narrow interface of this shape rather than importing this package for the type, and widening it would force every such declaration to grow a method it does not use — which is why Invalidatable adds Invalidator beside it rather than adding Invalidate here.
func Memoised ¶
Memoised returns a Resolver that builds the value at most once, sharing one attempt between concurrent callers and never caching a failure.
The returned resolver does no I/O and takes no lock at construction; the first Get starts the first attempt.
"At most once" is a contract, so the returned value exposes Get and nothing else. See [memoOnly] for why that is worth an allocation.
Example ¶
ExampleMemoised shows the shared resolution: the builder runs on the first Get and every later caller reuses what it produced.
package main
import (
"context"
"fmt"
"gitlab.com/phpboyscout/go/clientlifecycle"
)
// conn stands in for whatever a real builder resolves — an aws.Config, an Azure
// credential, a Vault client. The examples keep it a string so this module's
// graph stays empty.
type conn string
func main() {
builds := 0
c := clientlifecycle.Memoised(func(context.Context) (conn, error) {
builds++
return "connected", nil
})
for range 3 {
v, err := c.Get(context.Background())
if err != nil {
fmt.Println("resolve:", err)
return
}
fmt.Println(v)
}
fmt.Println("builds:", builds)
}
Output: connected connected connected builds: 1
Example (FailureIsNotCached) ¶
ExampleMemoised_failureIsNotCached shows the property sync.OnceValues cannot give you: a failed attempt is returned to the callers waiting on it and then forgotten, so the next call tries again rather than inheriting the failure.
package main
import (
"context"
"errors"
"fmt"
"gitlab.com/phpboyscout/go/clientlifecycle"
)
// conn stands in for whatever a real builder resolves — an aws.Config, an Azure
// credential, a Vault client. The examples keep it a string so this module's
// graph stays empty.
type conn string
func main() {
attempts := 0
c := clientlifecycle.Memoised(func(context.Context) (conn, error) {
attempts++
if attempts == 1 {
return "", errors.New("credentials not ready")
}
return "connected", nil
})
if _, err := c.Get(context.Background()); err != nil {
fmt.Println("first:", err)
}
v, err := c.Get(context.Background())
if err != nil {
fmt.Println("second:", err)
return
}
fmt.Println("second:", v)
}
Output: first: credentials not ready second: connected
func PerCall ¶
PerCall returns a Resolver that builds the value on every call and retains nothing between them.
Concurrent calls are deliberately not collapsed into one attempt. Collapsing them would share a resolution between callers who asked not to share one, which is the whole reason this strategy exists.
Each build is bounded by the build timeout, descending from the caller's own context rather than a lifetime context: the resolution belongs to that call and ends with it.
Example ¶
ExamplePerCall shows the other posture: resolve afresh every time and hold nothing, for a caller that must not keep credentials beyond the operation.
package main
import (
"context"
"fmt"
"gitlab.com/phpboyscout/go/clientlifecycle"
)
// conn stands in for whatever a real builder resolves — an aws.Config, an Azure
// credential, a Vault client. The examples keep it a string so this module's
// graph stays empty.
type conn string
func main() {
builds := 0
c := clientlifecycle.PerCall(func(context.Context) (conn, error) {
builds++
return "connected", nil
})
for range 3 {
if _, err := c.Get(context.Background()); err != nil {
fmt.Println("resolve:", err)
return
}
}
fmt.Println("builds:", builds)
}
Output: builds: 3