Skip to content

fix(engine,#663): plumb yaml dependsOn into RemoteModule.Dependencies (PR #664 follow-up)#665

Merged
intel352 merged 10 commits into
mainfrom
fix/663-plumb-dependsOn
May 14, 2026
Merged

fix(engine,#663): plumb yaml dependsOn into RemoteModule.Dependencies (PR #664 follow-up)#665
intel352 merged 10 commits into
mainfrom
fix/663-plumb-dependsOn

Conversation

@intel352
Copy link
Copy Markdown
Contributor

Summary

Follow-up to #664 (v0.51.8). The v0.51.8 fix reordered cfg.Modules by yaml dependsOn: before app.RegisterModule, but modular's app.Init() then runs its own DependencyAware-driven sort over the registered modules — and RemoteModule (the wrapper used for every external-plugin module) returned nil from Dependencies(). So modular still saw every external-plugin module as a root and sorted alphabetically.

BMW PR #280 image-launch confirmed the gap on commit 53283b2: with the aaa-/aab- prefix workaround removed and v0.51.8 in place, the engine still initialised the 6 bmw-consumer-* modules before bmw-eventbus and bmw-stream, hit the broker-not-registered 10s race, and never served /healthz. The modular init-order log shows the same alphabetical order v0.51.8 was meant to break.

Fix

Two-part:

1. plugin/external/remote_module.go — add a dependencies field on RemoteModule + a SetDependencies setter. Dependencies() now returns the stored slice instead of always returning nil.

2. engine.go — after the factory returns the module, call SetDependencies(modCfg.DependsOn) via a structural type assertion:

if dt, ok := mod.(interface{ SetDependencies([]string) }); ok {
    deps := make([]string, len(modCfg.DependsOn))
    copy(deps, modCfg.DependsOn)
    dt.SetDependencies(deps)
}

Built-in modules opt in by implementing the same setter; modules that don't implement it see no behaviour change. The yaml slice is defensively copied before storing so a downstream mutator can't corrupt the engine-side dependency graph.

Tests

6 new unit tests in plugin/external/remote_module_dependencies_test.go:

  • TestRemoteModule_Dependencies_DefaultsToNil — fresh module reports no deps.
  • TestRemoteModule_SetDependencies_PlumbsYamlDependsOn — explicit BMW shape (bmw-eventbus, bmw-stream → consumer).
  • TestRemoteModule_SetDependencies_EmptySliceIsEmpty — empty slice stays empty (not nil).
  • TestRemoteModule_SetDependencies_Overwrites — second call replaces.
  • TestRemoteModule_ImplementsDependencyAware — structural pin matching modular's interface.
  • TestRemoteModule_ImplementsDependencyTargetInterface — structural pin on the setter so a future refactor that drops SetDependencies fails loudly.
$ GOWORK=off go test -count=1 -short -timeout 180s . ./plugin/external/
ok  	github.com/GoCodeAlone/workflow	8.298s
ok  	github.com/GoCodeAlone/workflow/plugin/external	0.635s

Follow-up

BMW PR #280 will re-validate end-to-end against v0.51.9 after this lands. Once green there, the aaa-/aab- prefix workaround in buymywishlist/app.yaml finally goes away for real.

🤖 Generated with Claude Code

Follow-up to PR #664 (v0.51.8). The v0.51.8 fix reordered cfg.Modules
by yaml dependsOn before app.RegisterModule, but modular's app.Init()
then runs its OWN DependencyAware-driven sort over the registered
modules — and RemoteModule (the wrapper used for every external-plugin
module) returned nil from Dependencies(). So modular still saw every
external-plugin module as a root and sorted alphabetically.

BMW PR #280 image-launch surfaced this on 53283b2: with the
aaa-/aab- prefix workaround removed and v0.51.8 in place, the engine
still initialised the 6 bmw-consumer-* modules before bmw-eventbus
and bmw-stream, hit the broker-not-registered 10s race, and never
served /healthz.

The fix has two parts:

1. plugin/external/remote_module.go: add a dependencies field on
   RemoteModule + a SetDependencies setter. Dependencies() now
   returns the stored slice instead of always returning nil.

2. engine.go: after the factory returns the module, call
   SetDependencies(modCfg.DependsOn) via a structural type assertion:

     if dt, ok := mod.(interface{ SetDependencies([]string) }); ok {
         dt.SetDependencies(deps)
     }

   Built-in modules opt in by implementing the same setter; modules
   that don't implement it see no behaviour change. The yaml slice
   is defensively copied before storing.

6 unit tests in remote_module_dependencies_test.go cover the contract:
default-nil, plumb-from-yaml, empty-slice tolerance, overwrite-replaces,
and two structural type-assertion pins so a future refactor that
drops SetDependencies fails loudly instead of silently re-introducing
the workflow#663 race.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 13, 2026 22:49
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR completes the #663 init-order fix by ensuring external-plugin modules expose YAML dependsOn declarations through RemoteModule.Dependencies(), so modular’s own dependency-aware initialization sort matches the engine’s config ordering.

Changes:

  • Adds dependency storage and SetDependencies support to RemoteModule.
  • Plumbs modCfg.DependsOn from StdEngine.BuildFromConfig into modules that support the setter.
  • Adds RemoteModule dependency contract tests and an Unreleased changelog entry.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
engine.go Copies YAML module dependencies into modules that implement SetDependencies.
plugin/external/remote_module.go Stores and returns RemoteModule dependencies for modular init ordering.
plugin/external/remote_module_dependencies_test.go Adds unit tests for RemoteModule dependency behavior and structural interfaces.
CHANGELOG.md Documents the follow-up fix for external-plugin module init ordering.

Comment thread engine.go Outdated
Comment on lines +568 to +571
if depTarget, ok := mod.(interface{ SetDependencies([]string) }); ok {
deps := make([]string, len(modCfg.DependsOn))
copy(deps, modCfg.DependsOn)
depTarget.SetDependencies(deps)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in f0a6599: added 3 BuildFromConfig-level tests covering the new engine plumbing path. TestEngine_BuildFromConfig_PlumbsDependsOnIntoModule pins the production wiring (broker + consumer config drives exactly one SetDependencies call on the consumer with the broker name); TestEngine_BuildFromConfig_PlumbsDefensiveCopy proves the engine copies the yaml slice before passing through; TestEngine_BuildFromConfig_SkipsModulesWithoutSetter pins back-compat for modules that don't opt in. All 3 use a custom ModuleFactory registered via AddModuleType + a fakeDepAwareModule that records every SetDependencies call.

Copilot R1 on PR #665 flagged: the new engine.go plumbing path that
calls SetDependencies via structural type assertion is not covered
by an end-to-end test. The added RemoteModule unit tests prove the
storage works in isolation, but a future refactor that moves or
removes the assertion in engine.go would silently regress the
workflow#663 fix and only image-launch CI would catch it.

This commit adds 3 BuildFromConfig-level tests using a custom
ModuleFactory that registers a fake module implementing the
SetDependencies setter:

- TestEngine_BuildFromConfig_PlumbsDependsOnIntoModule: a 2-module
  config (broker + consumer where consumer DependsOn:[broker])
  must drive exactly one SetDependencies call on the consumer with
  the value [broker], and zero calls on the broker.

- TestEngine_BuildFromConfig_PlumbsDefensiveCopy: mutates the
  yaml-derived dependsOn slice after BuildFromConfig returns and
  verifies the recorded value didn't change. Proves the engine
  copies the slice before passing it through.

- TestEngine_BuildFromConfig_SkipsModulesWithoutSetter: registers a
  module whose type does NOT implement SetDependencies and confirms
  BuildFromConfig succeeds (doesn't panic, doesn't error).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 13, 2026

⏱ Benchmark Results

No significant performance regressions detected.

benchstat comparison (baseline → PR)
## benchstat: baseline → PR
baseline-bench.txt:264: parsing iteration count: invalid syntax
baseline-bench.txt:281038: parsing iteration count: invalid syntax
baseline-bench.txt:532724: parsing iteration count: invalid syntax
baseline-bench.txt:811272: parsing iteration count: invalid syntax
baseline-bench.txt:1081828: parsing iteration count: invalid syntax
baseline-bench.txt:1579256: parsing iteration count: invalid syntax
benchmark-results.txt:264: parsing iteration count: invalid syntax
benchmark-results.txt:328828: parsing iteration count: invalid syntax
benchmark-results.txt:653954: parsing iteration count: invalid syntax
benchmark-results.txt:989106: parsing iteration count: invalid syntax
benchmark-results.txt:1262755: parsing iteration count: invalid syntax
benchmark-results.txt:1803845: parsing iteration count: invalid syntax
goos: linux
goarch: amd64
pkg: github.com/GoCodeAlone/workflow/dynamic
cpu: AMD EPYC 7763 64-Core Processor                
                            │ baseline-bench.txt │
                            │       sec/op       │
InterpreterCreation-4               9.690m ± 64%
ComponentLoad-4                     3.978m ± 14%
ComponentExecute-4                  2.141µ ±  1%
PoolContention/workers-1-4          1.188µ ±  2%
PoolContention/workers-2-4          1.194µ ±  3%
PoolContention/workers-4-4          1.180µ ±  1%
PoolContention/workers-8-4          1.168µ ±  1%
PoolContention/workers-16-4         1.175µ ±  2%
ComponentLifecycle-4                3.962m ±  0%
SourceValidation-4                  2.558µ ±  2%
RegistryConcurrent-4                861.2n ±  2%
LoaderLoadFromString-4              4.023m ±  1%
geomean                             20.83µ

                            │ baseline-bench.txt │
                            │        B/op        │
InterpreterCreation-4               2.027Mi ± 0%
ComponentLoad-4                     2.180Mi ± 0%
ComponentExecute-4                  1.203Ki ± 0%
PoolContention/workers-1-4          1.203Ki ± 0%
PoolContention/workers-2-4          1.203Ki ± 0%
PoolContention/workers-4-4          1.203Ki ± 0%
PoolContention/workers-8-4          1.203Ki ± 0%
PoolContention/workers-16-4         1.203Ki ± 0%
ComponentLifecycle-4                2.183Mi ± 0%
SourceValidation-4                  1.984Ki ± 0%
RegistryConcurrent-4                1.133Ki ± 0%
LoaderLoadFromString-4              2.182Mi ± 0%
geomean                             15.25Ki

                            │ baseline-bench.txt │
                            │     allocs/op      │
InterpreterCreation-4                15.68k ± 0%
ComponentLoad-4                      18.02k ± 0%
ComponentExecute-4                    25.00 ± 0%
PoolContention/workers-1-4            25.00 ± 0%
PoolContention/workers-2-4            25.00 ± 0%
PoolContention/workers-4-4            25.00 ± 0%
PoolContention/workers-8-4            25.00 ± 0%
PoolContention/workers-16-4           25.00 ± 0%
ComponentLifecycle-4                 18.07k ± 0%
SourceValidation-4                    32.00 ± 0%
RegistryConcurrent-4                  2.000 ± 0%
LoaderLoadFromString-4               18.06k ± 0%
geomean                               183.3

cpu: AMD EPYC 9V74 80-Core Processor                
                            │ benchmark-results.txt │
                            │        sec/op         │
InterpreterCreation-4                  6.242m ± 61%
ComponentLoad-4                        3.488m ±  4%
ComponentExecute-4                     1.842µ ±  1%
PoolContention/workers-1-4             1.024µ ±  2%
PoolContention/workers-2-4             1.010µ ±  1%
PoolContention/workers-4-4             1.014µ ±  2%
PoolContention/workers-8-4             1.034µ ±  4%
PoolContention/workers-16-4            1.020µ ±  1%
ComponentLifecycle-4                   3.523m ±  1%
SourceValidation-4                     2.124µ ±  1%
RegistryConcurrent-4                   774.5n ±  6%
LoaderLoadFromString-4                 3.562m ±  0%
geomean                                17.65µ

                            │ benchmark-results.txt │
                            │         B/op          │
InterpreterCreation-4                  2.027Mi ± 0%
ComponentLoad-4                        2.180Mi ± 0%
ComponentExecute-4                     1.203Ki ± 0%
PoolContention/workers-1-4             1.203Ki ± 0%
PoolContention/workers-2-4             1.203Ki ± 0%
PoolContention/workers-4-4             1.203Ki ± 0%
PoolContention/workers-8-4             1.203Ki ± 0%
PoolContention/workers-16-4            1.203Ki ± 0%
ComponentLifecycle-4                   2.183Mi ± 0%
SourceValidation-4                     1.984Ki ± 0%
RegistryConcurrent-4                   1.133Ki ± 0%
LoaderLoadFromString-4                 2.182Mi ± 0%
geomean                                15.25Ki

                            │ benchmark-results.txt │
                            │       allocs/op       │
InterpreterCreation-4                   15.68k ± 0%
ComponentLoad-4                         18.02k ± 0%
ComponentExecute-4                       25.00 ± 0%
PoolContention/workers-1-4               25.00 ± 0%
PoolContention/workers-2-4               25.00 ± 0%
PoolContention/workers-4-4               25.00 ± 0%
PoolContention/workers-8-4               25.00 ± 0%
PoolContention/workers-16-4              25.00 ± 0%
ComponentLifecycle-4                    18.07k ± 0%
SourceValidation-4                       32.00 ± 0%
RegistryConcurrent-4                     2.000 ± 0%
LoaderLoadFromString-4                  18.06k ± 0%
geomean                                  183.3

pkg: github.com/GoCodeAlone/workflow/middleware
cpu: AMD EPYC 7763 64-Core Processor                
                                  │ baseline-bench.txt │
                                  │       sec/op       │
CircuitBreakerDetection-4                  321.1n ± 1%
CircuitBreakerExecution_Success-4          23.62n ± 2%
CircuitBreakerExecution_Failure-4          69.65n ± 1%
geomean                                    80.84n

                                  │ baseline-bench.txt │
                                  │        B/op        │
CircuitBreakerDetection-4                 144.0 ± 0%
CircuitBreakerExecution_Success-4         0.000 ± 0%
CircuitBreakerExecution_Failure-4         0.000 ± 0%
geomean                                              ¹
¹ summaries must be >0 to compute geomean

                                  │ baseline-bench.txt │
                                  │     allocs/op      │
CircuitBreakerDetection-4                 1.000 ± 0%
CircuitBreakerExecution_Success-4         0.000 ± 0%
CircuitBreakerExecution_Failure-4         0.000 ± 0%
geomean                                              ¹
¹ summaries must be >0 to compute geomean

cpu: AMD EPYC 9V74 80-Core Processor                
                                  │ benchmark-results.txt │
                                  │        sec/op         │
CircuitBreakerDetection-4                     296.8n ± 5%
CircuitBreakerExecution_Success-4             22.66n ± 1%
CircuitBreakerExecution_Failure-4             70.94n ± 0%
geomean                                       78.14n

                                  │ benchmark-results.txt │
                                  │         B/op          │
CircuitBreakerDetection-4                    144.0 ± 0%
CircuitBreakerExecution_Success-4            0.000 ± 0%
CircuitBreakerExecution_Failure-4            0.000 ± 0%
geomean                                                 ¹
¹ summaries must be >0 to compute geomean

                                  │ benchmark-results.txt │
                                  │       allocs/op       │
CircuitBreakerDetection-4                    1.000 ± 0%
CircuitBreakerExecution_Success-4            0.000 ± 0%
CircuitBreakerExecution_Failure-4            0.000 ± 0%
geomean                                                 ¹
¹ summaries must be >0 to compute geomean

pkg: github.com/GoCodeAlone/workflow/module
cpu: AMD EPYC 7763 64-Core Processor                
                                 │ baseline-bench.txt │
                                 │       sec/op       │
JQTransform_Simple-4                     1.037µ ± 24%
JQTransform_ObjectConstruction-4         1.591µ ±  2%
JQTransform_ArraySelect-4                3.728µ ± 10%
JQTransform_Complex-4                    42.75µ ±  2%
JQTransform_Throughput-4                 1.961µ ±  2%
SSEPublishDelivery-4                     70.85n ±  6%
geomean                                  1.821µ

                                 │ baseline-bench.txt │
                                 │        B/op        │
JQTransform_Simple-4                   1.273Ki ± 0%
JQTransform_ObjectConstruction-4       1.773Ki ± 0%
JQTransform_ArraySelect-4              2.625Ki ± 0%
JQTransform_Complex-4                  16.22Ki ± 0%
JQTransform_Throughput-4               1.984Ki ± 0%
SSEPublishDelivery-4                     0.000 ± 0%
geomean                                             ¹
¹ summaries must be >0 to compute geomean

                                 │ baseline-bench.txt │
                                 │     allocs/op      │
JQTransform_Simple-4                     10.00 ± 0%
JQTransform_ObjectConstruction-4         15.00 ± 0%
JQTransform_ArraySelect-4                30.00 ± 0%
JQTransform_Complex-4                    324.0 ± 0%
JQTransform_Throughput-4                 17.00 ± 0%
SSEPublishDelivery-4                     0.000 ± 0%
geomean                                             ¹
¹ summaries must be >0 to compute geomean

cpu: AMD EPYC 9V74 80-Core Processor                
                                 │ benchmark-results.txt │
                                 │        sec/op         │
JQTransform_Simple-4                        859.8n ± 23%
JQTransform_ObjectConstruction-4            1.373µ ±  1%
JQTransform_ArraySelect-4                   3.347µ ±  0%
JQTransform_Complex-4                       40.74µ ±  1%
JQTransform_Throughput-4                    1.702µ ±  1%
SSEPublishDelivery-4                        63.08n ±  1%
geomean                                     1.608µ

                                 │ benchmark-results.txt │
                                 │         B/op          │
JQTransform_Simple-4                      1.273Ki ± 0%
JQTransform_ObjectConstruction-4          1.773Ki ± 0%
JQTransform_ArraySelect-4                 2.625Ki ± 0%
JQTransform_Complex-4                     16.22Ki ± 0%
JQTransform_Throughput-4                  1.984Ki ± 0%
SSEPublishDelivery-4                        0.000 ± 0%
geomean                                                ¹
¹ summaries must be >0 to compute geomean

                                 │ benchmark-results.txt │
                                 │       allocs/op       │
JQTransform_Simple-4                        10.00 ± 0%
JQTransform_ObjectConstruction-4            15.00 ± 0%
JQTransform_ArraySelect-4                   30.00 ± 0%
JQTransform_Complex-4                       324.0 ± 0%
JQTransform_Throughput-4                    17.00 ± 0%
SSEPublishDelivery-4                        0.000 ± 0%
geomean                                                ¹
¹ summaries must be >0 to compute geomean

pkg: github.com/GoCodeAlone/workflow/schema
cpu: AMD EPYC 7763 64-Core Processor                
                                    │ baseline-bench.txt │
                                    │       sec/op       │
SchemaValidation_Simple-4                   1.228µ ±  5%
SchemaValidation_AllFields-4                1.888µ ± 18%
SchemaValidation_FormatValidation-4         1.766µ ±  5%
SchemaValidation_ManySchemas-4              2.042µ ±  4%
geomean                                     1.700µ

                                    │ baseline-bench.txt │
                                    │        B/op        │
SchemaValidation_Simple-4                   0.000 ± 0%
SchemaValidation_AllFields-4                0.000 ± 0%
SchemaValidation_FormatValidation-4         0.000 ± 0%
SchemaValidation_ManySchemas-4              0.000 ± 0%
geomean                                                ¹
¹ summaries must be >0 to compute geomean

                                    │ baseline-bench.txt │
                                    │     allocs/op      │
SchemaValidation_Simple-4                   0.000 ± 0%
SchemaValidation_AllFields-4                0.000 ± 0%
SchemaValidation_FormatValidation-4         0.000 ± 0%
SchemaValidation_ManySchemas-4              0.000 ± 0%
geomean                                                ¹
¹ summaries must be >0 to compute geomean

cpu: AMD EPYC 9V74 80-Core Processor                
                                    │ benchmark-results.txt │
                                    │        sec/op         │
SchemaValidation_Simple-4                       1.094µ ± 1%
SchemaValidation_AllFields-4                    1.640µ ± 6%
SchemaValidation_FormatValidation-4             1.599µ ± 1%
SchemaValidation_ManySchemas-4                  1.601µ ± 2%
geomean                                         1.464µ

                                    │ benchmark-results.txt │
                                    │         B/op          │
SchemaValidation_Simple-4                      0.000 ± 0%
SchemaValidation_AllFields-4                   0.000 ± 0%
SchemaValidation_FormatValidation-4            0.000 ± 0%
SchemaValidation_ManySchemas-4                 0.000 ± 0%
geomean                                                   ¹
¹ summaries must be >0 to compute geomean

                                    │ benchmark-results.txt │
                                    │       allocs/op       │
SchemaValidation_Simple-4                      0.000 ± 0%
SchemaValidation_AllFields-4                   0.000 ± 0%
SchemaValidation_FormatValidation-4            0.000 ± 0%
SchemaValidation_ManySchemas-4                 0.000 ± 0%
geomean                                                   ¹
¹ summaries must be >0 to compute geomean

pkg: github.com/GoCodeAlone/workflow/store
cpu: AMD EPYC 7763 64-Core Processor                
                                   │ baseline-bench.txt │
                                   │       sec/op       │
EventStoreAppend_InMemory-4                1.600µ ± 20%
EventStoreAppend_SQLite-4                  2.229m ± 17%
GetTimeline_InMemory/events-10-4           15.06µ ±  5%
GetTimeline_InMemory/events-50-4           84.72µ ±  3%
GetTimeline_InMemory/events-100-4          167.7µ ± 19%
GetTimeline_InMemory/events-500-4          706.9µ ±  1%
GetTimeline_InMemory/events-1000-4         1.451m ±  1%
GetTimeline_SQLite/events-10-4             117.2µ ±  1%
GetTimeline_SQLite/events-50-4             277.6µ ±  1%
GetTimeline_SQLite/events-100-4            480.6µ ±  2%
GetTimeline_SQLite/events-500-4            2.029m ±  1%
GetTimeline_SQLite/events-1000-4           3.961m ±  1%
geomean                                    260.6µ

                                   │ baseline-bench.txt │
                                   │        B/op        │
EventStoreAppend_InMemory-4                 782.0 ± 11%
EventStoreAppend_SQLite-4                 1.986Ki ±  3%
GetTimeline_InMemory/events-10-4          7.953Ki ±  0%
GetTimeline_InMemory/events-50-4          46.62Ki ±  0%
GetTimeline_InMemory/events-100-4         94.48Ki ±  0%
GetTimeline_InMemory/events-500-4         472.8Ki ±  0%
GetTimeline_InMemory/events-1000-4        944.3Ki ±  0%
GetTimeline_SQLite/events-10-4            16.74Ki ±  0%
GetTimeline_SQLite/events-50-4            87.14Ki ±  0%
GetTimeline_SQLite/events-100-4           175.4Ki ±  0%
GetTimeline_SQLite/events-500-4           846.1Ki ±  0%
GetTimeline_SQLite/events-1000-4          1.639Mi ±  0%
geomean                                   67.29Ki

                                   │ baseline-bench.txt │
                                   │     allocs/op      │
EventStoreAppend_InMemory-4                  7.000 ± 0%
EventStoreAppend_SQLite-4                    53.00 ± 0%
GetTimeline_InMemory/events-10-4             125.0 ± 0%
GetTimeline_InMemory/events-50-4             653.0 ± 0%
GetTimeline_InMemory/events-100-4           1.306k ± 0%
GetTimeline_InMemory/events-500-4           6.514k ± 0%
GetTimeline_InMemory/events-1000-4          13.02k ± 0%
GetTimeline_SQLite/events-10-4               382.0 ± 0%
GetTimeline_SQLite/events-50-4              1.852k ± 0%
GetTimeline_SQLite/events-100-4             3.681k ± 0%
GetTimeline_SQLite/events-500-4             18.54k ± 0%
GetTimeline_SQLite/events-1000-4            37.29k ± 0%
geomean                                     1.162k

cpu: AMD EPYC 9V74 80-Core Processor                
                                   │ benchmark-results.txt │
                                   │        sec/op         │
EventStoreAppend_InMemory-4                   1.127µ ± 14%
EventStoreAppend_SQLite-4                     992.1µ ±  5%
GetTimeline_InMemory/events-10-4              12.92µ ±  2%
GetTimeline_InMemory/events-50-4              72.39µ ±  1%
GetTimeline_InMemory/events-100-4             145.9µ ±  3%
GetTimeline_InMemory/events-500-4             577.0µ ± 28%
GetTimeline_InMemory/events-1000-4            1.168m ±  2%
GetTimeline_SQLite/events-10-4                84.74µ ±  2%
GetTimeline_SQLite/events-50-4                221.3µ ±  1%
GetTimeline_SQLite/events-100-4               384.8µ ±  1%
GetTimeline_SQLite/events-500-4               1.682m ±  1%
GetTimeline_SQLite/events-1000-4              3.289m ±  1%
geomean                                       200.0µ

                                   │ benchmark-results.txt │
                                   │         B/op          │
EventStoreAppend_InMemory-4                    782.0 ± 11%
EventStoreAppend_SQLite-4                    1.985Ki ±  2%
GetTimeline_InMemory/events-10-4             7.953Ki ±  0%
GetTimeline_InMemory/events-50-4             46.62Ki ±  0%
GetTimeline_InMemory/events-100-4            94.48Ki ±  0%
GetTimeline_InMemory/events-500-4            472.8Ki ±  0%
GetTimeline_InMemory/events-1000-4           944.3Ki ±  0%
GetTimeline_SQLite/events-10-4               16.74Ki ±  0%
GetTimeline_SQLite/events-50-4               87.14Ki ±  0%
GetTimeline_SQLite/events-100-4              175.4Ki ±  0%
GetTimeline_SQLite/events-500-4              846.1Ki ±  0%
GetTimeline_SQLite/events-1000-4             1.639Mi ±  0%
geomean                                      67.29Ki

                                   │ benchmark-results.txt │
                                   │       allocs/op       │
EventStoreAppend_InMemory-4                     7.000 ± 0%
EventStoreAppend_SQLite-4                       53.00 ± 0%
GetTimeline_InMemory/events-10-4                125.0 ± 0%
GetTimeline_InMemory/events-50-4                653.0 ± 0%
GetTimeline_InMemory/events-100-4              1.306k ± 0%
GetTimeline_InMemory/events-500-4              6.514k ± 0%
GetTimeline_InMemory/events-1000-4             13.02k ± 0%
GetTimeline_SQLite/events-10-4                  382.0 ± 0%
GetTimeline_SQLite/events-50-4                 1.852k ± 0%
GetTimeline_SQLite/events-100-4                3.681k ± 0%
GetTimeline_SQLite/events-500-4                18.54k ± 0%
GetTimeline_SQLite/events-1000-4               37.29k ± 0%
geomean                                        1.162k

Benchmarks run with go test -bench=. -benchmem -count=6.
Regressions ≥ 20% are flagged. Results compared via benchstat.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment on lines +27 to +31
// Defensive copy so we record what the engine passed, not what later
// code may have mutated on the slice.
cp := make([]string, len(deps))
copy(cp, deps)
m.setCalls = append(m.setCalls, cp)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 925fafe: split off rawSliceRecorderModule (separate from fakeDepAwareModule) that retains the EXACT slice reference SetDependencies receives — no defensive copy on the recording side. TestEngine_BuildFromConfig_PlumbsDefensiveCopy now uses that fake. Sanity-checked: temporarily replaced the engine's depTarget.SetDependencies(deps) with depTarget.SetDependencies(modCfg.DependsOn) and the test correctly FAILS with 'engine did NOT defensively copy'. Restoring the canonical engine code makes it pass again.

Copilot R2 caught it: the original fakeDepAwareModule.SetDependencies
defensively copied the slice on the recording side, which masked
whether the engine itself copies — even if BuildFromConfig passed
modCfg.DependsOn through verbatim, mutating the yaml slice afterward
would not change the recorded value (the fake had its own copy).

This commit splits the recorder into a second fake type
(rawSliceRecorderModule) that retains the EXACT slice reference passed
to SetDependencies. TestEngine_BuildFromConfig_PlumbsDefensiveCopy
now uses that fake and proves the engine itself copies: mutating the
yaml-derived `deps` slice after BuildFromConfig must not affect the
child's recorded reference.

Sanity-checked locally by temporarily replacing the engine's
`depTarget.SetDependencies(deps)` with `depTarget.SetDependencies(modCfg.DependsOn)`
— the test correctly FAILS with "engine did NOT defensively copy".
After restoring the engine the test passes again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Comment thread plugin/external/remote_module.go Outdated
Comment on lines +73 to +74
func (m *RemoteModule) SetDependencies(deps []string) {
m.dependencies = deps
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 04ac269: SetDependencies now defensively copies the slice. The engine path already copies before calling, but copying inside the setter too means external callers (tests, future integration paths) can mutate their source slice without silently corrupting this module's init graph.

Comment thread CHANGELOG.md Outdated

### Fixed (issue #663 — follow-up)

- **`*external.RemoteModule.Dependencies()` now returns the yaml-level `dependsOn:` keys** instead of always returning `nil`. The v0.51.8 fix (PR #664) only reordered the `cfg.Modules` slice — but modular's `app.Init()` then runs its own `DependencyAware`-driven sort over the registered modules, and `RemoteModule` (the wrapper used for every external-plugin module) returned `nil` from `Dependencies()`, so modular saw every external-plugin module as a root and sorted alphabetically. BMW PR #280 image-launch surfaced this as the same `bmw-eventbus`/`bmw-stream` ordering race that v0.51.8 was supposed to close. Engine `BuildFromConfig` now calls `SetDependencies(modCfg.DependsOn)` on each module that implements `interface{ SetDependencies([]string) }` immediately after the factory returns, before `app.RegisterModule`. `RemoteModule` implements that setter and stores the slice on the struct; modular's Init() walker then reads it via the existing `Dependencies()` contract. 6 unit tests cover the contract (default-nil, plumb, empty-slice, overwrite, two structural type-assertion pins). Built-in modules can opt in by implementing the same setter; existing behaviour is unchanged for modules that don't.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 04ac269: CHANGELOG now states 'each module that declares dependsOn: in yaml AND implements the setter' and explains why the len(modCfg.DependsOn) > 0 guard exists (to avoid clobbering constructor-time defaults with SetDependencies(nil)).

Comment on lines +79 to +80
app := newMockApplication()
engine := NewStdEngine(app, app.Logger())
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 04ac269: added TestEngine_BuildFromConfig_RealModularHonoursDependsOn which uses modular.NewStdApplication (real framework, not the no-op mockApplication.Init). The test names the broker 'z-broker' — alphabetically AFTER its dependent 'consumer' — so the only way the assertion passes is if SetDependencies plumbs into modular's actual DependencyAware sort. A regression where deps are recorded but modular still misorders would fail with order=[consumer z-broker] instead of [z-broker consumer].

…NGELOG

Addresses Copilot R3 on PR #665:

1. **plugin/external/remote_module.go**: SetDependencies now defensively
   copies the slice. The engine path already copies before calling, but
   the setter is exported and Dependencies() exposes the same backing
   array to modular — copying inside the setter too means any external
   caller (tests, future integration paths) can mutate its source slice
   without silently corrupting this module's init graph.

2. **CHANGELOG.md**: clarified that BuildFromConfig calls SetDependencies
   only when modCfg.DependsOn is non-empty (the production code's
   `len(modCfg.DependsOn) > 0` guard exists so calling
   SetDependencies(nil) doesn't clobber a custom module's
   constructor-time defaults). Also bumped the test count from 6 to 10
   to reflect the engine-level coverage added in #f0a6599b + #925fafe8
   + this commit.

3. **engine_dependson_plumbing_test.go**: added
   TestEngine_BuildFromConfig_RealModularHonoursDependsOn which uses
   modular.NewStdApplication (NOT the test-package mockApplication
   whose Init() is a no-op). The test names the broker module
   "z-broker" — alphabetically AFTER its dependent "consumer" — so
   the only way the assertion passes is if SetDependencies actually
   plumbs into modular's DependencyAware-driven Init walker. A
   regression where SetDependencies is called but modular still
   misorders would FAIL with order=[consumer z-broker] instead of
   the asserted [z-broker consumer]. Adds 2 helpers
   (initOrderRecorderModule + stubConfigProvider) so this remains
   self-contained inside the workflow package.

Sanity checked locally that the new test passes against the real
modular framework + against the canonical engine code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Comment on lines +79 to +82
func (m *RemoteModule) SetDependencies(deps []string) {
cp := make([]string, len(deps))
copy(cp, deps)
m.dependencies = cp
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in fb9d4dd: added TestRemoteModule_SetDependencies_DefensivelyCopies — mutates the caller's source slice after SetDependencies and asserts Dependencies() still returns the original values. Sanity-checked: temporarily removing the cp := make()/copy block in remote_module.go made the test FAIL with 'Dependencies() = [MUTATED b]'. Restoring made it pass.

Comment on lines +63 to +66
// satisfies a structurally-typed interface matching modular.DependencyAware.
// We can't import modular's interface here without a circular dep, but
// the structural check is sufficient — modular itself does the same
// type assertion at init time.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in fb9d4dd: TestRemoteModule_ImplementsDependencyAware now asserts *RemoteModule satisfies modular.DependencyAware directly. The original 'circular dep' rationale was misleading — plugin/external already imports modular for modular.Module + modular.Application.

Addresses Copilot R4 on PR #665:

1. Added TestRemoteModule_SetDependencies_DefensivelyCopies — mutates
   the caller's source slice after SetDependencies and asserts
   Dependencies() still returns the original values. Sanity-checked
   locally: temporarily removed the cp := make()/copy block in
   remote_module.go and the test correctly FAILS with
   "Dependencies() = [MUTATED b]". Restoring the copy makes it pass.
   This closes the regression window where the engine's pre-copy
   masks a missing setter-side copy from R3's review.

2. TestRemoteModule_ImplementsDependencyAware now asserts
   *RemoteModule satisfies modular.DependencyAware directly, not a
   structural-typed interface. plugin/external already imports
   modular (remote_module.go imports it for modular.Module +
   modular.Application), so the original "circular dep" rationale
   was misleading — Copilot caught it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment thread CHANGELOG.md Outdated

### Fixed (issue #663 — follow-up)

- **`*external.RemoteModule.Dependencies()` now returns the yaml-level `dependsOn:` keys** instead of always returning `nil`. The v0.51.8 fix (PR #664) only reordered the `cfg.Modules` slice — but modular's `app.Init()` then runs its own `DependencyAware`-driven sort over the registered modules, and `RemoteModule` (the wrapper used for every external-plugin module) returned `nil` from `Dependencies()`, so modular saw every external-plugin module as a root and sorted alphabetically. BMW PR #280 image-launch surfaced this as the same `bmw-eventbus`/`bmw-stream` ordering race that v0.51.8 was supposed to close. Engine `BuildFromConfig` now calls `SetDependencies(modCfg.DependsOn)` on each module that **declares `dependsOn:` in yaml AND implements** `interface{ SetDependencies([]string) }` immediately after the factory returns, before `app.RegisterModule`. (Modules with empty `dependsOn:` are skipped — calling `SetDependencies(nil)` would clear any defaults a custom module set up in its constructor.) `RemoteModule` implements that setter, defensively copies the slice, and modular's Init() walker then reads it via the existing `Dependencies()` contract. 6 unit tests cover the `RemoteModule` contract (default-nil, plumb, empty-slice, overwrite, two structural type-assertion pins) plus 4 engine-level `BuildFromConfig` tests covering the production path (basic plumb + defensive copy via raw-slice recorder + back-compat skip + real-modular Init order). Built-in modules can opt in by implementing the same setter; existing behaviour is unchanged for modules that don't.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 448463c: CHANGELOG count bumped 6 → 7 and descriptor updated to mention the new defensive-copy aliasing test + clarify that one of the type-assertion pins is now against the real modular.DependencyAware (not a structural type).

Copilot R5 caught: CHANGELOG said 6 RemoteModule unit tests but the
file now contains 7 after R4 added TestRemoteModule_SetDependencies_DefensivelyCopies.
Updated the count + the descriptor to mention the new aliasing test
and reflect that one of the type-assertion pins is now against the
real modular.DependencyAware interface rather than a structural one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment thread plugin/external/remote_module.go Outdated
Comment on lines +70 to +72
// module so modular's Init() walker can honour them. Called by the engine
// from BuildFromConfig once per module, immediately after the factory
// returns and before app.RegisterModule. See workflow#663.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 212ffa0: docstring now spells out both conditions (non-empty modCfg.DependsOn AND setter satisfied) and the rationale (constructor-time defaults must not be clobbered by SetDependencies(nil)).

Copilot R6 caught: the SetDependencies docstring said the engine
calls it once per module, but BuildFromConfig only calls it when
modCfg.DependsOn is non-empty AND the module satisfies the setter
interface. Updated the docstring to spell out both conditions and
the rationale (constructor-time defaults must not be clobbered by
SetDependencies(nil)).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@intel352 intel352 requested a review from Copilot May 13, 2026 23:35
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment thread engine.go Outdated
Comment on lines +569 to +571
deps := make([]string, len(modCfg.DependsOn))
copy(deps, modCfg.DependsOn)
depTarget.SetDependencies(deps)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a096a67: extracted filterResolvableDeps helper into engine_module_order.go and call it from engine.go before each SetDependencies. The helper drops empty strings + names not in cfg.Modules, preserving input order. If everything is filtered out, SetDependencies is skipped entirely (same back-compat as a module declared with no dependsOn). 4 helper-level unit tests cover drops-empty/all-unknown/empty-input/order-preserved. Going through BuildFromConfig end-to-end isn't possible without registering a config transform hook (schema rejects the bad inputs upstream).

@codecov
Copy link
Copy Markdown

codecov Bot commented May 13, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Copilot R7 caught: the engine forwarded the raw modCfg.DependsOn
slice into SetDependencies even though topoSortModules intentionally
ignored empty strings and dependency names not present in cfg.Modules.
modular's DependencyAware sort would then see unresolvable entries —
either mis-ordering modules or panicking depending on modular version.

Schema validation rejects empty + unknown deps for declared modules,
but ConfigTransformHooks can inject modules whose dependsOn was never
schema-checked — this is the engine's defensive boundary against that.

Two-part fix:

1. engine_module_order.go: new filterResolvableDeps helper that drops
   empty strings + names not in moduleNames, preserving input order.
   Same filter as topoSortModules' internal edge construction.

2. engine.go: BuildFromConfig builds a moduleNameSet once after
   topoSortModules + uses filterResolvableDeps before each
   SetDependencies call. If the filtered slice is empty (everything
   was unresolvable or empty), the setter is skipped entirely —
   same back-compat surface as a module declared with no dependsOn.

4 unit tests covering filterResolvableDeps directly (drops-empty,
all-unknown, empty-input, order-preserved). Going through
BuildFromConfig isn't possible without registering a config
transform hook (schema rejects the bad inputs upstream); the
helper-level tests are the practical surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Comment thread CHANGELOG.md Outdated

### Fixed (issue #663 — follow-up)

- **`*external.RemoteModule.Dependencies()` now returns the yaml-level `dependsOn:` keys** instead of always returning `nil`. The v0.51.8 fix (PR #664) only reordered the `cfg.Modules` slice — but modular's `app.Init()` then runs its own `DependencyAware`-driven sort over the registered modules, and `RemoteModule` (the wrapper used for every external-plugin module) returned `nil` from `Dependencies()`, so modular saw every external-plugin module as a root and sorted alphabetically. BMW PR #280 image-launch surfaced this as the same `bmw-eventbus`/`bmw-stream` ordering race that v0.51.8 was supposed to close. Engine `BuildFromConfig` now calls `SetDependencies(modCfg.DependsOn)` on each module that **declares `dependsOn:` in yaml AND implements** `interface{ SetDependencies([]string) }` immediately after the factory returns, before `app.RegisterModule`. (Modules with empty `dependsOn:` are skipped — calling `SetDependencies(nil)` would clear any defaults a custom module set up in its constructor.) `RemoteModule` implements that setter, defensively copies the slice, and modular's Init() walker then reads it via the existing `Dependencies()` contract. 7 unit tests cover the `RemoteModule` contract (default-nil, plumb, empty-slice, overwrite, defensive-copy aliasing, plus two type-assertion pins for `modular.DependencyAware` and the engine's `SetDependencies` interface) plus 4 engine-level `BuildFromConfig` tests covering the production path (basic plumb + defensive copy via raw-slice recorder + back-compat skip + real-modular Init order). Built-in modules can opt in by implementing the same setter; existing behaviour is unchanged for modules that don't.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 78d7718: comment now reflects the post-R7 filter behaviour (calls SetDependencies only when filterResolvableDeps leaves at least one resolvable name).

Comment thread plugin/external/remote_module.go Outdated
Comment on lines +72 to +75
// app.RegisterModule, but only when the module's `modCfg.DependsOn` is
// non-empty AND the module satisfies `interface{ SetDependencies([]string) }`
// — modules with no declared dependsOn are skipped so a constructor-time
// default isn't clobbered with a SetDependencies(nil) call. See workflow#663.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 78d7718: comment now reflects the post-R7 filter behaviour (calls SetDependencies only when filterResolvableDeps leaves at least one resolvable name).

Comment on lines +50 to +53
// replaces the previous slice rather than appending. The engine calls
// SetDependencies exactly once per module per BuildFromConfig, but
// pinning the contract guards against future engine-side double-calls
// (e.g., a config-transform hook that mutates dependsOn post-registration).
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 78d7718: comment now reflects the post-R7 filter behaviour (calls SetDependencies only when filterResolvableDeps leaves at least one resolvable name).

Comment thread engine_dependson_plumbing_test.go Outdated
Comment on lines +75 to +79
// on each module that implements it, with a defensive copy of
// modCfg.DependsOn, before app.RegisterModule. Without this end-to-end
// test, a future refactor that moves the structural type assertion out
// of the registration loop (or changes the contract) would silently
// regress the BMW PR #279/280 fix and only image-launch CI would catch it.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 78d7718: comment now reflects the post-R7 filter behaviour (calls SetDependencies only when filterResolvableDeps leaves at least one resolvable name).

Copilot R8 caught 4 wording-drift sites where comments still described
the pre-R7 plumb behaviour ("calls SetDependencies on each module that
implements it") instead of the post-R7 reality ("calls only when
filterResolvableDeps leaves at least one resolvable name"):

- CHANGELOG.md: spell out filterResolvableDeps role + the
  non-empty-after-filter precondition.
- plugin/external/remote_module.go: SetDependencies docstring now
  mentions filterResolvableDeps + the no-resolvable-dep skip path.
- plugin/external/remote_module_dependencies_test.go:
  TestRemoteModule_SetDependencies_Overwrites comment says "at most
  once per BuildFromConfig" + spells out the filter precondition.
- engine_dependson_plumbing_test.go:
  TestEngine_BuildFromConfig_PlumbsDependsOnIntoModule comment narrows
  to the setter-implementing + has-resolvable-dep case.

No code changes — only doc accuracy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Comment thread engine.go
Comment on lines +582 to +587
// edge set topoSortModules used.
if depTarget, ok := mod.(interface{ SetDependencies([]string) }); ok {
filtered := filterResolvableDeps(modCfg.DependsOn, moduleNameSet)
if len(filtered) > 0 {
depTarget.SetDependencies(filtered)
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a55b13b: filterResolvableDeps now takes a selfName arg and drops self-references. topoSortModules' n==1 path explicitly checks for a self-dep and errors with the same cycle message format. 3 new tests cover the self-dep paths (drops-self-reference, only-self-returns-empty, single-module-self-dep-is-cycle); existing filter tests updated for the new signature.

…ycle

Copilot R9 caught the self-dependency edge case:

1. Schema validation does NOT reject a module declaring itself in
   dependsOn (only checks the dep name exists, which "self" trivially
   does).
2. topoSortModules' n<=1 early return short-circuited before cycle
   detection, so a single-module config with a self-dep slipped past
   too.
3. filterResolvableDeps preserved the self-name in the filtered slice
   (the name resolves to itself), and the engine handed it to modular's
   DependencyAware sort which would either treat it as a 1-cycle or
   stall.

Three-part fix:

- engine_module_order.go: filterResolvableDeps now takes a `selfName`
  parameter and drops any dep matching it. Comment explains why the
  schema gap matters.
- engine_module_order.go: topoSortModules splits the n<=1 path —
  n==0 still short-circuits, n==1 explicitly checks for a self-dep
  and returns the same cycle error format Kahn produces for n>=2.
- engine.go: passes modCfg.Name as selfName to filterResolvableDeps.

3 new unit tests:

- TestFilterResolvableDeps_DropsSelfReference — module "me" depending
  on ["me", "you"] filters to ["you"].
- TestFilterResolvableDeps_OnlySelfReferenceReturnsEmpty — module
  "me" depending only on itself returns empty (engine then skips
  SetDependencies, preserving constructor defaults).
- TestTopoSortModules_SingleModuleSelfDepIsCycle — single module
  declaring itself errors with the cycle message.
- TestTopoSortModules_SingleModuleNoSelfDepIsClean — sanity that
  n==1 without self-dep still passes through.

Existing 4 filterResolvableDeps tests updated to pass the new
selfName arg ("_test_self_" — non-matching sentinel).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Comment on lines +304 to +305
// Module declaring only itself → engine should skip SetDependencies
// entirely (filtered slice is empty).
Comment thread engine_module_order.go
Comment on lines +154 to +156
// This is the engine's defensive boundary against both: stripping the
// self-reference here means modular can't either treat it as a 1-cycle or
// stall waiting for the module to initialise itself before itself.
Comment thread CHANGELOG.md

### Fixed (issue #663 — follow-up)

- **`*external.RemoteModule.Dependencies()` now returns the yaml-level `dependsOn:` keys** instead of always returning `nil`. The v0.51.8 fix (PR #664) only reordered the `cfg.Modules` slice — but modular's `app.Init()` then runs its own `DependencyAware`-driven sort over the registered modules, and `RemoteModule` (the wrapper used for every external-plugin module) returned `nil` from `Dependencies()`, so modular saw every external-plugin module as a root and sorted alphabetically. BMW PR #280 image-launch surfaced this as the same `bmw-eventbus`/`bmw-stream` ordering race that v0.51.8 was supposed to close. Engine `BuildFromConfig` now filters `modCfg.DependsOn` through `filterResolvableDeps` (drops empty strings + names not present in `cfg.Modules` — the same edge-set topoSortModules used for ordering) and calls `SetDependencies(filtered)` on each module that **implements** `interface{ SetDependencies([]string) }`, but **only when the filtered slice is non-empty**, immediately after the factory returns and before `app.RegisterModule`. (Modules with no resolvable dependsOn — empty yaml + transform-injected modules whose dependsOn is all empty/ghost — are skipped, so a constructor-time default isn't clobbered with `SetDependencies(nil)`.) `RemoteModule` implements that setter, defensively copies the slice, and modular's Init() walker then reads it via the existing `Dependencies()` contract. 7 unit tests cover the `RemoteModule` contract (default-nil, plumb, empty-slice, overwrite, defensive-copy aliasing, plus two type-assertion pins for `modular.DependencyAware` and the engine's `SetDependencies` interface) plus 4 engine-level `BuildFromConfig` tests covering the production path (basic plumb + defensive copy via raw-slice recorder + back-compat skip + real-modular Init order). Built-in modules can opt in by implementing the same setter; existing behaviour is unchanged for modules that don't.
@intel352 intel352 merged commit 6428aa3 into main May 14, 2026
29 checks passed
@intel352 intel352 deleted the fix/663-plumb-dependsOn branch May 14, 2026 00:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants