singleflight

package
v0.4.4 Latest Latest
Warning

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

Go to latest
Published: Sep 15, 2026 License: MIT Imports: 4 Imported by: 0

README

singleflight

Generic call deduplication Group[K,V] — a kernel primitive (stdlib-only, no domain vocabulary). N concurrent callers naming the same key produce exactly one execution, and all N receive its result.

var group singleflight.Group[string, []byte]

body, shared, err := group.Do(ctx, url, func(callCtx context.Context) ([]byte, error) {
    return fetch(callCtx, url) // runs once, however many callers arrive
})

A caller that abandons (its context is cancelled) stops waiting and gets its own ctx.Err(); the shared call keeps running for everyone else, and is cancelled only when the last caller leaves. A panic in the call is re-raised in every waiter as a PanicValue carrying the originating stack.

It is not a cache (nothing is remembered once the call ends) and it deduplicates within one process only.

ADR 0049. See CLAUDE.md for the design, and BENCH.md for the measured threshold below which a Group costs more than it saves.

Documentation

Overview

Package singleflight — the in-flight call record shared by every caller of one key.

Package singleflight — the panic carried out of the shared call into every waiter.

Package singleflight deduplicates concurrent work that names the same key: of N goroutines asking for the same thing at the same time, exactly one does the work and all N receive its result. It is a kernel primitive — stdlib only, fully generic, and free of domain vocabulary (Group, Do, Forget; no Cache, no Request, no Key that means something).

The shared call outlives any single caller

The obvious implementation runs fn on the first caller's goroutine with the first caller's context. It has a defect that only shows up under load: the caller who arrived first is also the one most likely to give up first — it has been waiting longest — and when it does, every other caller inherits its cancellation. One abandoned request fails ten that were still willing to wait.

So fn runs on a goroutine of its own, under a context derived from the first caller's with cancellation removed (context.WithoutCancel), and that context is cancelled only when the LAST caller has left. A caller that abandons stops waiting and gets its own ctx.Err(); the call keeps running for everyone else. When nobody is left, the work stops — nothing is computed for an audience of zero.

Two consequences are worth stating rather than discovering:

  • The shared call inherits the FIRST caller's context VALUES, and no caller's deadline. A follower that expected its own request-scoped values (a trace id, a tenant) will not see them; it sees the leader's. Deduplication means one execution, and one execution can carry only one set of values.
  • Because fn runs on its own goroutine, a Do that has returned has not necessarily stopped fn. Abandonment is a departure, not a kill.

A panic is delivered, never swallowed

If fn panics, the recovered value and its stack are captured and re-raised in EVERY waiter as a PanicValue. The alternative — recover and report an error — turns a programming fault into a runtime condition and loses the original stack; the other alternative — let it escape the goroutine — takes the process down while the waiters are still blocked on a channel that will never close. One panic becoming N is the honest arithmetic: N callers asked for a result, and none of them can have one.

What this is NOT

It is not a cache: nothing is remembered once the call completes, so two SEQUENTIAL Do calls run fn twice. It is not a lock: two DIFFERENT keys never wait for each other. And it is per-PROCESS — N replicas of a service each running this primitive still send N concurrent calls to whatever is behind them.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Group

type Group[K comparable, V any] struct {
	// contains filtered or unexported fields
}

Group deduplicates concurrent Group.Do calls that name the same key. The zero value is ready to use; a Group must not be copied after first use.

Safe for concurrent use by any number of goroutines.

func (*Group[K, V]) Do

func (g *Group[K, V]) Do(ctx context.Context, key K, fn func(ctx context.Context) (V, error)) (value V, shared bool, err error)

Do runs fn for key unless an identical key is already in flight, in which case it waits for that call instead of starting a second one. shared reports whether the result came from a call this goroutine did not start.

fn does NOT receive ctx. It receives the shared call's context: the first caller's values with every deadline and cancellation removed, cancelled only once every caller has stopped waiting. See the package documentation.

A ctx that is done while this goroutine is still waiting returns (zero, shared, ctx.Err()) and leaves the call running for whoever else is waiting. A result that is already available is delivered even if ctx is also done — cancellation preempts waiting, not delivery.

If fn panics, Do panics with a PanicValue carrying the original value and fn's stack.

func (*Group[K, V]) Forget

func (g *Group[K, V]) Forget(key K)

Forget retires key so the NEXT Do starts a fresh call. Callers already waiting on the in-flight call keep waiting and still receive its result — forgetting a key drops the dedup entry, it does not abandon the work.

It exists for the case where the in-flight call is known to be answering a question that has since changed: without it, a caller arriving one microsecond later would be handed a result computed from stale inputs and would have no way to tell.

func (*Group[K, V]) InFlight

func (g *Group[K, V]) InFlight() int

InFlight reports how many keys a Group.Do arriving now would JOIN rather than start — the deduplication entries currently registered. It is a point-in-time reading, useful for a gauge or a test — never for a decision, since the number can change between the read and the next statement.

It is NOT a count of running calls. A key that was Forgotten, or whose every caller abandoned it, stops being counted while its fn may still be running — Forget drops the entry and not the work, and abandonment is a departure and not a kill — so a forgotten key that a later Do re-issued counts ONCE while two calls for it run.

type PanicValue

type PanicValue struct {
	// Raised is the value the original panic carried, verbatim.
	Raised any
	// Stack is the stack of the goroutine that ran fn, captured at recovery.
	Stack []byte
}

PanicValue carries a panic raised inside a deduplicated call across the goroutine boundary to every caller waiting on it.

It is deliberately NOT an error. A panic is a programming fault, and converting one into a value the caller may ignore is how a broken invariant becomes a silent wrong answer; it is also why this type carries no error code (the kernel's cache and clock primitives take the same position — a programmer error is not a runtime condition with a dotted quad).

The stack is captured at the moment of recovery, inside the goroutine that ran fn. Without it a re-raised panic would point at the waiter's stack — code that did nothing wrong — and the frame that actually failed would be gone.

func (PanicValue) String

func (p PanicValue) String() string

String renders the original panic followed by the stack of the goroutine that raised it. The Go runtime prints a panic value through String when the value has one, so an uncaught re-raise shows fn's stack rather than only the waiter's.

Jump to

Keyboard shortcuts

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