persistence

package
v0.1.0-develop.2026061... Latest Latest
Warning

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

Go to latest
Published: Jun 13, 2026 License: MIT Imports: 6 Imported by: 0

Documentation

Overview

Package persistence defines shared persistence abstractions for the GoCell framework. These interfaces are implemented by adapters (e.g., adapters/postgres) and consumed by cells via dependency injection.

Package persistence defines shared transaction abstractions for the GoCell framework. TxCtxKey is owned by the kernel so that adapters (e.g. adapters/postgres) can WRITE a concrete tx into ctx and cells' own adapter implementations can READ it, without either side importing the other (per CLAUDE.md cells→adapters layering rule).

Contract: only ONE adapter may claim this key per assembly. If a second DB adapter is introduced, define its own key — do NOT reuse TxCtxKey for a different value type.

Index

Constants

This section is empty.

Variables

View Source
var TxCtxKey = txKey{}

TxCtxKey is the context value key used by transactional adapters. Adapters (e.g. adapters/postgres) use this to store their concrete tx (e.g. pgx.Tx); cell-local adapters retrieve and type-assert.

ref: go-zero TransactCtx — session injected via context for downstream participation in ambient transaction. Adopted pattern; kernel owns the key, adapters own the typed helpers.

Functions

func AfterCommitMark

func AfterCommitMark(ctx context.Context) int

AfterCommitMark returns the number of hooks currently in the ambient registry — a checkpoint a RunInTx scope captures before running its fn so it can discard exactly the hooks fn registers if fn fails (see TruncateAfterCommitTo). Returns 0 when no registry is present.

func RegisterAfterCommit

func RegisterAfterCommit(ctx context.Context, hook AfterCommitHook)

RegisterAfterCommit appends hook to the after-commit registry installed by the enclosing RunInTx. It is the sole public entry point for scheduling post-commit work; call it from within a RunInTx fn body.

