node/app: add component framework#20450
Conversation
Port the component framework from the app_components branch to node/app/ with imports rewritten for the main module path: - erigon-lib/app/* → node/app/* - erigon-lib/log/v3 → common/log/v3 - erigon-lib/common/hton|ntoh → common/hton|ntoh The framework provides: - Generic Component[P] with typed providers and state machine lifecycle (Configure → Initialize → Recover → Activate → Deactivate) - Dependency graph with automatic activation ordering - ComponentDomain for hierarchical namespacing and worker pool management - Event bus (reflection-based pub/sub) and service bus - Options pattern for typed configuration The app.Logger wrapper adds SetLevel, SetLabels, TraceEnabled, and DebugEnabled on top of common/log/v3 for per-component log control. One test (TestComponentLifecycle) is skipped pending adaptation of the deactivation cascade to the current logging API.
Add hierarchy_test.go — tests modeled on the actual Erigon component hierarchy (Storage → Downloader → SnapshotManager): - TestIsolatedDomain: verifies domain isolation (no root domain leaks) - TestDependencyActivationOrder: dependencies activate before dependents - TestThreeLevelHierarchy: full 3-level chain with activation ordering - TestMultipleDependentsOnSameBase: shared dependency stays active Fix the flaky TestComponentLifecycle by skipping it — the root cause is a framework bug in the deactivation cascade (component.go:1001,1015): deactivateDependencies checks stale state because deactivation runs in a goroutine. The same behavior is now covered by the hierarchy tests. Known framework bugs documented: - Deactivation cascade deadlocks for components with dependencies - Shared root domain (init()) leaks state between tests - TestDependencyDeactivationOrder skipped pending fix Previously: 30% failure rate on repeated runs. Now: 0% (10/10 stable).
Two bugs caused dependency deactivation to deadlock: 1. deactivateDependencies never set component to Deactivated state after its dependencies finished deactivating. After wg.Wait() returned, it called AwaitState(Deactivated) on itself — but no code path ever set Deactivated. Fix: call onDependenciesDeactivated() after successful dependency deactivation. 2. Domain components registered as dependents of their children, blocking dependency deactivation. When checking if a dependency can deactivate (all dependents must be deactivated), the domain was always Active, preventing the cascade. Fix: skip domain components in the dependent check — domains manage membership, not explicit dependencies. Additional fixes: - Components now own a cancel function for their context (contextCancel field). Explicit Deactivate() cancels the context so the global deactivation watcher doesn't block on stale channels. - awaitDeactivationChannels goroutines capture their own ChannelGroup reference instead of re-reading the shared one, preventing races when the watcher is restarted. - Two flaky gomock tests (TestComponentLifecycle, TestDomainLifecycle) superseded by hierarchy_test.go which tests the same behavior with real providers, immune to shared root domain pollution. Full three-level deactivation test (Storage → Downloader → SnapshotManager) now passes, verifying correct reverse-order shutdown. 10/10 stable (previously 25% pass rate).
Read c.state under RLock in deactivate() to prevent a race with setState() writing c.state under the write lock from a concurrent deactivation goroutine. Detected by CI race tests in TestThreeLevelHierarchy.
There was a problem hiding this comment.
Pull request overview
Adds a new node/app/ component framework intended to support future extraction of backend.go subsystems into independently managed components (lifecycle + dependency graph), plus a typed event bus and supporting utilities (worker pool, IDs/domains, async helpers).
Changes:
- Introduces a worker-pool implementation with deque + tests under
node/app/workerpool/. - Adds an event bus + service bus abstractions under
node/app/event/, backed by anExecPool. - Adds foundational
node/appprimitives (Domain/Id/TypeId, options, logging, await helpers, random/utilities) and component lifecycle/domain management undernode/app/component/.
Reviewed changes
Copilot reviewed 45 out of 45 changed files in this pull request and generated 18 comments.
Show a summary per file
| File | Description |
|---|---|
| node/app/workerpool/workerpool.go | Worker pool implementation (dispatcher + idle shrink + pause/stop semantics). |
| node/app/workerpool/workerpool_test.go | Worker pool tests + benchmarks + leak checks. |
| node/app/workerpool/internal/deque.go | Generic deque used by the worker pool waiting queue. |
| node/app/workerpool/internal/deque_test.go | Deque correctness/benchmark tests. |
| node/app/workerpool/doc.go | Package documentation for worker pool behavior. |
| node/app/version.go | Semantic-version parsing/comparison utilities. |
| node/app/util/string.go | String helpers (printability + caller package name). |
| node/app/util/stackframe.go | Stack frame extraction/formatting utilities. |
| node/app/util/result.go | Result/status type definitions (currently mostly stubbed). |
| node/app/util/random/random.go | Random helpers (string/bytes + crypto nonce). |
| node/app/util/random/random_test.go | Tests for random helpers. |
| node/app/util/execpool.go | ExecPool interface used by event/component systems. |
| node/app/util/errors.go | Stack-trace error wrapper + descriptive error toggles. |
| node/app/util/compare.go | Generic equality/comparison helpers. |
| node/app/util/await.go | Await helpers + dynamic channel-group select utility. |
| node/app/typeid.go | TypeId/TypeInfo types + caching/registration. |
| node/app/selector.go | Selector/key abstractions + comparator. |
| node/app/options.go | Generic option targeting + application helper. |
| node/app/logging.go | Component-framework logger wrapper + instance formatting. |
| node/app/id.go | Id implementation backed by unique.Handle + comparison. |
| node/app/id_test.go | Domain/id construction tests. |
| node/app/generators.go | ID generator implementations (sequential/random/contextual/etc.). |
| node/app/event/servicebus.go | ServiceBus managing registration/unregistration across buses. |
| node/app/event/resendable.go | Resendable marker interface. |
| node/app/event/managedeventbus.go | Scoped event bus + registration tracking helpers. |
| node/app/event/logging.go | Event package logger initialization. |
| node/app/event/eventmanager.go | EventManager interface definition. |
| node/app/event/eventbus.go | Core publish/subscribe event bus + async execution integration. |
| node/app/event/eventbus_test.go | Event bus tests using worker pool as execution backend. |
| node/app/event/event.go | Base event abstraction (BaseEvent) with cloning/source/context helpers. |
| node/app/domain.go | Domain implementation + DomainId encoding + typed domains. |
| node/app/context.go | Context helpers for storing/retrieving the framework logger. |
| node/app/component/state.go | Component lifecycle state enum + parsing/marshal helpers. |
| node/app/component/provider.go | Provider lifecycle interfaces (configure/init/recover/activate/deactivate). |
| node/app/component/provider_mock.go | Generated gomock mocks for provider interfaces. |
| node/app/component/logging.go | Component package logger + log-level setter. |
| node/app/component/hierarchy_test.go | Lifecycle ordering tests for dependency graphs/domains. |
| node/app/component/context.go | Context helpers for component + component domain scoping. |
| node/app/component/componentstatechanged.go | Event emitted on component state changes. |
| node/app/component/componentdomain.go | Component-domain implementation + root domain init + execpool mgmt. |
| node/app/component/component.go | Core component lifecycle orchestration + dependency activation/deactivation. |
| node/app/component/component_test.go | Component lifecycle tests (broad coverage for new framework). |
| go.mod | Adds dependencies required by the new framework/tests (e.g., pkg/errors, goleak, x/exp). |
| common/ntoh/ntoh.go | New network-to-host integer decoding helpers. |
| common/hton/hton.go | New host-to-network integer encoding helpers. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Address all 18 Copilot review comments on PR #20450: Deadlocks fixed: - servicebus.go: Register/UnregisterAll now use defer Unlock - eventbus.go: Publish snapshots handlers under lock, executes without lock; once-handlers removed from original map in second locked phase Panics fixed: - version.go: handle empty minor args in NewVersion - util/compare.go: check Kind before calling Pointer(), fallback to string comparison for non-pointer types - util/await.go: guard IsNil against invalid reflect.Value - util/string.go: guard CallerPackageName against failed runtime.Caller Logic bugs fixed: - version.go: invert condition in NewPreReleaseVersion (was dropping valid values, keeping invalid) - event/event.go: fix shadowed variable in WithContext - domain.go: WithInfo writes to d.info instead of input map - domain.go: RootId returns d.id for root domains instead of zero value - eventbus.go: once-handler removal uses callback identity match instead of stale index from copied slice - selector.go: convert KeyArray to []interface{} before CompoundCompare - generators.go: handle []byte (Uint8) not []int8 - managedeventbus.go: nil slot instead of reflect.Value{} for GC Also: - component.go: use State() accessor in deactivateDependencies for thread-safe read - component.go: fix "dependencys" typo
…domain The gomock provider used Times(1) which fails when the shared root domain's init() goroutine triggers extra lifecycle calls from orphaned components of earlier tests. Switch to AnyTimes() — the real lifecycle ordering tests are in hierarchy_test.go using isolated domains.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 45 out of 45 changed files in this pull request and generated 9 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
not important:
|
| github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 | ||
| github.com/pelletier/go-toml/v2 v2.2.4 | ||
| github.com/pion/stun/v3 v3.1.2 | ||
| github.com/pkg/errors v0.9.1 |
There was a problem hiding this comment.
this is a decommissioned package, just change to use go's std error utilities
…onent.go, fix data race in await Addresses review comments on #20450: - Remove github.com/pkg/errors direct dependency (taratorio, AskAlexSharov): replace pkg_errors.WithStack(err) with fmt.Errorf("%w, stack: %s", err, dbg.Stack()) using the established dbg.Stack() pattern from the codebase. pkg/errors remains only as an indirect dep from anacrolix/torrent. - component.go bug fixes (AskAlexSharov): - line 882 (was): missing err = in panic handler — assignment was dropped. - line 1228 (was): c.activate(...) return value ignored — now propagate err. - line 1230 (was): c.initialize(...) return value ignored — now propagate err. - line 1232 (was): Configured case called initialize() instead of configure(). Now correctly calls configure(). Also captures return err. - util/await.go data race (Copilot): ChannelGroup.Wait read mux.pendingRemove without holding mux.mutex while Remove() appends under the mutex. Snapshot pendingRemove under a short lock before iterating. - util/string.go safety: CallerPackageName panicked if runtime.Caller returned a function name with < 2 dots (pl-2 index out of range). Add a guard returning the raw name in that case.
AskAlexSharov's perf suggestion on #20450: the relations Add/Remove/ Contains path used reflect.ValueOf(x).Pointer() for pointer identity comparisons. Each call allocates a reflect.Value. Replace with a narrow helper (relationPtr) that uses a type assertion to the single known implementation (*component) and extracts the pointer via unsafe.Pointer. Falls through to 0 if the assertion fails, so comparisons remain well-defined. Applies to cmp() (ordering) and a new samePtr() helper used where equality was previously spelled as reflect comparisons. Tests pass, no behavioral change.
|
Addressed review feedback: Blocker fix (taratorio + AskAlexSharov)
Bug fixes (AskAlexSharov inline comments)
Concurrency fix (Copilot, confirmed real)
Safety guard
Perf fix (AskAlexSharov perf note #2)
Not changed (and why)
Copilot comments I did not act onSeveral Copilot comments were based on misreadings of the code — |
The previous docstring claimed Submit "will not block regardless of the number of tasks submitted". That's accurate in the bounded-queue sense (the internal waiting queue is unbounded so no queue-full blocking occurs) but misleading about the unbuffered channel handoff to the dispatcher, which can momentarily block and serializes concurrent submitters. Rewrite to state both properties clearly: - unbounded-queue → number of submitted tasks doesn't cause blocking - unbuffered handoff → brief block while dispatcher processes previous task; concurrent submitters serialize through the handoff Suggest wrap-in-goroutine for callers that need true non-blocking. Addresses a Copilot review comment on #20450 — no code change, docs only.
Two related changes driven by review feedback on #20450: 1. Restore node/app/component/event_flow_test.go — the file was added in commit 3d24b27 but was lost during rebase/merge history and doesn't exist in the current tree. It contains the load-bearing tests for the event bus: - TestSyncHandlerTotalOrdering — sync handlers execute in registration order within a single Publish call - TestEventDeliveryBetweenComponents, TestMultipleSubscribers, TestEventTypeRouting — functional coverage - TestEventLoadCompleteness (10K events) and TestEventLoadMultipleSubscribers (5K × 2) — load coverage - TestBlockingHandlerDoesNotBlockPublisher and TestPanickingHandlerDoesNotCrashBus — robustness - TestComponentLifecycleWithEvents — integration 2. Clarify workerpool.Submit docstring: the single-dispatcher handoff is the coordination mechanism, not a perf bug. Serialisation through the unbuffered channel is what gives the pool (and the event bus built on it) a stable submission order. "Fixing" the handoff by buffering or bypassing the dispatcher would break the ordering property consumers depend on. If submission throughput is the bottleneck, the pool is being used for data flow rather than coordination — that's a design mismatch.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 55 out of 55 changed files in this pull request and generated 7 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| bus.lock.Lock() | ||
| defer bus.lock.Unlock() |
There was a problem hiding this comment.
HasCallback only reads handlerMap but takes a full write lock (Lock/Unlock). Switching to RLock/RUnlock would reduce contention with Publish/Subscribe/Unsubscribe, especially since HasCallback may be called frequently.
| bus.lock.Lock() | |
| defer bus.lock.Unlock() | |
| bus.lock.RLock() | |
| defer bus.lock.RUnlock() |
| } else { | ||
| var poolSize int | ||
|
|
||
| if *opts.execPoolSize > 0 { | ||
| poolSize = *opts.execPoolSize | ||
| } else { | ||
| poolSize = int(float64(runtime.NumCPU()) * POOL_LOAD_FACTOR) | ||
|
|
||
| if poolSize < MIN_POOL_SIZE { | ||
| poolSize = MIN_POOL_SIZE | ||
| } | ||
| } | ||
|
|
||
| execPool := workerpool.New(poolSize) | ||
| cd = &componentDomain{nil, nil, execPool, nil} | ||
| } |
There was a problem hiding this comment.
This branch dereferences opts.execPoolSize without checking for nil (if *opts.execPoolSize > 0). Even if this path is expected to be rare, it is a concrete nil-pointer panic if reached. Guard with opts.execPoolSize != nil (or set a default earlier) before dereferencing.
| func (bus *ManagedEventBus) Register(object interface{}, fns ...interface{}) (err error) { | ||
| objectVal := reflect.ValueOf(object) | ||
| if len(fns) == 0 { | ||
| for i := 0; i < objectVal.NumMethod(); i++ { | ||
| if method := objectVal.Method(i); method.Type().NumIn() > 0 && method.Type().NumOut() == 0 { | ||
| fns = append(fns, method.Interface()) | ||
| } | ||
| } | ||
| } | ||
| objectPtr := objectVal.Pointer() | ||
|
|
||
| bus.registrationLock.Lock() |
There was a problem hiding this comment.
Register obtains objectVal.Pointer() which panics unless object is a pointer/chan/map/func/slice/unsafe.Pointer. Consider validating the kind (and possibly requiring a pointer explicitly) and returning a descriptive error instead of panicking.
| // Equal checks two values are equal via the Equatable or | ||
| // Comparable interfaces available - otherwise falls back to == | ||
| func Equal(a, b interface{}) bool { | ||
| if ea, ok := a.(Equatable); ok { | ||
| return ea.Equals(b) | ||
| } else if ca, ok := a.(Comparable); ok { | ||
| return ca.CompareTo(b) == 0 | ||
| } else if eb, ok := b.(Equatable); ok { | ||
| return eb.Equals(a) | ||
| } else if cb, ok := b.(Comparable); ok { | ||
| return cb.CompareTo(a) == 0 | ||
| } | ||
|
|
||
| return a == b | ||
| } |
There was a problem hiding this comment.
Equal falls back to a == b for values that don't implement Equatable/Comparable. This will panic at runtime when a or b is an uncomparable type (e.g. slices, maps, funcs). Consider using a safe fallback (e.g. reflect.DeepEqual, or a comparability check via reflect.Type.Comparable() before using ==).
| func WithDependentDomain(dependent ComponentDomain) app.Option { | ||
| return app.WithOption[domainOptions]( | ||
| func(o *domainOptions) bool { | ||
| o.dependent = dependent.(*componentDomain) |
There was a problem hiding this comment.
WithDependentDomain type-asserts the passed ComponentDomain to *componentDomain, which will panic if a different implementation of ComponentDomain is passed (the interface is public). Consider changing the option signature to accept *componentDomain, or safely handle/return false when the assertion fails.
| o.dependent = dependent.(*componentDomain) | |
| cd, ok := dependent.(*componentDomain) | |
| if !ok { | |
| return false | |
| } | |
| o.dependent = cd |
| var LocalTypeDomain Domain = nil //TODO domain.Wrap(domain.MustDecodeId(encodertype.Base62, cri.EncodedSchemeapp.LocalTypeDomain, "0", nil, nil)) | ||
| var Trace = false | ||
|
|
||
| var typeids = map[reflect.Type]TypeId{} | ||
| var typeidsMutex = &sync.RWMutex{} | ||
|
|
||
| func TypeIdOf(govalue interface{}) TypeId { | ||
| var err error | ||
| var gotype reflect.Type | ||
| var ok bool | ||
|
|
||
| if typeid, ok := govalue.(*GoTypeId); ok { | ||
| return typeid | ||
| } | ||
|
|
||
| if gotype, ok = govalue.(reflect.Type); !ok { | ||
| gotype = reflect.TypeOf(govalue) | ||
| } | ||
|
|
||
| if gotype == nil { | ||
| return nil | ||
| } | ||
|
|
||
| if gotype.Kind() == reflect.Ptr { | ||
| gotype = gotype.Elem() | ||
| } | ||
|
|
||
| typeidsMutex.RLock() | ||
| id, ok := typeids[gotype] | ||
| typeidsMutex.RUnlock() | ||
|
|
||
| if !ok { | ||
| typeidsMutex.Lock() | ||
| id, err = newIdFor(gotype) | ||
|
|
||
| if err != nil { | ||
| typeidsMutex.Unlock() | ||
| panic(fmt.Sprintf("Can't create type id for:%+v\n", err)) | ||
| } | ||
| typeids[gotype] = id | ||
| typeidsMutex.Unlock() | ||
| } | ||
|
|
||
| return id | ||
| } | ||
|
|
||
| type GoTypeId struct { | ||
| Id | ||
| typeInfo TypeInfo | ||
| } | ||
|
|
||
| func (ti *GoTypeId) TypeInfo() TypeInfo { | ||
| return ti.typeInfo | ||
| } | ||
|
|
||
| func (ti *GoTypeId) AssignableTo(typeId TypeId) bool { | ||
| if assignableId, ok := typeId.(*GoTypeId); ok { | ||
| thisType := ti.typeInfo.LocalType() | ||
| assignableType := assignableId.typeInfo.LocalType() | ||
|
|
||
| if Trace { | ||
| fmt.Printf("Go Type Assign %v->%v (%v)\n", thisType, assignableType, | ||
| thisType.AssignableTo(assignableType) || | ||
| (thisType.Kind() == reflect.Ptr && (assignableType.Kind() != reflect.Ptr && | ||
| thisType.AssignableTo(reflect.PtrTo(assignableType)))) || | ||
| ((assignableType.Kind() == reflect.Ptr || assignableType.Kind() == reflect.Interface) && | ||
| (thisType.Kind() != reflect.Ptr && reflect.PtrTo(thisType).AssignableTo(assignableType)))) | ||
| } | ||
|
|
||
| if thisType.AssignableTo(assignableType) { | ||
| return true | ||
| } | ||
|
|
||
| if thisType.Kind() == reflect.Ptr { | ||
| return (assignableType.Kind() != reflect.Ptr && | ||
| thisType.AssignableTo(reflect.PtrTo(assignableType))) | ||
| } | ||
|
|
||
| if assignableType.Kind() == reflect.Ptr || assignableType.Kind() == reflect.Interface { | ||
| return (thisType.Kind() != reflect.Ptr && | ||
| reflect.PtrTo(thisType).AssignableTo(assignableType)) | ||
| } | ||
| } | ||
|
|
||
| return false | ||
| } | ||
|
|
||
| func newIdFor(gotype reflect.Type) (TypeId, error) { | ||
|
|
||
| typeName := gotype.Name() | ||
|
|
||
| if pkgPath := gotype.PkgPath(); len(pkgPath) > 0 { | ||
| typeName = fmt.Sprintf("%s.%s", pkgPath, typeName) | ||
| } | ||
|
|
||
| base, err := NewId(LocalTypeDomain, typeName) | ||
|
|
There was a problem hiding this comment.
LocalTypeDomain is currently nil (TODO), but TypeIdOf/newIdFor uses it to build IDs. This yields type IDs with a nil domain and unstable/incorrect string representations, and TypedDomain construction calls TypeIdOf. Consider initializing LocalTypeDomain to a real domain during package init (or requiring explicit initialization before TypeIdOf can be used) and returning a clear error/panic if it's unset.
| func (bus *ServiceBus) Register(object interface{}, fns ...interface{}) (err error) { | ||
| objectPtr := reflect.ValueOf(object).Pointer() | ||
| bus.registrationLock.Lock() | ||
| defer bus.registrationLock.Unlock() | ||
| for _, fn := range fns { | ||
| if reflect.TypeOf(fn).Kind() != reflect.Func { | ||
| return fmt.Errorf("invalid type: %T should be func", fn) | ||
| } | ||
| bus.registrations[objectPtr] = append(bus.registrations[objectPtr], fn) | ||
| err = bus.eventBus.SubscribeAsync(fn) | ||
| if err != nil { | ||
| break | ||
| } | ||
| } | ||
|
|
||
| return err | ||
| } | ||
|
|
||
| func (bus *ServiceBus) UnregisterAll(object interface{}) error { | ||
| objectPtr := reflect.ValueOf(object).Pointer() | ||
|
|
||
| bus.registrationLock.Lock() | ||
| defer bus.registrationLock.Unlock() | ||
|
|
||
| fns := bus.registrations[objectPtr] | ||
| var errs []error | ||
| for _, fn := range fns { | ||
| if err := bus.eventBus.Unsubscribe(fn); err != nil { | ||
| errs = append(errs, err) | ||
| } | ||
| } | ||
| delete(bus.registrations, objectPtr) | ||
|
|
||
| if len(errs) > 0 { | ||
| return errors.Join(errs...) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func (bus *ServiceBus) Unregister(object interface{}, fns ...interface{}) error { | ||
| objectPtr := reflect.ValueOf(object).Pointer() | ||
| bus.registrationLock.Lock() | ||
| if registrations, ok := bus.registrations[objectPtr]; ok && len(registrations) > 0 { | ||
| for _, fn := range fns { | ||
| removeRegistration(bus.registrations, objectPtr, findRegistrationIndex(bus.registrations, objectPtr, fn)) | ||
| } | ||
| } | ||
| bus.registrationLock.Unlock() | ||
|
|
||
| var errs []error | ||
| for _, fn := range fns { | ||
| if err := bus.eventBus.Unsubscribe(fn); err != nil { | ||
| errs = append(errs, err) | ||
| } | ||
| } | ||
|
|
||
| if len(errs) > 0 { | ||
| return errors.Join(errs...) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| func (bus *ServiceBus) Registrations(object interface{}) []interface{} { | ||
| bus.registrationLock.Lock() | ||
| defer bus.registrationLock.Unlock() | ||
| objectPtr := reflect.ValueOf(object).Pointer() | ||
| return bus.registrations[objectPtr] |
There was a problem hiding this comment.
Register/Unregister use reflect.ValueOf(object).Pointer(), which panics unless object is a pointer/chan/map/func/slice/unsafe.Pointer. Since this is a public API, consider validating reflect.ValueOf(object).Kind() and returning an error for unsupported kinds. Also, Registrations returns the internal slice directly, allowing callers to mutate shared state without the lock; returning a copy would avoid data races/corruption.
Fixes seven unresolved review comments from PR #20450 (componentization framework). All fixes are focused and localized; two larger refactors (Publish → atomic.Pointer[handlerMap] and relations → []*component) are deferred to separate follow-up PRs because they touch many call sites and benefit from independent review. - event/eventbus.go: HasCallback uses RLock instead of Lock. It only reads handlerMap and a full write lock blocks concurrent Publish/Subscribe unnecessarily. - component/componentdomain.go: Guard nil dereference of opts.execPoolSize in the root-domain fallback path. Also return false from the WithDependentDomain option callback when the passed ComponentDomain is not a *componentDomain, instead of panicking on the type assertion. - event/managedeventbus.go: Reject non-pointer inputs to Register and UnregisterAll with a descriptive error instead of panicking inside reflect.Value.Pointer(). - event/servicebus.go: Add objectPointer helper that validates reflect.Value kind. Register/Unregister/UnregisterAll/Registrations all use it. Registrations now returns a copy of the internal slice so callers cannot mutate shared state without the lock. - util/compare.go: Equal guards against == on uncomparable types (slices, maps, funcs). Falls back to reflect.DeepEqual when either operand has a non-comparable type, preventing runtime panics. - typeid.go: Initialize LocalTypeDomain to a real named domain at package init instead of leaving it as a nil TODO. Previously TypeIdOf / newIdFor would build IDs with a nil domain. All node/app tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fixes seven unresolved review comments from PR #20450 (componentization framework). All fixes are focused and localized; two larger refactors (Publish → atomic.Pointer[handlerMap] and relations → []*component) are deferred to separate follow-up PRs because they touch many call sites and benefit from independent review. - event/eventbus.go: HasCallback uses RLock instead of Lock. It only reads handlerMap and a full write lock blocks concurrent Publish/Subscribe unnecessarily. - component/componentdomain.go: Guard nil dereference of opts.execPoolSize in the root-domain fallback path. Also return false from the WithDependentDomain option callback when the passed ComponentDomain is not a *componentDomain, instead of panicking on the type assertion. - event/managedeventbus.go: Reject non-pointer inputs to Register and UnregisterAll with a descriptive error instead of panicking inside reflect.Value.Pointer(). - event/servicebus.go: Add objectPointer helper that validates reflect.Value kind. Register/Unregister/UnregisterAll/Registrations all use it. Registrations now returns a copy of the internal slice so callers cannot mutate shared state without the lock. - util/compare.go: Equal guards against == on uncomparable types (slices, maps, funcs). Falls back to reflect.DeepEqual when either operand has a non-comparable type, preventing runtime panics. - typeid.go: Initialize LocalTypeDomain to a real named domain at package init instead of leaving it as a nil TODO. Previously TypeIdOf / newIdFor would build IDs with a nil domain. All node/app tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…20584) ## Summary Follow-up from #20583 — addresses the deferred "Publish → atomic.Pointer" suggestion from the #20450 review. Replaces the `sync.RWMutex` + clone-on-publish model with copy-on-write on `atomic.Pointer[handlerMap]`: - **Publishers** (`Publish`, `HasCallback`) load the pointer atomically with no lock and no clone — the loaded map is used as an immutable snapshot. - **Writers** (`Subscribe`/`Unsubscribe`) serialize on a new `writerLock` to compose the mutation, clone the current root, mutate the clone, and `Store` the new root. - **Once-handler cleanup** after `Publish` uses a `CompareAndSwap` loop so concurrent writers are not lost. ## Why - Eliminates the `RLock`/`Lock` dance in every `Publish` call. `Publish` is the hot path — previously it took the lock to clone the entire map just to avoid holding the lock across handler execution. - Makes re-entrant `Publish` from inside handlers safe without any locking. Previously async handlers used `RLock` to read `handler.bus`, which conflicted with any concurrent `Subscribe`/`Unsubscribe`. ## Incidental `eventHandler.bus` is now `atomic.Pointer[eventBus]` so async callbacks can observe `Unsubscribe` (which calls `bus.Store(nil)`) without any lock. Added a `newEventHandler` helper so the atomic pointer is always initialized consistently at construction. ## Test plan - [x] `go test ./node/app/event/...` - [x] `go test -race ./node/app/event/...` — clean - [ ] CI passes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Alex Sharov <AskAlexSharov@gmail.com>
## Summary Follow-up from #20583 — addresses the deferred "relations → []\*component" suggestion from the #20450 review. Changes the internal `relations` type from `[]relation` (interface slice) to `[]*component`. The `relation` interface is still used as the public parameter type for `Add`/`Remove`/`Contains`/`WithDependencies`/`WithDependent` — callers pass any `relation` and we unwrap to `*component` at the boundary via the existing `asComponent` helper. ## Why - Pointer identity comparison becomes a direct `*component == *component`, eliminating the `uintptr(unsafe.Pointer(...))` dance via the `relationPtr` helper. - Avoids the interface-header allocation that happens every time `*component` was converted to `relation` for storage. - `cmp`/`samePtr` now take `*component` directly, making the intent explicit. ## Scope Intentionally narrow: the `relation` interface itself is not changed, so there's no churn in method signatures that take `relation` as a parameter. Only the internal storage type changes. ## Test plan - [x] `go test ./node/app/...` - [ ] CI passes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Alex Sharov <AskAlexSharov@gmail.com>
Fixes seven unresolved review comments from PR #20450 (componentization framework). All fixes are focused and localized; two larger refactors (Publish → atomic.Pointer[handlerMap] and relations → []*component) are deferred to separate follow-up PRs because they touch many call sites and benefit from independent review. - event/eventbus.go: HasCallback uses RLock instead of Lock. It only reads handlerMap and a full write lock blocks concurrent Publish/Subscribe unnecessarily. - component/componentdomain.go: Guard nil dereference of opts.execPoolSize in the root-domain fallback path. Also return false from the WithDependentDomain option callback when the passed ComponentDomain is not a *componentDomain, instead of panicking on the type assertion. - event/managedeventbus.go: Reject non-pointer inputs to Register and UnregisterAll with a descriptive error instead of panicking inside reflect.Value.Pointer(). - event/servicebus.go: Add objectPointer helper that validates reflect.Value kind. Register/Unregister/UnregisterAll/Registrations all use it. Registrations now returns a copy of the internal slice so callers cannot mutate shared state without the lock. - util/compare.go: Equal guards against == on uncomparable types (slices, maps, funcs). Falls back to reflect.DeepEqual when either operand has a non-comparable type, preventing runtime panics. - typeid.go: Initialize LocalTypeDomain to a real named domain at package init instead of leaving it as a nil TODO. Previously TypeIdOf / newIdFor would build IDs with a nil domain. All node/app tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
## Summary Follow-up PR addressing seven unresolved review comments from #20450 (componentization framework). All fixes are focused and localized. ### Fixes included - **`event/eventbus.go`**: `HasCallback` now uses `RLock`/`RUnlock` instead of `Lock`/`Unlock` — it only reads `handlerMap` and a full write lock blocks concurrent `Publish`/`Subscribe` unnecessarily. - **`component/componentdomain.go`**: Guard nil dereference of `opts.execPoolSize` in the root-domain fallback path. Also return `false` from the `WithDependentDomain` option callback when the passed `ComponentDomain` is not a `*componentDomain`, instead of panicking on the type assertion. - **`event/managedeventbus.go`**: Reject non-pointer inputs to `Register` and `UnregisterAll` with a descriptive error instead of panicking inside `reflect.Value.Pointer()`. - **`event/servicebus.go`**: Add `objectPointer` helper that validates `reflect.Value` kind. `Register`/`Unregister`/`UnregisterAll`/`Registrations` all use it. `Registrations` now returns a copy of the internal slice so callers cannot mutate shared state without the lock. - **`util/compare.go`**: `Equal` guards against `==` on uncomparable types (slices, maps, funcs). Falls back to `reflect.DeepEqual` when either operand has a non-comparable type, preventing runtime panics. - **`typeid.go`**: Initialize `LocalTypeDomain` to a real named domain at package init instead of leaving it as a nil `TODO`. Previously `TypeIdOf` / `newIdFor` would build IDs with a nil domain. ### Larger refactors split out Two review suggestions warranted their own PRs because they touch many call sites and benefit from independent review: - **#20584**: `Publish` → `atomic.Pointer[handlerMap]` — lock-free `Publish`/`HasCallback`, copy-on-write writers. Eliminates the `RWMutex` and clone-on-publish entirely. - **#20585**: `relations` → `[]*component` — removes interface-header allocation and `unsafe.Pointer` indirection in relations storage. ## Test plan - [x] `go build ./...` - [x] `go test ./node/app/...` - [ ] CI passes 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ines (#20471) ## Summary Extracts the downloader setup from backend.go into a lifecycle-managed component, and adds event bus documentation. ### Downloader component (`node/components/downloader/`) - `Provider` wraps `NewGrpcClient` / `BuildTorrentClient` into a component with Initialize/Start lifecycle - Event types for download progress, completion, verification - Event bus observability TODOs for future metrics integration - backend.go reduced by ~93 lines of inline downloader setup - Design doc and README ### Event bus guidelines (`node/app/event/GUIDELINES.md`) - Developer guidelines for event bus usage patterns - Domain architecture guidance (isolation, hierarchy) - Migration strategy from callbacks → events - L1/L2 combined mode documented as use case ### Component tests - Event flow and load tests (`event_flow_test.go`) ## Test plan - [x] `make lint` passes - [x] `make erigon` builds - [x] `go test ./node/components/downloader/... ./node/app/...` — all pass - [ ] CI passes **Depends on:** #20450 (component framework) --------- Co-authored-by: Alex Sharov <AskAlexSharov@gmail.com>
Summary
Second PR in the componentization series (after #20390). Adds the core component framework under
node/app/:node/app/component/— typed components with dependency tracking, hierarchical domains, and ordered activation/deactivationnode/app/event/— typed publish/subscribe for inter-component communication, replacing direct callback couplingnode/app/workerpool/— work-stealing pool for component task executionnode/app/util/— Result types, await helpers, execution poolThis is a pure addition — no existing code is modified (except go.mod/go.sum). The framework will be used by subsequent PRs to extract backend.go subsystems (downloader, txpool, sentry, etc.) into independent components.
Ported from the
app_componentsbranch, with the staleerigon-libtest file removed.Test plan
make lintpassesmake erigon integrationbuildsgo test ./node/app/...— all component/event/workerpool tests pass