Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ import { DataSource } from 'typeorm';
import { AppModule } from './app.module';
import { CustomLoggerService } from './common/services/custom-logger.service';
import { StructuredLoggerService } from './shared/logger/structured-logger.service';
import { setAppContext } from './shared/app-context.holder';
import { loadExternalExtensions } from './evo-extension-points';
import axios from 'axios';
import { json, raw, urlencoded } from 'express';
Expand Down Expand Up @@ -147,6 +148,10 @@ async function bootstrap() {
bodyParser: false,
});

// EVO-1829: stash the primary context so Temporal action-node activities reuse
// it instead of bootstrapping a second AppModule (which freezes single-mode).
setAppContext(app);

// Route framework + injected logs through the JSON structured logger so every
// record carries correlationId/service/level/timestamp (FR38, NFR32).
app.useLogger(app.get(StructuredLoggerService));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { NestFactory } from '@nestjs/core';
import { publishCampaignsPack } from './campaign-execution.activities';
import { IMESSAGE_BROKER } from '../../../shared/broker/interfaces/message-broker.interface';
import {
Expand All @@ -7,11 +6,12 @@ import {
isCampaignsPackContract,
} from '../../../shared/broker/contracts/campaigns-pack.contract';

jest.mock('@nestjs/core');
// Stub the app module so booting the activity's Nest context never pulls the
// real application graph (DB, brokers) into the unit test.
jest.mock('../../../app.module', () => ({
AppModule: { forRoot: () => ({}) },
// EVO-1829: the activity resolves services from the primary app context held in
// app-context.holder (no second AppModule bootstrap). Mock the holder so the
// unit test never pulls the real application graph (DB, brokers) in.
const mockAppGet = jest.fn();
jest.mock('../../../shared/app-context.holder', () => ({
getAppContext: () => ({ get: mockAppGet }),
}));
jest.mock('@temporalio/activity', () => ({
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() },
Expand All @@ -21,17 +21,14 @@ const CORRELATION_ID = '11111111-1111-4111-8111-111111111111';
const CAMPAIGN_ID = 'camp-1';

describe('publishCampaignsPack activity', () => {
// Stable mock references: the activity caches its Nest context as a module
// singleton, so the broker resolved on the first call is reused. Reconfigure
// behaviour per test on the same `publish` fn rather than swapping it out.
// The broker is resolved via the reused mockAppGet/publish jest fns: the mock
// holder returns a fresh context object each call (not a singleton), while the
// production holder IS a genuine singleton. Reconfigure per test on `publish`.
const publish = jest.fn();
const appGet = jest.fn().mockReturnValue({ publish });

beforeEach(() => {
publish.mockReset().mockResolvedValue(undefined);
(NestFactory.createApplicationContext as jest.Mock).mockResolvedValue({
get: appGet,
});
mockAppGet.mockReset().mockReturnValue({ publish });
});

it('publishes one schema-valid campaigns.pack message resolved via the broker token', async () => {
Expand All @@ -40,7 +37,7 @@ describe('publishCampaignsPack activity', () => {
correlationId: CORRELATION_ID,
});

expect(appGet).toHaveBeenCalledWith(IMESSAGE_BROKER);
expect(mockAppGet).toHaveBeenCalledWith(IMESSAGE_BROKER);
expect(publish).toHaveBeenCalledTimes(1);

const [topic, payload] = publish.mock.calls[0] as [
Expand Down
14 changes: 4 additions & 10 deletions src/modules/temporal/activities/campaign-execution.activities.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { log } from '@temporalio/activity';
import { NestFactory } from '@nestjs/core';
import type { INestApplicationContext } from '@nestjs/common';
import { AppModule } from '../../../app.module';
import { getAppContext as getHeldContext } from '../../../shared/app-context.holder';
import { AudienceComputationService } from '../../../shared/audience/audience-computation.service';
import { CampaignsService } from '../../campaigns/services/campaigns.service';
import { Campaign } from '../../campaigns/entities/campaign.entity';
Expand All @@ -17,15 +16,10 @@ import {
CampaignsPackContract,
} from '../../../shared/broker/contracts/campaigns-pack.contract';

let appContext: any = null;

// EVO-1829: reuse the primary app context (stashed at boot) instead of
// bootstrapping a second AppModule, which freezes single-mode.
async function getAppContext() {
if (!appContext) {
appContext = await NestFactory.createApplicationContext(AppModule.forRoot(), {
logger: false,
});
}
return appContext;
return getHeldContext();
}

// ==================== Input/Output Interfaces ====================
Expand Down
27 changes: 27 additions & 0 deletions src/modules/temporal/activities/no-app-bootstrap.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { readdirSync, readFileSync, statSync } from 'fs';
import { join } from 'path';

// EVO-1829 regression guard: Temporal activities must NOT bootstrap a second
// Nest app via createApplicationContext(AppModule.forRoot()). That boots a
// redundant worker + Kafka consumers in-process and silently freezes
// single-mode. Activities resolve DI from the primary context via
// app-context.holder (getAppContext) instead.
describe('EVO-1829: activities never bootstrap a second AppModule', () => {
const ACTIVITIES_DIR = __dirname;

function walk(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const full = join(dir, name);
if (statSync(full).isDirectory()) return walk(full);
return full.endsWith('.ts') && !full.endsWith('.spec.ts') ? [full] : [];
});
}

it('no activity file calls createApplicationContext(AppModule.forRoot())', () => {
const pattern = /createApplicationContext\s*\(\s*AppModule\.forRoot\(/;
const offenders = walk(ACTIVITIES_DIR).filter((file) =>
pattern.test(readFileSync(file, 'utf8')),
);
expect(offenders).toEqual([]);
});
});
18 changes: 4 additions & 14 deletions src/modules/temporal/activities/nodes/add-label.node.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BaseNode, NodeExecutionResult } from './base.node';
import { getAppContext } from '../../../../shared/app-context.holder';

export interface AddLabelNodeInput {
nodeId: string;
Expand All @@ -15,33 +16,22 @@ export interface AddLabelNodeInput {
export class AddLabelNode extends BaseNode {
private labelsService: any = null;
private contactsService: any = null;
private appContext: any = null;

constructor() {
super('AddLabel');
}

private async getServices() {
if (!this.appContext) {
const { NestFactory } = await import('@nestjs/core');
const { AppModule } = await import('../../../../app.module');

this.appContext = await NestFactory.createApplicationContext(
AppModule.forRoot(),
{
logger: false,
},
);
}
const appContext = getAppContext();

if (!this.labelsService) {
const { LabelsService } = await import('../../../labels/labels.service');
this.labelsService = this.appContext.get(LabelsService);
this.labelsService = appContext.get(LabelsService);
}

if (!this.contactsService) {
const { ContactsService } = await import('../../../contacts/contacts.service');
this.contactsService = this.appContext.get(ContactsService);
this.contactsService = appContext.get(ContactsService);
}

return {
Expand Down
18 changes: 4 additions & 14 deletions src/modules/temporal/activities/nodes/remove-label.node.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BaseNode, NodeExecutionResult } from './base.node';
import { getAppContext } from '../../../../shared/app-context.holder';

export interface RemoveLabelNodeInput {
nodeId: string;
Expand All @@ -15,33 +16,22 @@ export interface RemoveLabelNodeInput {
export class RemoveLabelNode extends BaseNode {
private labelsService: any = null;
private contactsService: any = null;
private appContext: any = null;

constructor() {
super('RemoveLabel');
}

private async getServices() {
if (!this.appContext) {
const { NestFactory } = await import('@nestjs/core');
const { AppModule } = await import('../../../../app.module');

this.appContext = await NestFactory.createApplicationContext(
AppModule.forRoot(),
{
logger: false,
},
);
}
const appContext = getAppContext();

if (!this.labelsService) {
const { LabelsService } = await import('../../../labels/labels.service');
this.labelsService = this.appContext.get(LabelsService);
this.labelsService = appContext.get(LabelsService);
}

if (!this.contactsService) {
const { ContactsService } = await import('../../../contacts/contacts.service');
this.contactsService = this.appContext.get(ContactsService);
this.contactsService = appContext.get(ContactsService);
}

return {
Expand Down
22 changes: 5 additions & 17 deletions src/modules/temporal/activities/nodes/transfer-journey.node.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BaseNode, NodeExecutionResult } from './base.node';
import { getAppContext } from '../../../../shared/app-context.holder';

export interface TransferJourneyNodeInput {
nodeId: string;
Expand All @@ -15,30 +16,19 @@ export interface TransferJourneyNodeInput {

export class TransferJourneyNode extends BaseNode {
private journeysService: any = null;
private appContext: any = null;

constructor() {
super('TransferJourney');
}

private async getServices() {
if (!this.appContext) {
const { NestFactory } = await import('@nestjs/core');
const { AppModule } = await import('../../../../app.module');

this.appContext = await NestFactory.createApplicationContext(
AppModule.forRoot(),
{
logger: false,
},
);
}

const appContext = getAppContext();

if (!this.journeysService) {
const { JourneysService } = await import('../../../journeys/journeys.service');
this.journeysService = this.appContext.get(JourneysService);
this.journeysService = appContext.get(JourneysService);
}

return {
journeysService: this.journeysService
};
Expand All @@ -56,8 +46,6 @@ export class TransferJourneyNode extends BaseNode {
'../../workflows/journey-execution.workflow'
);
const { randomUUID } = await import('crypto');
const { NestFactory } = await import('@nestjs/core');
const { AppModule } = await import('../../../../app.module');

// Get services using lazy initialization
const { journeysService } = await this.getServices();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BaseNode, NodeExecutionResult } from './base.node';
import { getAppContext } from '../../../../shared/app-context.holder';

export interface UpdateCustomAttributeNodeInput {
nodeId: string;
Expand All @@ -16,33 +17,22 @@ export interface UpdateCustomAttributeNodeInput {
export class UpdateCustomAttributeNode extends BaseNode {
private customAttributesService: any = null;
private contactsService: any = null;
private appContext: any = null;

constructor() {
super('UpdateCustomAttribute');
}

private async getServices() {
if (!this.appContext) {
const { NestFactory } = await import('@nestjs/core');
const { AppModule } = await import('../../../../app.module');

this.appContext = await NestFactory.createApplicationContext(
AppModule.forRoot(),
{
logger: false,
},
);
}
const appContext = getAppContext();

if (!this.customAttributesService) {
const { CustomAttributesService } = await import('../../../custom-attributes/custom-attributes.service');
this.customAttributesService = this.appContext.get(CustomAttributesService);
this.customAttributesService = appContext.get(CustomAttributesService);
}

if (!this.contactsService) {
const { ContactsService } = await import('../../../contacts/contacts.service');
this.contactsService = this.appContext.get(ContactsService);
this.contactsService = appContext.get(ContactsService);
}

return {
Expand Down
31 changes: 31 additions & 0 deletions src/shared/app-context.holder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { INestApplicationContext } from '@nestjs/common';

/**
* Primary Nest application context, stashed at boot (main.ts) so Temporal
* activities can resolve DI services WITHOUT bootstrapping a second AppModule.
*
* EVO-1829: action-node activities run outside the Nest container. Each used to
* call `NestFactory.createApplicationContext(AppModule.forRoot())`, which boots
* a full second app (Kafka consumers, a redundant Temporal worker, scheduled
* jobs) in-process and silently freezes single-mode. The primary context built
* by `NestFactory.create()` already provides every service those activities
* need and is guaranteed to exist before any activity dispatch, so they reuse
* it from here.
*
* Out of scope: `journey-tracking.activities.ts` deliberately does
* `new KafkaService()` (a producer-only force-init) — do NOT route it here.
*/
let appContext: INestApplicationContext | null = null;

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

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;
}


export function getAppContext(): INestApplicationContext {
if (!appContext) {
throw new Error(
'App context not initialized: setAppContext() must run at boot before any Temporal activity',
);
}
return appContext;
}
Loading