Calling RegisterAfterCommit outside a managed transaction (no ambient registry) is a programmer error and panics through the approved funnel — scheduling a post-commit side effect with no commit to follow would silently drop the effect. (spring-tx's registerSynchronization likewise throws IllegalStateException when no synchronization is active.) The panic propagates up the stack; in an HTTP request it is recovered by the framework Recovery middleware as a 500. In tests, assert it with require.Panics.

A nil hook is ignored. A hook registered while hooks are draining is ignored (snapshot semantics; there is no second round).

func RunAfterCommitHooks

func RunAfterCommitHooks(ctx context.Context)

RunAfterCommitHooks drains and synchronously runs the registered hooks on the committing goroutine. It must be called only by the outermost RunInTx, and only after a durable commit.

Each hook runs under context.WithoutCancel so it survives the request ctx being canceled (e.g. an HTTP handler that has already returned), and with the ambient tx stripped (TxCtxKey shadowed) so the committed tx is unreachable through the passed ctx. Each hook is recovered independently: a panic in one hook is logged and does not stop the others or escape to the caller.

Draining a ctx with no registry is a safe no-op.

func TruncateAfterCommitTo

func TruncateAfterCommitTo(ctx context.Context, mark int)

TruncateAfterCommitTo discards every hook registered after mark — the hooks of a RunInTx scope whose fn returned an error or panicked. The scope's unit of work was rolled back (a PG savepoint, or simply not committed), so the premise of its after-commit side effects no longer holds and they must not fire even if an enclosing scope swallows the error and commits.

Sole callers are TxRunner implementations on their fn-failure path (archtest AFTERCOMMIT-HOOK-PURE-TRANSIENT-01/A3 caller allowlist). A no-op when no registry is present or mark is out of [0, len].

func TxFromContext

func TxFromContext[T any](ctx context.Context) (T, bool)

TxFromContext extracts the ambient transaction carrier stored under TxCtxKey, type-asserting it to T. The boolean reports whether a value of type T was present. The type parameter is supplied by the caller (e.g. adapters/postgres and cell-private PG adapters pass pgx.Tx), so this kernel helper stays driver-agnostic and does NOT import any DB SDK — the package doc invariant ("intentionally does not import pgx") is preserved while the extract+assert logic lives in exactly one place.

Caller invariant: the writer ([CtxWithTx]) is responsible for storing a non-nil carrier. A typed-nil concrete pointer stored under TxCtxKey (e.g. (*pgx.Conn)(nil)) succeeds the type assertion and returns (typed-nil, true) — downstream `tx.Exec` would nil-panic. Adapters must only inject live transactions; this helper does not defensively re-check. A typed-nil interface (fakeTx(nil)) is correctly reported as (zero, false) since `context.WithValue` stores the underlying nil interface{}.

func WithAfterCommitRegistry

func WithAfterCommitRegistry(ctx context.Context) (context.Context, bool)

WithAfterCommitRegistry installs a fresh after-commit registry into ctx unless one is already present (a nested RunInTx). The bool reports whether THIS call installed the registry: only the outermost installer is responsible for draining via RunAfterCommitHooks, so nested hooks accumulate into the outer registry and fire once after the outermost commit.

Sole callers are TxRunner implementations (archtest AFTERCOMMIT-HOOK-PURE-TRANSIENT-01/A3 caller allowlist).

Types

type AfterCommitHook

type AfterCommitHook func(ctx context.Context)

AfterCommitHook is a best-effort, transient-only side effect run after a transaction's outermost commit succeeds. Typical hooks kick a saga dispatcher, invalidate a cache, flush a metric, or broadcast over a websocket — work that must observe a durable commit but must not be part of the transaction itself.

A hook MUST NOT perform any persistent side effect: it must not touch the (now-committed) *pgx.Tx / *sql.Tx, nor write to an outbox.Writer. The tx is deliberately stripped from the ctx passed to a hook (see RunAfterCommitHooks), and archtest AFTERCOMMIT-HOOK-PURE-TRANSIENT-01 statically rejects hook bodies that reach for the tx or outbox writer.

Failure semantics are best-effort: a panicking hook is recovered and logged at Warn, never propagated to the committing goroutine. This is a deliberate deviation from spring-tx, whose afterCommit propagates exceptions — the commit is already durable, so a transient-hook failure must not surface as a request error or trigger a rollback. See docs/architecture/202605230300-adr-aftercommit-hook-narrow-scope.md.

Hooks run without a deadline (context.WithoutCancel); a hook doing network I/O or heavy work MUST install its own timeout (context.WithTimeout) so it cannot block the committing goroutine — and thus RunInTx's return — indefinitely.

Example (saga dispatcher kick — the canonical use):

persistence.RegisterAfterCommit(ctx, func(ctx context.Context) {
    dispatcher.Kick(ctx) // ctx still carries trace/request-id; the tx is stripped
})

Spring-tx analog: TransactionSynchronization.afterCommit(); registration is persistence.RegisterAfterCommit (≙ TransactionSynchronizationManager.registerSynchronization).

type CellTxManager

type CellTxManager interface {
	TxRunner
	// ApplyTenantScope sets the RLS app.tenant_id GUC on the AMBIENT transaction
	// mid-flight (must be called inside RunInTx). It exists for the one path that
	// cannot know the tenant at tx-start because it must read a non-RLS row inside
	// the tx to learn it — sessionrefresh (#1617 PR-3b): Peek + sessions.tenant_id
	// reads happen inside the cross-store tx (REFRESH-CROSS-STORE-TX-01), then the
	// derived tenant scopes the subsequent users/roles reads via this call. Backends
	// without row-level security (mem / demo TxRunner) return nil — there is no GUC
	// to scope. The real injection lives in adapters/postgres.TxManager.ApplyTenantScope.
	//
	// canonicalTenantID is the canonical lowercase-UUID tenant string (the typed
	// pkg/tenant.TenantID is enforced upstream at the corecells/accesscore/internal/scopedtx
	// funnel; this kernel boundary takes a string so kernel/persistence stays free of
	// pkg/tenant's transitive deps — google/uuid — which would otherwise ripple into
	// every satellite module's go.sum). adapters/postgres re-validates it via
	// tenant.ParseTenantID before writing the GUC (defense in depth).
	ApplyTenantScope(ctx context.Context, canonicalTenantID string) error
	// contains filtered or unexported methods
}

CellTxManager is the only TxRunner-shaped type that cells/<x>/cell.go public With* Options may accept. The unexported sealedCellTxManager() method makes CellTxManager unimplementable outside this package — kernel/persistence is the sole entry point via WrapForCell, which composition roots must call.

AI-robust 评级:

  • 字段/赋值层:Hard(sealed marker,外部不可表达 internalCellTxManager 字面量)
  • 公开 With* Option 签名层:Medium(CELL-RAW-INFRA-PUBLIC-OPTION-PARAM-01 archtest type-aware 守)

双重防线,参见 ADR 202605101900-adr-cell-raw-infra-sealed-marker §D2。

CellTxManager embeds TxRunner so cells can pass the wrapped value directly to internal service constructors — service.NewXxx accepts TxRunner and the embedded interface satisfies it transparently, so service signatures do not change.

ref: docs/architecture/202605101900-adr-cell-raw-infra-sealed-marker.md §D1

func WrapForCell

func WrapForCell(tr TxRunner) CellTxManager

WrapForCell is the sole authorized path for handing a TxRunner to a cell's With* Option. Returns nil when tr is bare-nil OR a typed-nil interface (e.g. `var p *postgres.TxManager`) so caller-side typed-nil detection in builder options keeps working — without IsNilInterface the wrapper would emit a non-nil sealed value hiding the inner nil pointer, silently bypassing CheckNotNoop / Init() fail-fast guards.

Allowed callers (enforced by archtest CELL-RAW-INFRA-WRAPPER-LOCATION-01):

  • cmd/* composition roots
  • examples/<demo>/main.go, examples/<demo>/app.go, and examples/<demo>/run.go composition roots (run.go is the hand-written half of the K#10 main+run split)
  • *_test.go in any layer
  • kernel/persistence/cell_marker.go (this file)

Adding a new caller requires updating both the archtest allowlist and reviewing whether the new path is truly composition-root.

type TxRunner

type TxRunner interface {
	RunInTx(ctx context.Context, fn func(ctx context.Context) error) error
}

TxRunner executes fn within a database transaction. The transaction is embedded in the context so that participants (notably outbox.Writer) can join it via TxFromContext(ctx).

outbox.Writer.Write MUST be invoked from within RunInTx — calling Write outside this scope returns an error rather than silently committing the outbox row without transactional guarantees. RunInTx implementations commit on nil return and roll back on any non-nil return (including panics, which are converted to errors). Implementations live in adapters (e.g., adapters/postgres.TxManager).

After-commit hooks are part of the RunInTx contract. Every implementation:

  • installs an after-commit registry into the ctx passed to fn, so fn may schedule transient post-commit side effects via RegisterAfterCommit;
  • runs those hooks synchronously, exactly once, after the OUTERMOST RunInTx commits durably (nested RunInTx / savepoints do not drain); and
  • discards the hooks a fn registered when that fn returns an error or panics (its unit of work did not commit), even if an enclosing scope swallows the error and commits.

Conformance: persistencetest.RunAfterCommitConformance (+ ...Nested) exercises these guarantees against every TxRunner implementation.

Directories

Path Synopsis
Package persistencetest provides conformance suites for persistence.TxRunner implementations.
Package persistencetest provides conformance suites for persistence.TxRunner implementations.

Jump to

Keyboard shortcuts

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