docs: add Temporal patterns (9 patterns from temporalio/temporal)

Key patterns:
- Effect buffer (transactional side effects with rollback)
- Soft assertions (log invariant violations, don't crash)
- Type-safe state transitions (HSM with source validation)
- Mutable vs immutable context (type-level access control)
- Goroutine Handle (safe lifecycle, predates CockroachDB by 3.5y)
- Dynamic config with generics (566 settings, namespace-scoped)
- Composable predicates (filter algebra with flattening)
- Persistence plugin registration (init pattern)
- ShutdownOnce (CAS-based safe channel close)
This commit is contained in:
Rodin
2026-04-30 11:40:31 -07:00
parent 2f7536766c
commit 498a3e9b27
+323
View File
@@ -0,0 +1,323 @@
# Patterns from Temporal (temporalio/temporal)
Source: github.com/temporalio/temporal
Analyzed: 2026-04-30
Commits: 8,958 | Contributors: 290
---
## 1. Effect Buffer (Transactional Side Effects)
**Location:** `common/effect/buffer.go`
Buffer side effects during a transaction, apply after
commit, rollback (in reverse order) after failure.
```go
type Buffer struct {
effects []func(context.Context)
cancels []func(context.Context)
}
func (b *Buffer) OnAfterCommit(effect func(ctx)) {
b.effects = append(b.effects, effect)
}
func (b *Buffer) OnAfterRollback(effect func(ctx)) {
b.cancels = append(b.cancels, effect)
}
func (b *Buffer) Apply(ctx context.Context) bool {
b.cancels = nil
for _, effect := range b.effects {
effect(ctx) // FIFO order
}
b.effects = nil
return true
}
func (b *Buffer) Cancel(ctx context.Context) bool {
b.effects = nil
for i := len(b.cancels) - 1; i >= 0; i-- {
b.cancels[i](ctx) // LIFO (reverse) order
}
b.cancels = nil
return true
}
```
**When to use:** Any operation that has side effects
(notifications, cache invalidation, external calls)
that should only execute if the primary transaction
succeeds.
**When NOT to use:** Simple operations where failure
leaves no cleanup needed. Over-engineering for
functions with a single side effect.
---
## 2. Soft Assertions (Log, Don't Crash)
**Location:** `common/softassert/softassert.go`
Assert invariants in production code without panicking.
Log the violation so tests catch it but production
continues serving.
```go
func That(logger log.Logger, condition bool,
staticMessage string, tags ...tag.Tag) bool {
if !condition {
logger.Error("failed assertion: "+staticMessage,
append([]tag.Tag{tag.FailedAssertion},
tags...)...)
}
return condition
}
```
Usage at call sites:
```go
if !softassert.That(logger, obj.State == "ready",
"object not ready before dispatch") {
return // or take recovery action
}
```
**The philosophy (from PR #7411):** "Why not panic?
Maybe in the future. For now, we're happy with finding
these failed assertions in functional tests."
**When to use:** Invariants that should always hold
but where crashing production is worse than logging.
Distributed systems where one node's invariant
violation shouldn't cascade.
**When NOT to use:** Safety-critical paths where
corruption would be worse than downtime. Better to
crash than serve corrupt data.
---
## 3. Type-Safe State Transitions
**Location:** `service/history/hsm/sm.go`
State machines with compile-time source state
validation and typed events.
```go
type Transition[S comparable, SM StateMachine[S], E any] struct {
Sources []S
Destination S
apply func(SM, E) (TransitionOutput, error)
}
func (t Transition[S, SM, E]) Apply(sm SM, event E) (TransitionOutput, error) {
if !slices.Contains(t.Sources, sm.State()) {
return TransitionOutput{},
fmt.Errorf("%w from %v: %v",
ErrInvalidTransition, sm.State(), event)
}
sm.SetState(t.Destination)
return t.apply(sm, event)
}
```
**When to use:** Any system with defined state
lifecycles — workflow engines, order processing,
connection state, protocol implementations.
**When NOT to use:** Simple boolean flags, systems
where state changes are free-form or user-defined.
---
## 4. Mutable vs Immutable Context (Type-Level Access Control)
**Location:** `chasm/context.go`
Separate `Context` (read-only) from `MutableContext`
(read-write) at the type level. Functions that only
read take `Context`; functions that mutate take
`MutableContext`.
```go
// Read-only operations
func (h *Handler) Check(ctx chasm.Context, c Component) (bool, error)
// Mutation operations
func (h *Handler) Execute(ctx chasm.MutableContext, c Component) error
```
**From PR discussion (Sushisource):** "I think I prefer
them separate, because what happens if you mutate
something and then say 'not ready'? That would be some
weird violation that shouldn't be possible, and separate
contexts enforces that at the type level."
**When to use:** Any system where read vs write access
matters — databases, caches, state machines, APIs with
both query and mutation.
**When NOT to use:** Simple CRUD where every operation
is both a read and a write.
---
## 5. Goroutine Handle (Safe Lifecycle)
**Location:** `common/goro/goro.go`
A handle to a goroutine that supports safe multi-stop,
context cancellation, and error collection.
```go
h := goro.NewHandle(ctx)
h.Go(func(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case task := <-ch:
process(task)
}
}
})
// Safe to call multiple times:
h.Cancel()
err := h.Wait()
```
**Born from:** PR #1892, fixing a double-close panic in
the task writer. The old code used a raw `chan struct{}`
for shutdown, which panics if closed twice.
**When to use:** Any long-lived goroutine that needs
clean shutdown, error reporting, or may be stopped from
multiple places.
**When NOT to use:** Fire-and-forget goroutines, or
goroutines managed by higher-level frameworks
(errgroup, worker pools).
---
## 6. Dynamic Config with Type-Safe Generics
**Location:** `common/dynamicconfig/`
566 settings, each declared as a typed constant with
default value, description, and precedence resolution.
```go
var WorkflowMaxTimeout = NewNamespaceDurationSetting(
"limit.maxWorkflowTimeout",
24*365*time.Hour,
`Maximum timeout for a workflow execution.`,
)
// Consumer side:
timeout := dc.GetDurationProperty(
dynamicconfig.WorkflowMaxTimeout,
namespace,
)()
```
Resolution order: task queue → namespace → global.
Uses `weak.Pointer` cache for GC-friendly memoization.
**When to use:** Large systems with many operational
knobs that need to change without restart. Multi-tenant
systems where different tenants need different limits.
**When NOT to use:** Simple services with <10 config
values. Static configuration is simpler and more
predictable.
---
## 7. Composable Predicates (Filter Algebra)
**Location:** `common/predicates/`
Generic predicate interface with And/Or/Not composition
and automatic flattening.
```go
type Predicate[T any] interface {
Test(T) bool
Equals(Predicate[T]) bool
Size() int
}
// Usage:
filter := predicates.And(
predicates.Not(isExpired),
predicates.Or(isHighPriority, isRetryable),
)
```
Flattening: `And(And(a, b), c)``And(a, b, c)`.
Short-circuit: `And(Empty, anything)``Empty`.
**When to use:** Task queues, query builders, access
control rules — anywhere filters compose.
**When NOT to use:** Single predicate checks, simple
if-statements.
---
## 8. Persistence Plugin Registration (init pattern)
**Location:** `common/persistence/sql/`
```go
func init() {
sql.RegisterPlugin("postgres12", &plugin{
driver: &driver.PQDriver{},
})
}
```
Import-driven registration. The main binary imports the
plugins it wants; each plugin's init() registers it.
**When to use:** Database drivers, encoding formats,
protocol implementations — anything with a fixed set
of implementations selected at compile time.
**When NOT to use:** When you need runtime plugin
discovery (use a factory pattern instead). When the
number of implementations is small and stable (just
use a switch).
---
## 9. ShutdownOnce (CAS-Based Safe Close)
**Location:** `common/channel/shutdown_once.go`
```go
func (c *ShutdownOnceImpl) Shutdown() {
if atomic.CompareAndSwapInt32(
&c.status, statusOpen, statusClosed) {
close(c.channel)
}
}
```
Wraps the "close of closed channel" panic with a CAS
guard. Safe to call from multiple goroutines.
**When to use:** Any shared shutdown signal where
multiple goroutines might initiate shutdown. Replaces
`sync.Once` when you also need a channel for select.
**When NOT to use:** When only one goroutine ever
triggers shutdown. When `context.WithCancel` suffices.
<!-- PATTERN_COMPLETE -->