Documentation
¶
Overview ¶
Package assembly provides the CoreAssembly that orchestrates Cell lifecycle (register, start, stop, health).
Design ref: uber-go/fx app.go, lifecycle.go
- FIFO Start / LIFO Stop
- Start 失败自动 rollback 已启动的 Cell
- Stop 尽力而为,合并错误
- 状态机防止重入
ref: go-kratos/kratos app.go — BeforeStart/AfterStart/BeforeStop/AfterStop ref: uber-go/fx lifecycle.go — FIFO Start, LIFO Stop, rollback on failure
canonical.go provides a deterministic, stable binary encoding for arbitrary Go values used to derive fingerprints from struct metadata.
Design constraints:
- struct fields are visited in sorted field-name order (not declaration order) so that field reordering in source does not change the encoding
- nil pointer and zero value are distinguishable
- fields tagged with fingerprint:"-" are skipped (documents, not structure)
- maps are visited in sorted key order
- slices include a length prefix so [] and nil are both distinguishable from single-element slices
- encoding/json is NOT used: its map-key/field ordering is not guaranteed to be stable across all Go versions and does not support sorted struct fields
Package assembly is the runtime that mounts and lifecycles a set of Cells declared in assembly.yaml. CoreAssembly orchestrates Cell lifecycle (register, init, start, stop, health): Cells are started in registration order (FIFO) and stopped in reverse order (LIFO).
Hook timing: assembly enforces hook deadlines via clock.Clock. Test code uses clock.FakeClock; production wires adapters/clock.SystemClock.
ref: uber-go/fx App.Start / App.Stop — phased lifecycle with rollback on failure during boot. ref: kubernetes-sigs/controller-runtime manager.Start — supervisor that owns goroutine ownership for registered Runnable controllers.
generator.go produces derived files (main.go entrypoint and boundary.yaml) for an assembly based on project metadata.
Design ref: go-zero goctl genFile abstraction
- embed templates via gentpl.FS
- genFile: template load -> Parse -> Execute -> bytes
- strong-typed context structs for each template
- boundary.yaml is always overwritten (generated artifact)
Index ¶
- Constants
- type AssemblyScaffoldSpec
- type Config
- type CoreAssembly
- func (a *CoreAssembly) Cell(id string) cell.Cell
- func (a *CoreAssembly) CellIDs() []string
- func (a *CoreAssembly) Clock() clock.Clock
- func (a *CoreAssembly) FlushHookEvents(timeout time.Duration) bool
- func (a *CoreAssembly) Health() map[string]cell.HealthStatus
- func (a *CoreAssembly) ID() string
- func (a *CoreAssembly) Register(c cell.Cell) error
- func (a *CoreAssembly) ReloadTimeout() time.Duration
- func (a *CoreAssembly) Shutdown()
- func (a *CoreAssembly) Snapshots() map[string]cell.RegistrySnapshot
- func (a *CoreAssembly) Start(ctx context.Context) error
- func (a *CoreAssembly) StartWithConfig(ctx context.Context, cfgMap map[string]any) error
- func (a *CoreAssembly) Stop(ctx context.Context) error
- type Generator
- func (g *Generator) GenerateBoundary(assemblyID string) ([]byte, error)
- func (g *Generator) GenerateEntrypoint(assemblyID string) ([]byte, error)
- func (g *Generator) GenerateModulesGen(assemblyID string) ([]byte, error)
- func (g *Generator) PlanAssemblyScaffold(spec AssemblyScaffoldSpec) ([]pathsafe.PlannedFile, error)
Constants ¶
const ( DefaultHookObserverQueueSize = 128 DefaultHookObserverSinkTimeout = 5 * time.Second DefaultHookObserverDrainTimeout = 5 * time.Second )
Default queue / timeout settings for hookDispatcher. Tuned for GoCell's event volume: a single assembly emits on the order of (cells * 4 phases * 2 start/stop) ≤ 100 events over its lifetime, so a 128-slot queue leaves margin for bursts during shutdown without growing the memory footprint.
const ( DropReasonQueueFull = "queue_full" DropReasonSinkTimeout = "sink_timeout" DropReasonObserverPanic = "observer_panic" )
Drop reason label values emitted on hook_observer_dropped_total (bare name; the Provider's Namespace field adds the "gocell_" prefix to produce the final Prometheus fqName "gocell_hook_observer_dropped_total"). Exported so downstream tests (and operators wiring Grafana variables) can refer to the reason string by symbolic name instead of duplicating the literal — a rename of the literal flips every call site at once.
const DefaultHookTimeout = 30 * time.Second
DefaultHookTimeout is the per-hook deadline applied when Config.HookTimeout is zero. 30s accommodates slow-starting cells (DB pool warm-up, readiness probes) while still bounding a stuck hook.
ref: uber-go/fx app.go@master:L54 DefaultTimeout=15s (assembly-wide). ref: kubernetes-sigs/controller-runtime pkg/manager/internal.go@main:L394-L399 GracefulShutdownTimeout default (per-phase, user-configured). GoCell picks 30s as midpoint — cell lifecycle is closer to k8s scope.
const DefaultReloadTimeout = DefaultHookTimeout
DefaultReloadTimeout is the per-invocation deadline applied to each OnConfigReload callback when Config.ReloadTimeout is zero. Deliberately the same value as DefaultHookTimeout so reload callbacks share the same timeout budget as lifecycle hooks — but named independently to avoid semantic confusion between lifecycle hooks and config-reload callbacks.
ref: etcd clientv3 Watch ctx propagation — watchers propagate the caller's ctx. ref: k8s SharedInformer ctx propagation — informer callbacks carry caller ctx.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type AssemblyScaffoldSpec ¶
type AssemblyScaffoldSpec struct {
// ID is the assembly identifier (e.g. "myassembly"). Required. Typed so the
// (^[a-z][a-z0-9]+$) constraint is established at construction time via
// scaffoldid.Parse — callers cannot supply an unvalidated raw string
// (SCAFFOLD-INPUT-CONTRACT-TYPED-ID-01).
ID scaffoldid.ScaffoldID
// Cells lists the cell IDs that compose this assembly, in startup order.
// Each entry must reference an existing cells/{cellID}/cell.yaml. Typed
// for the same reason as ID.
Cells []scaffoldid.ScaffoldID
// OwnerTeam, OwnerRole identify the maintainers of this assembly.
// Both required; written verbatim to assembly.yaml owner.
OwnerTeam string
OwnerRole string
// Deploy selects the target deployment template; legal values are
// "k8s" (default), "compose", "binary". Per ADR 202605061800 the
// k8s value is omitted from assembly.yaml — the parser inherits the
// default. compose/binary are written verbatim.
Deploy string
// SkipGenerate when true causes PlanAssemblyScaffold to return only the
// 3 skeleton PlannedFiles (assembly.yaml + cmd/{id}/run.go + cmd/{id}/app.go),
// skipping in-memory codegen for the 3 K#10 derived files.
SkipGenerate bool
}
AssemblyScaffoldSpec drives Generator.PlanAssemblyScaffold (K#09 SCAFFOLD-ONE-CMD). The Generator already owns the assembly.yaml / run.go / app.go renderers and downstream codegen (modules_gen.go, main.go, boundary.yaml) so a single PlanAssemblyScaffold method composes the full assembly bundle without a new subpackage.
type Config ¶
type Config struct {
ID string
DurabilityMode outbox.DurabilityMode // Required: Demo or Durable (zero value rejected by CheckNotNoop)
// HookTimeout bounds every BeforeStart/AfterStart/BeforeStop/AfterStop
// hook invocation. Zero uses DefaultHookTimeout. Set to a negative value
// to disable per-hook timeouts entirely (hook inherits parent ctx only).
//
// Semantics: soft-cancel only — when the deadline fires the assembly
// records OutcomeTimeout and returns context.DeadlineExceeded to the
// caller, but the hook goroutine continues until it observes ctx (GoCell
// hooks are synchronous and internal, so a well-behaved hook respects
// ctx; a misbehaving hook is detected via the timeout event).
HookTimeout time.Duration
// HookObserver receives one HookEvent per invocation of every lifecycle
// hook. Nil uses cell.NopHookObserver{} (allocation-free no-op).
// Implementations must not block the caller.
HookObserver cell.LifecycleHookObserver
// HookObserverQueueSize bounds the async dispatcher's pending-event
// buffer. Zero uses DefaultHookObserverQueueSize (128). Negative is
// clamped to default.
HookObserverQueueSize int
// HookObserverSinkTimeout bounds a single OnHookEvent invocation. When
// exceeded, the event is counted as dropped with reason="sink_timeout"
// and the dispatcher moves on (the observer goroutine is abandoned).
// Zero uses DefaultHookObserverSinkTimeout (5s).
HookObserverSinkTimeout time.Duration
// HookObserverDrainTimeout bounds Stop()'s shared hook-dispatcher
// shutdown budget after event intake is closed. The same budget covers
// both queue drain (worker processing already-emitted events) and sink
// drain (observer goroutines that exceeded HookObserverSinkTimeout but
// later exit). Zero uses DefaultHookObserverDrainTimeout (5s). Stop(ctx)
// also returns early when ctx is canceled; Shutdown uses
// context.Background() and is bounded only by this timeout. After drain
// completion, timeout, or cancellation, any further emit() calls are
// counted as queue_full.
HookObserverDrainTimeout time.Duration
// MetricsProvider receives the dispatcher's internal drop / queue-depth
// metrics. Nil uses metrics.NopProvider, preserving the prior
// zero-dependency default. Wire a real provider to make dropped events
// visible in dashboards.
MetricsProvider metrics.Provider
// ReloadTimeout bounds each OnConfigReload callback invocation. Zero uses
// DefaultReloadTimeout. Negative disables the per-invocation timeout
// (callbacks inherit the parent ctx's deadline only).
//
// Semantics mirror HookTimeout: soft-cancel — the ctx passed to the
// callback is canceled when the deadline fires, but the callback goroutine
// continues until it observes ctx. A well-behaved callback should return
// promptly when ctx.Done() closes.
//
// ref: etcd clientv3 Watch — watchers propagate the caller's ctx.
// ref: k8s SharedInformer — informer callbacks carry a bounded ctx.
ReloadTimeout time.Duration
}
Config holds assembly-level configuration.
type CoreAssembly ¶
type CoreAssembly struct {
// contains filtered or unexported fields
}
CoreAssembly is the default Assembly implementation. It manages a set of Cells, starting them in registration order and stopping them in reverse.
func New ¶
func New(clk clock.Clock, cfg Config) *CoreAssembly
New creates a CoreAssembly with the given configuration.
clk is required: assembly.New panics when clk is nil OR a typed-nil interface (e.g. (*realClock)(nil) wrapped in a clock.Clock value). The single root clock is constructed once at the composition root via clock.Real() and threaded through every assembly + cell; missing wiring fails fast at construction time rather than masquerading as wall-clock-driven flakiness in tests. Tests inject clockmock.New(...).
If cfg.HookObserver is nil, cell.NopHookObserver{} is substituted so the hook call sites can emit unconditionally. If cfg.HookTimeout is zero, DefaultHookTimeout is applied. Negative value disables per-hook timeout entirely.
func (*CoreAssembly) Cell ¶
func (a *CoreAssembly) Cell(id string) cell.Cell
Cell returns the registered Cell with the given ID, or nil if not found.
func (*CoreAssembly) CellIDs ¶
func (a *CoreAssembly) CellIDs() []string
CellIDs returns the IDs of all registered cells in registration order.
func (*CoreAssembly) Clock ¶
func (a *CoreAssembly) Clock() clock.Clock
Clock returns the single root clock.Clock used by the assembly for lifecycle hook timing. Exposed so that Bootstrap can fail-fast when a caller passes both WithAssembly and a positional clock with non-identical clock instances.
Always non-nil — assembly.New rejects nil and typed-nil at construction.
func (*CoreAssembly) FlushHookEvents ¶
func (a *CoreAssembly) FlushHookEvents(timeout time.Duration) bool
FlushHookEvents waits for the async hook dispatcher to process every event emitted so far, bounded by timeout. Returns true if drain completed, false if the timeout fired first. Intended for tests that need to observe deterministic observer state after a Start or Stop that may have failed (failed Start does not drain; only a successful Stop implicitly drains via HookObserverDrainTimeout).
Zero timeout is interpreted as one second. Safe to call concurrently.
func (*CoreAssembly) Health ¶
func (a *CoreAssembly) Health() map[string]cell.HealthStatus
Health returns the HealthStatus of every registered Cell, keyed by Cell ID.
func (*CoreAssembly) ID ¶
func (a *CoreAssembly) ID() string
ID returns the assembly's identifier as set in Config.ID.
func (*CoreAssembly) Register ¶
func (a *CoreAssembly) Register(c cell.Cell) error
Register adds a Cell to the assembly. It returns an error if the Cell ID is empty, already registered, or the assembly has already been started.
func (*CoreAssembly) ReloadTimeout ¶
func (a *CoreAssembly) ReloadTimeout() time.Duration
ReloadTimeout returns the per-invocation deadline applied to each OnConfigReload callback as configured by Config.ReloadTimeout. Always non-zero after construction (zero is normalized to DefaultReloadTimeout by New). Negative values disable the per-invocation timeout.
func (*CoreAssembly) Shutdown ¶
func (a *CoreAssembly) Shutdown()
Shutdown terminates background workers owned by the assembly without running Cell lifecycle stop. Intended for tests and for callers that constructed an assembly via New() but never invoked Start/Stop — it ensures the hook dispatcher goroutine does not linger.
Shutdown is safe to call multiple times. Stop drains the dispatcher for normal lifecycle shutdown; Shutdown is the explicit teardown path for assemblies that never reached a successful Start/Stop cycle.
func (*CoreAssembly) Snapshots ¶
func (a *CoreAssembly) Snapshots() map[string]cell.RegistrySnapshot
Snapshots returns the per-cell registry declarations recorded during Init. Returns nil when assembly is not in running state (starting, Init failure, Start failure, stopping, or after Stop).
The returned map is a copy keyed by cell ID — mutations do not affect the assembly's internal state. The RegistrySnapshot values themselves are not deep-copied; callers must not mutate the slice/map fields inside each snapshot.
Invariant: Snapshots() returns a non-empty map only when the assembly is in stateStarted. failStart, Stop, and rollbackCells all clear the map so that callers never observe stale snapshots from a prior run.
ref: uber-go/fx App.Done lifecycle state machine — state gates access to component metadata.
func (*CoreAssembly) Start ¶
func (a *CoreAssembly) Start(ctx context.Context) error
Start initializes and starts every registered Cell in registration order. Dependencies are built from all registered Cells.
ref: uber-go/fx app.go — Start 出错后自动 rollback 已启动的 Cell(LIFO Stop)。
func (*CoreAssembly) StartWithConfig ¶
StartWithConfig is like Start but injects the given config map into Dependencies.Config before initializing cells.
func (*CoreAssembly) Stop ¶
func (a *CoreAssembly) Stop(ctx context.Context) error
Stop stops every registered Cell in reverse registration order. If multiple Cells fail, Stop continues and returns all errors joined via errors.Join. Stop is only allowed from the Started state; calling Stop in any other state is a no-op.
For each cell, the sequence is: BeforeStop → Stop → AfterStop. All three phases are best-effort — errors are accumulated, never abort.
ref: uber-go/fx app.go — Stop 尽力而为,不因某个 hook 失败而中止。 ref: go-kratos/kratos app.go — BeforeStop/AfterStop hooks around server.Stop
type Generator ¶
type Generator struct {
// contains filtered or unexported fields
}
Generator produces derived files for an assembly.
func NewGenerator ¶
func NewGenerator(project *metadata.ProjectMeta, module, projectRoot string) *Generator
NewGenerator creates a Generator from project metadata, a Go module path, and the absolute filesystem path to the project root (the directory containing go.mod). projectRoot is required when contracts reference schema files.
func (*Generator) GenerateBoundary ¶
GenerateBoundary generates the boundary.yaml content for an assembly.
Boundary contains:
- exportedContracts: contracts whose provider cell is in this assembly but has consumers outside this assembly (or consumers list is empty)
- importedContracts: contracts whose consumer cell is in this assembly but provider is outside
- smokeTargets: all cell.verify.smoke targets for cells in assembly
func (*Generator) GenerateEntrypoint ¶
GenerateEntrypoint generates the main.go content for an assembly.
func (*Generator) GenerateModulesGen ¶
GenerateModulesGen generates the modules_gen.go content for an assembly's CellModule factory list. cells appear in the order declared in assembly.yaml.cells (not sorted), preserving runtime startup order.
When assembly.yaml declares build.compositionAPI: true, the output uses the runtime/composition.CellModule form (cellmodules/{cellID}.Module() calls) — see modules_gen_composition.go.tpl. Otherwise, the legacy local-type form is emitted (modules_gen.go.tpl), which is used by examples/.
Each cell must have GoStructName set (cell.yaml schema extension consumed by codegen). The legacy factory references {GoStructName}Module by convention; the *Module struct is hand-written in cmd/{assemblyID}/.
generatedCapabilities() is the sorted, de-duplicated union of the assembly cells' cell.yaml `requires` (Design Y, #855). Changing a cell's `requires` therefore requires re-running `gocell generate assembly` to refresh modules_gen.go; `--verify` catches stale output in CI.
func (*Generator) PlanAssemblyScaffold ¶
func (g *Generator) PlanAssemblyScaffold(spec AssemblyScaffoldSpec) ([]pathsafe.PlannedFile, error)
PlanAssemblyScaffold builds the complete []pathsafe.PlannedFile for a new assembly: 3 skeleton files (assembly.yaml + cmd/{id}/run.go + cmd/{id}/app.go) plus 3 K#10 derived files (cmd/{id}/modules_gen.go + cmd/{id}/main.go + assemblies/{id}/generated/boundary.yaml) when SkipGenerate=false.
PURE RENDER: no filesystem mutation, no re-parse. Caller (typically cmd/ CLI) feeds the returned plan into pathsafe.WritePlannedFiles, which is the single funnel for both dry-run and live writes (SCAFFOLD-WRITE-FUNNEL-01).
PlanAssemblyScaffold is safe for concurrent calls on the same Generator: each call operates on a shadow ProjectMeta and never mutates g.project.
The Generator must have been constructed with a non-empty projectRoot. Each cell in spec.Cells must exist in g.project.Cells.
K#10 derived files are produced by constructing a transient Generator that holds a shallow-copied ProjectMeta with a synthesized AssemblyMeta injected into its Assemblies map. The original g.project.Assemblies is never touched.
Returns the plan or an error; the plan is empty on error.