clientlifecycle

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 17, 2026 License: MIT Imports: 3 Imported by: 0

README

clientlifecycle

Resolve a value that is expensive to build — exactly once, race-free, and without ever caching a failure.

import "gitlab.com/phpboyscout/go/clientlifecycle"

// Built at most once, shared by every caller, retried if it fails.
cfg := clientlifecycle.Memoised(func(ctx context.Context) (aws.Config, error) {
    return config.LoadDefaultConfig(ctx)
})

c, err := cfg.Get(ctx) // the first call resolves; the rest reuse

Why it exists

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. The three obvious ways to handle that are each wrong in a different way:

  • Build it in the constructor and you have put I/O somewhere with no context to bound it, and no way to fail gracefully.
  • Build it per operation and you resolve the same chain over and over.
  • Build it with sync.OnceValues and the first error is cached forever, so one flap at startup wedges the process until it is restarted.

This package is the state machine that gets it right, written once so the modules that need it do not each write their own — and get it subtly differently wrong.

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 gives up 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:

cfg := clientlifecycle.PerCall(func(ctx context.Context) (aws.Config, error) {
    return config.LoadDefaultConfig(ctx)
})

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. Concurrent calls are not collapsed, because collapsing them would share a resolution between callers who asked not to share one.

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:

src := clientlifecycle.Invalidatable(func(ctx context.Context) (*Client, error) {
    return dial(ctx)
})

// ... later, on an error asserting the credential is no longer valid:
src.Invalidate()

Memoised would serve such a value past its life with no recovery; PerCall would pay the expensive resolution on every operation. This holds and reuses, and re-resolves when told. It satisfies Invalidator, which embeds Resolver — added beside Resolver rather than widening it, so no existing implementor or test fake had to change.

Invalidate only on asserted credential-invalidity. Single-flight is per-generation, so attempts at different generations may overlap and generations are created by the caller. Invalidating 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 already throttling you.

The error asserts Invalidate?
this credential is no longer valid yes
the credential is valid, you lack permission no — it would loop
you are rate limited no — back off
"the call failed" no

Being able to draw that distinction is a prerequisite for wiring invalidation, not an improvement to it.

Invalidate does not dispose the discarded value — a caller may still be mid-operation with one it obtained earlier. So this strategy is not for values that must be closed: those belong to a consumer-owned type that holds and closes them, and a value behind a memoising resolver has no such owner at the moment it is discarded.

All three satisfy Resolver, so switching strategy is a one-word change at the construction site and nothing else. Which one to take is the consumer's choice across three facts — does the value refresh itself, does the consumer want to hold the credential, is construction expensive — rather than a property of the value that dictates one answer.

Options

Option Effect
WithBuildTimeout(d) Bounds a single attempt. Defaults to DefaultBuildTimeout (30s). Per attempt, not a total budget — a failure caches nothing, so a later call gets a fresh allowance.
The bound is cooperative: Go cannot preempt a running function, so a builder that ignores its context runs to completion and its waiters wait that long. Every builder this was written for — the cloud SDKs' configuration loaders — honours its context, which is what makes the bound effective rather than advisory.
WithLifetimeContext(ctx) Scopes a Memoised resolver's attempts to the life of whatever owns it. Ignored by PerCall, which is already scoped to its caller.

A resolver whose lifetime context is already done does not build at all, and a PerCall whose caller has already cancelled does not either. The check is not redundant with a builder respecting its context: a builder that ignores it would otherwise succeed after shutdown, and that success would then be memoised.

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.

What it costs

Nothing. The library graph contains no third-party module at all, which depfootprint_test.go asserts rather than claims. Every provider module depends on this one, so anything arriving here would be inherited estate-wide.

Specification

org 0003 — provider client modules, which applies the connection lifecycle of org 0002 across the estate. The behaviour above is 0002's D-4; the zero-dependency rule and PerCall are 0003's P-4 and P-12. Invalidatable is commissioned by 0003's P-16, which places the three strategies on an axis orthogonal to the injection ladder and records why the staleness case takes neither of the other two.

Licence

MIT. See LICENSE.

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

View Source
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

type Builder[T any] func(context.Context) (T, error)

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

func WithBuildTimeout(d time.Duration) Option

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

func WithLifetimeContext(ctx context.Context) Option

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

type Resolver[T any] interface {
	Get(ctx context.Context) (T, error)
}

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

func Memoised[T any](build Builder[T], opts ...Option) Resolver[T]

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

func PerCall[T any](build Builder[T], opts ...Option) Resolver[T]

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

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL