Skip to content

fix(evo-flow): reuse primary app context in action-node activities (EVO-1829)#75

Merged
dpaes merged 1 commit into
developfrom
fix/EVO-1829
Jun 18, 2026
Merged

fix(evo-flow): reuse primary app context in action-node activities (EVO-1829)#75
dpaes merged 1 commit into
developfrom
fix/EVO-1829

Conversation

@nickoliveira23

@nickoliveira23 nickoliveira23 commented Jun 18, 2026

Copy link
Copy Markdown

Summary

Journey/campaign action nodes (Add Label, Remove Label, Update Custom Attribute, Transfer Journey) + campaign-execution activities run outside the Nest DI container. Each one bootstrapped a full second AppModule via NestFactory.createApplicationContext(AppModule.forRoot()) just to resolve a DI service. In single-mode that boots a redundant Temporal worker + Kafka consumers + ScheduleModule jobs in the same process — they collide and the app silently freezes the first time an action node runs per boot (HTTP server dies, session stuck active on the node). Same family as EVO-1737, different path.

Fix (chosen via a design evaluation of 3 approaches): stash the primary application context at boot in a tiny module-level holder, and resolve services from it via getAppContext(). No second AppModule is ever booted. The primary context already provides every service these activities need and is guaranteed to exist before any activity dispatch.

Changes

  • src/shared/app-context.holder.ts (new): setAppContext/getAppContext (type-only import; fail-fast throw, never a silent hang).
  • src/main.ts: setAppContext(app) right after NestFactory.create() (before init/worker creation in all RUN_MODEs).
  • 4 action nodes + campaign-execution.activities.ts: resolve DI via getAppContext(); drop the per-node createApplicationContext(AppModule.forRoot()) bootstrap + dead imports.
  • no-app-bootstrap.guard.spec.ts (new): regression guard forbidding the bootstrap pattern under activities/.
  • Out of scope (documented): journey-tracking.activities.ts new KafkaService() (deliberate producer-only force-init).

Validation

  • evo-flow: npm run build → clean
  • evo-flow: npx jest (guard + campaign + add/remove-label + update-custom-attribute + conditional + split) → 64/64
  • evo-flow: npx prettier --check → new files + main + campaign spec clean (the 4 node files + campaign activity had pre-existing prettier drift on develop, intentionally not reformatted to keep the diff focused)
  • Live smoke (RUN_MODE=single, fix branch): a journey with an Add Label node — which froze the app before — now runs journey-trigger → add-label (started → completed) → exit, session completed, label applied, app stays up, and no second AppModule bootstrap appears in the logs.
  • Adversarial code review (multi-agent): 1 LOW (spec comment) fixed; the RLS/tenant and test-coverage concerns were verified as pre-existing / out-of-scope / inert in the single-tenant community build.

Acceptance Criteria

  • A journey with an action node runs in single-mode without freezing.
  • Action nodes still resolve their DI services and execute correctly (label applied live).
  • No second worker/consumer/scheduled-job started by an action node's DI bootstrap.
  • A/B repro: action-node journey completes without taking the process down.

Changed Files

  • src/shared/app-context.holder.ts (new)
  • src/main.ts
  • src/modules/temporal/activities/nodes/{add-label,remove-label,update-custom-attribute,transfer-journey}.node.ts
  • src/modules/temporal/activities/campaign-execution.activities.ts
  • src/modules/temporal/activities/campaign-execution.activities.spec.ts
  • src/modules/temporal/activities/no-app-bootstrap.guard.spec.ts (new)

Linked Issue

  • EVO-1829

🤖 Generated with Claude Code

Summary by Sourcery

Reuse the primary Nest application context for Temporal campaign and action-node activities instead of bootstrapping a second AppModule, preventing single-mode freezes and redundant workers.

Bug Fixes:

  • Ensure campaign-execution and action-node activities resolve dependencies from the primary Nest app context, avoiding secondary AppModule bootstraps that froze the app in single-mode.

Enhancements:

  • Introduce a shared app-context holder to stash and retrieve the primary Nest application context for out-of-container activities.
  • Update label, custom-attribute, and journey-transfer node activities to use the shared app context for dependency resolution instead of local NestFactory application contexts.

Tests:

  • Adjust campaign-execution activity tests to mock the new app-context holder and add a regression test guarding against reintroducing in-activity Nest bootstrap patterns.

…VO-1829)

Journey/campaign action-node activities run outside the Nest DI container and
each bootstrapped a full second AppModule via
createApplicationContext(AppModule.forRoot()) to resolve services. In
single-mode that boots a redundant Temporal worker + Kafka consumers + scheduled
jobs in the same process and silently freezes the app on the first action node.

Stash the primary application context at boot (app-context.holder) and resolve
services from it via getAppContext() in the 4 journey action nodes
(add-label, remove-label, update-custom-attribute, transfer-journey) and
campaign-execution activities — no second AppModule is ever booted. Adds a
regression guard spec forbidding the bootstrap pattern under activities/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Reviewer's Guide

Reuses the primary Nest application context for Temporal action-node and campaign-execution activities via a shared holder set at bootstrap, removing per-activity AppModule bootstraps and adding a regression test guard against reintroducing that pattern.

Sequence diagram for reusing the primary Nest app context in Temporal activities

sequenceDiagram
    participant Bootstrap as main_ts_bootstrap
    participant NestFactory as NestFactory
    participant AppContext as INestApplicationContext
    participant Holder as app_context_holder
    participant AddLabelNode as AddLabelNode
    participant LabelsService as LabelsService

    Bootstrap->>NestFactory: create()
    NestFactory-->>Bootstrap: INestApplicationContext
    Bootstrap->>Holder: setAppContext(AppContext)

    AddLabelNode->>AddLabelNode: getServices()
    AddLabelNode->>Holder: getAppContext()
    Holder-->>AddLabelNode: AppContext
    AddLabelNode->>AppContext: get(LabelsService)
    AppContext-->>AddLabelNode: LabelsService
    AddLabelNode->>LabelsService: addLabel(...)
Loading

File-Level Changes

Change Details Files
Introduce a process-wide holder for the primary Nest application context and initialize it at bootstrap so out-of-container activities can resolve DI services without bootstrapping a second app.
  • Add a shared app-context holder module that stores the primary Nest INestApplicationContext with setter/getter and a fail-fast error if uninitialized.
  • In main bootstrap, call the holder setter immediately after NestFactory.create() to stash the primary app context before any workers/consumers are started.
src/shared/app-context.holder.ts
src/main.ts
Refactor Temporal action-node and campaign-execution activities to resolve dependencies from the held primary context instead of creating their own Nest application contexts.
  • Replace per-node NestFactory.createApplicationContext(AppModule.forRoot()) logic with calls to the shared getAppContext(), keeping lazy resolution of services but reusing the process-wide context.
  • Remove now-unused appContext fields and dynamic imports of NestFactory/AppModule from action node classes.
  • Update campaign-execution activity helper getAppContext() to delegate to the shared holder instead of caching its own created context.
src/modules/temporal/activities/nodes/add-label.node.ts
src/modules/temporal/activities/nodes/remove-label.node.ts
src/modules/temporal/activities/nodes/update-custom-attribute.node.ts
src/modules/temporal/activities/nodes/transfer-journey.node.ts
src/modules/temporal/activities/campaign-execution.activities.ts
Adapt and strengthen tests to align with the new context-holder pattern and prevent regressions that reintroduce per-activity app bootstraps.
  • Update campaign-execution activity tests to mock the app-context holder instead of NestFactory/AppModule, asserting broker resolution via the mocked context getter.
  • Add a regression guard test that scans activities to ensure no NestFactory.createApplicationContext(AppModule.forRoot())-style app bootstrapping is reintroduced under activities/.
  • Keep existing test suites green and aligned with the new DI resolution mechanism.
src/modules/temporal/activities/campaign-execution.activities.spec.ts
src/modules/temporal/activities/no-app-bootstrap.guard.spec.ts

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 1 issue, and left some high level feedback:

  • The new app-context.holder relies on a mutable module-level singleton; consider making setAppContext defensive (e.g., throw or warn if called more than once) to surface accidental double-bootstraps or reuse across tests early.
  • In the node classes (AddLabelNode, RemoveLabelNode, UpdateCustomAttributeNode, TransferJourneyNode), the service fields are still typed as any; now that they resolve directly from the main DI context, you can tighten these to their concrete service types to catch wiring issues at compile time.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new `app-context.holder` relies on a mutable module-level singleton; consider making `setAppContext` defensive (e.g., throw or warn if called more than once) to surface accidental double-bootstraps or reuse across tests early.
- In the node classes (`AddLabelNode`, `RemoveLabelNode`, `UpdateCustomAttributeNode`, `TransferJourneyNode`), the service fields are still typed as `any`; now that they resolve directly from the main DI context, you can tighten these to their concrete service types to catch wiring issues at compile time.

## Individual Comments

### Comment 1
<location path="src/shared/app-context.holder.ts" line_range="20-22" />
<code_context>
+ */
+let appContext: INestApplicationContext | null = null;
+
+export function setAppContext(context: INestApplicationContext): void {
+  appContext = context;
+}
+
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Consider guarding against multiple calls to `setAppContext` to detect unintended re-bootstrap/overwrites.

As written, a second Nest bootstrap calling `setAppContext` will silently replace the existing context, potentially masking bugs by swapping the DI graph at runtime. Consider guarding against this by throwing or at least logging when `appContext` is already set, e.g.:

```ts
if (appContext && appContext !== context) {
  throw new Error('App context already initialized');
}
appContext = context;
```

```suggestion
export function setAppContext(context: INestApplicationContext): void {
  if (appContext && appContext !== context) {
    throw new Error('App context already initialized');
  }

  appContext = context;
}
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +20 to +22
export function setAppContext(context: INestApplicationContext): void {
appContext = context;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (bug_risk): Consider guarding against multiple calls to setAppContext to detect unintended re-bootstrap/overwrites.

As written, a second Nest bootstrap calling setAppContext will silently replace the existing context, potentially masking bugs by swapping the DI graph at runtime. Consider guarding against this by throwing or at least logging when appContext is already set, e.g.:

if (appContext && appContext !== context) {
  throw new Error('App context already initialized');
}
appContext = context;
Suggested change
export function setAppContext(context: INestApplicationContext): void {
appContext = context;
}
export function setAppContext(context: INestApplicationContext): void {
if (appContext && appContext !== context) {
throw new Error('App context already initialized');
}
appContext = context;
}

@dpaes dpaes left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Approved — EVO-1829

Clean, well-scoped fix. The redundant per-node createApplicationContext(AppModule.forRoot()) bootstrap is fully removed; action-node and campaign-execution activities now resolve DI from the primary context stashed at boot (app-context.holder + setAppContext in main.ts), and a recursive guard spec forbids the pattern from returning.

Acceptance criteria

  • AC1 (no freeze): ✅ root cause (second bootstrap) removed; live smoke confirms trigger → add-label → exit with the app staying up.
  • AC2 (DI still resolves): ✅ both old and new code build from the same AppModule.forRoot() (no args, deterministic per process). Labels/Contacts/CustomAttributes/Journeys/Broker/Audience/Campaigns are all in baseImports (loaded in every RUN_MODE), so the primary context exposes exactly the providers the second bootstrap did — including in split/worker mode.
  • AC3 (no 2nd worker/consumer/job): ✅ no createApplicationContext/NestFactory remains in the temporal tree; guard spec blocks regressions.
  • AC4 (visible failure, no process death): ✅ worst case is now a clear thrown error from getAppContext() → Temporal retry, not a silent freeze.

Adversarial checks (all pass)

  • Single entrypoint: every mode (incl. start:proddist/main) runs bootstrap()setAppContext; no alternate worker entrypoint, so split/prod also populate the holder.
  • Timing: setAppContext runs before app.listen()/app.init() triggers the worker's onModuleInit, so the holder is always populated before any activity dispatch.
  • Node specs: mock getServices directly and never touch the holder → unaffected; only campaign-execution.activities.spec.ts needed updating, and it correctly mocks the holder now.
  • No orphan import; transfer-journey.node.ts is clean.
  • Out-of-scope journey-tracking new KafkaService() (producer-only) correctly left untouched and documented in the holder.

Non-blocking

  • getAppContext() in campaign-execution.activities.ts is now a trivial async passthrough — could be inlined/sync. Cosmetic.
  • evo-flow CI is Sourcery-only (no jest/tsc/build gate), so build / jest 64/64 / prettier are self-reported — worth a local npx jest confirmation.

@dpaes dpaes merged commit 8b5ab60 into develop Jun 18, 2026
3 checks passed
@dpaes dpaes deleted the fix/EVO-1829 branch June 18, 2026 22:07
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