Skip to content

Commit 05b0799

Browse files
authored
Merge pull request #1675 from kodustech/fix/query-perf-and-indexes
fix(pool,queries,locks): pool exhaustion mitigations from the 2026-08 incident audit
2 parents 2ce48d9 + 09527a0 commit 05b0799

24 files changed

Lines changed: 768 additions & 232 deletions

File tree

apps/worker/src/cron/analytics-classifier.cron.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,26 @@ import { Injectable, Logger } from '@nestjs/common';
22
import { Cron, CronExpression } from '@nestjs/schedule';
33

44
import { PullRequestClassifierService } from '@libs/ee/analytics-warehouse';
5+
import { DistributedLockService } from '@libs/core/workflow/infrastructure/distributed-lock.service';
6+
7+
const LOCK_KEY = 'CRON:ANALYTICS_CLASSIFIER';
8+
// 25min TTL for a 30min-tick cron. Auto-clears on crashes without
9+
// letting a runaway hold the slot into the next tick.
10+
const LOCK_TTL_MS = 25 * 60 * 1000;
511

612
/**
713
* Cron wrapper that drives `PullRequestClassifierService` on a schedule.
814
* Classifies unclassified PRs via LLM and fills `analytics.pull_request_types`
915
* so bug-ratio and other "by type" aggregates have ground truth.
1016
*
1117
* Tunable via `ANALYTICS_CLASSIFIER_CRON` (standard cron expression).
12-
* Default = every 15 minutes. Disable with `ANALYTICS_CLASSIFIER_DISABLED=true`.
18+
* Default = every 30 minutes. Disable with `ANALYTICS_CLASSIFIER_DISABLED=true`.
1319
*
14-
* Concurrency: same in-memory mutex trick as `AnalyticsIngestionCron`.
15-
* Two ticks overlapping would mostly be wasted work (both picking the
16-
* same unclassified rows) — the upserts are idempotent but the LLM
17-
* cost would double.
20+
* Concurrency: 15 worker replicas in prod. Without a distributed lock
21+
* every replica would call the LLM on the same unclassified rows every
22+
* 30min — the upserts are idempotent so the DB stays correct, but the
23+
* OpenAI spend multiplies by 15. Local `running` mutex still catches
24+
* same-node reentry.
1825
*/
1926
@Injectable()
2027
export class AnalyticsClassifierCron {
@@ -23,6 +30,7 @@ export class AnalyticsClassifierCron {
2330

2431
constructor(
2532
private readonly classifier: PullRequestClassifierService,
33+
private readonly distributedLockService: DistributedLockService,
2634
) {}
2735

2836
// `||` so that docker-compose's `${VAR:-}` empty-string fallthrough
@@ -43,6 +51,22 @@ export class AnalyticsClassifierCron {
4351
return;
4452
}
4553

54+
const lock = await this.distributedLockService
55+
.acquire(LOCK_KEY, { ttl: LOCK_TTL_MS })
56+
.catch((err: unknown) => {
57+
this.logger.warn(
58+
`analytics classifier lock acquire threw: ${err instanceof Error ? err.message : String(err)}`,
59+
);
60+
return null;
61+
});
62+
63+
if (!lock) {
64+
this.logger.log(
65+
'skipping analytics classifier — another replica holds the lock',
66+
);
67+
return;
68+
}
69+
4670
this.running = true;
4771
const start = Date.now();
4872
try {
@@ -59,6 +83,11 @@ export class AnalyticsClassifierCron {
5983
);
6084
} finally {
6185
this.running = false;
86+
await lock.release().catch((err: unknown) => {
87+
this.logger.warn(
88+
`analytics classifier lock release failed: ${err instanceof Error ? err.message : String(err)}`,
89+
);
90+
});
6291
}
6392
}
6493
}

apps/worker/src/cron/analytics-ingestion.cron.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,28 @@ import {
66
PullRequestIngestionService,
77
ReviewOperationalIngestionService,
88
} from '@libs/ee/analytics-warehouse';
9+
import { DistributedLockService } from '@libs/core/workflow/infrastructure/distributed-lock.service';
10+
11+
const LOCK_KEY = 'CRON:ANALYTICS_INGESTION';
12+
// 25min — the run typically takes 5–15min; the tick is every 30min so
13+
// this covers the slowest observed pass while still auto-clearing if
14+
// a worker crashes mid-run.
15+
const LOCK_TTL_MS = 25 * 60 * 1000;
916

1017
/**
1118
* Cron wrapper that drives cockpit warehouse ingestion on a schedule.
1219
* Interval is tunable via `ANALYTICS_INGESTION_CRON` (standard cron
13-
* expression). Default = every 15 minutes.
20+
* expression). Default = every 30 minutes.
1421
*
15-
* Concurrency: a second instance landing while one is still running
16-
* would cause transaction contention, not correctness issues — UPSERTs
17-
* and the per-PR DELETE/INSERT children run inside a single tx per
18-
* batch, and the watermark is idempotent. We keep a local in-memory
19-
* guard as a cheap mutex so we don't stack up runs on a single node.
22+
* Concurrency: the worker deployment runs 15 replicas in prod
23+
* (`worker_desired_count = 15`), so an in-process `running` mutex is
24+
* not enough — without a distributed lock every replica would run the
25+
* warehouse ingestion (LLM calls + DELETE/INSERT children in tx per
26+
* batch) simultaneously every 30min. That produces 15× the OpenAI
27+
* spend on classification + burns warehouse write capacity for no
28+
* benefit. The local `running` flag is still kept as a cheap short-
29+
* circuit against reentry on the same node (e.g. the boot spawn
30+
* overlapping the first cron tick).
2031
*/
2132
@Injectable()
2233
export class AnalyticsIngestionCron implements OnApplicationBootstrap {
@@ -27,6 +38,7 @@ export class AnalyticsIngestionCron implements OnApplicationBootstrap {
2738
private readonly ingestion: PullRequestIngestionService,
2839
private readonly feedbackIngestion: FeedbackIngestionService,
2940
private readonly reviewOperationalIngestion: ReviewOperationalIngestionService,
41+
private readonly distributedLockService: DistributedLockService,
3042
) {}
3143

3244
onApplicationBootstrap(): void {
@@ -66,6 +78,23 @@ export class AnalyticsIngestionCron implements OnApplicationBootstrap {
6678
return;
6779
}
6880

81+
// Cross-replica gate: only one worker across the fleet runs.
82+
const lock = await this.distributedLockService
83+
.acquire(LOCK_KEY, { ttl: LOCK_TTL_MS })
84+
.catch((err: unknown) => {
85+
this.logger.warn(
86+
`analytics ingestion (${trigger}) lock acquire threw: ${err instanceof Error ? err.message : String(err)}`,
87+
);
88+
return null;
89+
});
90+
91+
if (!lock) {
92+
this.logger.log(
93+
`skipping analytics ingestion (${trigger}) — another replica holds the lock`,
94+
);
95+
return;
96+
}
97+
6998
this.running = true;
7099
const start = Date.now();
71100
try {
@@ -109,6 +138,11 @@ export class AnalyticsIngestionCron implements OnApplicationBootstrap {
109138
}
110139
} finally {
111140
this.running = false;
141+
await lock.release().catch((err: unknown) => {
142+
this.logger.warn(
143+
`analytics ingestion lock release failed: ${err instanceof Error ? err.message : String(err)}`,
144+
);
145+
});
112146
}
113147
}
114148
}

libs/centralized-config/infrastructure/adapters/listeners/centralized-config-sync.listener.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
ICentralizedConfigService,
88
} from '@libs/centralized-config/domain/contracts/CentralizedConfigService.contract';
99
import { PullRequestClosedEvent } from '@libs/core/domain/events/pull-request-closed.event';
10+
import { DistributedLockService } from '@libs/core/workflow/infrastructure/distributed-lock.service';
1011
import { CentralizedConfigSyncListener } from './centralized-config-sync.listener';
1112

1213
describe('CentralizedConfigSyncListener', () => {
@@ -34,10 +35,19 @@ describe('CentralizedConfigSyncListener', () => {
3435
removeStaleKodyRules: jest.fn(),
3536
};
3637

38+
// Default: lock acquired (sync runs). Individual tests can override
39+
// by calling `distributedLockServiceMock.acquire.mockResolvedValueOnce(null)`.
40+
const distributedLockServiceMock = {
41+
acquire: jest.fn(),
42+
};
43+
3744
beforeEach(async () => {
3845
centralizedConfigSyncUseCaseMock.execute.mockReset();
3946
centralizedConfigPrServiceMock.handleTrackedPullRequestClose.mockReset();
4047
jest.clearAllMocks();
48+
distributedLockServiceMock.acquire.mockResolvedValue({
49+
release: jest.fn().mockResolvedValue(undefined),
50+
});
4151

4252
const module: TestingModule = await Test.createTestingModule({
4353
providers: [
@@ -54,6 +64,10 @@ describe('CentralizedConfigSyncListener', () => {
5464
provide: CENTRALIZED_CONFIG_SERVICE_TOKEN,
5565
useValue: centralizedConfigServiceMock,
5666
},
67+
{
68+
provide: DistributedLockService,
69+
useValue: distributedLockServiceMock,
70+
},
5771
],
5872
}).compile();
5973

libs/centralized-config/infrastructure/adapters/listeners/centralized-config-sync.listener.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,15 @@ import {
66
ICentralizedConfigService,
77
} from '@libs/centralized-config/domain/contracts/CentralizedConfigService.contract';
88
import { PullRequestClosedEvent } from '@libs/core/domain/events/pull-request-closed.event';
9+
import { DistributedLockService } from '@libs/core/workflow/infrastructure/distributed-lock.service';
910
import { Inject, Injectable } from '@nestjs/common';
1011
import { OnEvent } from '@nestjs/event-emitter';
1112

13+
// Sync involves ~15 GitHub calls + ~45 pg queries for a monorepo with
14+
// centralized config enabled. 5min covers the largest observed run;
15+
// TTL exists so a crashed handler doesn't leave the lock stuck.
16+
const CENTRALIZED_SYNC_LOCK_TTL_MS = 5 * 60 * 1000;
17+
1218
@Injectable()
1319
export class CentralizedConfigSyncListener {
1420
private readonly logger = createLogger(CentralizedConfigSyncListener.name);
@@ -18,6 +24,7 @@ export class CentralizedConfigSyncListener {
1824
private readonly centralizedConfigPrService: CentralizedConfigPrService,
1925
@Inject(CENTRALIZED_CONFIG_SERVICE_TOKEN)
2026
private readonly centralizedConfigService: ICentralizedConfigService,
27+
private readonly distributedLockService: DistributedLockService,
2128
) {}
2229

2330
@OnEvent('pull-request.closed')
@@ -88,9 +95,38 @@ export class CentralizedConfigSyncListener {
8895
return;
8996
}
9097

91-
await this.centralizedConfigSyncUseCase.execute({
92-
organizationAndTeamData: event.organizationAndTeamData,
93-
repository: event.repository,
98+
// Cross-process idempotency via pg_try_advisory_lock. The
99+
// pull-request.closed event reaches every process hosting this
100+
// listener (API + worker, plus CrossProcessEventsBridge re-emit)
101+
// — without a shared claim, each replica runs the ~45 pg + 15
102+
// GitHub call sync pipeline for the same merge. First acquirer
103+
// wins; the others get null and skip. Same pattern used by
104+
// KodyRulesSyncListener.
105+
const lockKey = `CENTRALIZED_CONFIG:SYNC:${event.organizationAndTeamData?.organizationId}:${event.repository.id}:${event.pullRequestNumber}`;
106+
const lock = await this.distributedLockService.acquire(lockKey, {
107+
ttl: CENTRALIZED_SYNC_LOCK_TTL_MS,
94108
});
109+
110+
if (!lock) {
111+
this.logger.log({
112+
message:
113+
'Centralized sync already claimed by another process for this merge — skipping duplicate run',
114+
context: CentralizedConfigSyncListener.name,
115+
metadata: {
116+
lockKey,
117+
pullRequestNumber: event.pullRequestNumber,
118+
},
119+
});
120+
return;
121+
}
122+
123+
try {
124+
await this.centralizedConfigSyncUseCase.execute({
125+
organizationAndTeamData: event.organizationAndTeamData,
126+
repository: event.repository,
127+
});
128+
} finally {
129+
await lock.release();
130+
}
95131
}
96132
}

libs/core/context-resolution/infrastructure/adapters/services/context-resolution.service.ts

Lines changed: 21 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,6 @@ import {
77
IIntegrationConfigService,
88
INTEGRATION_CONFIG_SERVICE_TOKEN,
99
} from '@libs/integrations/domain/integrationConfigs/contracts/integration-config.service.contracts';
10-
import {
11-
IIntegrationService,
12-
INTEGRATION_SERVICE_TOKEN,
13-
} from '@libs/integrations/domain/integrations/contracts/integration.service.contracts';
1410
import {
1511
IParametersService,
1612
PARAMETERS_SERVICE_TOKEN,
@@ -21,8 +17,6 @@ export class ContextResolutionService implements IContextResolutionService {
2117
constructor(
2218
@Inject(PARAMETERS_SERVICE_TOKEN)
2319
private readonly parametersService: IParametersService,
24-
@Inject(INTEGRATION_SERVICE_TOKEN)
25-
private readonly integrationService: IIntegrationService,
2620
@Inject(INTEGRATION_CONFIG_SERVICE_TOKEN)
2721
private readonly integrationConfigService: IIntegrationConfigService,
2822
) {}
@@ -31,44 +25,33 @@ export class ContextResolutionService implements IContextResolutionService {
3125
organizationId: string,
3226
repositoryId: string,
3327
): Promise<string> {
34-
// 1. Fetch all active integrations for the organization
35-
const integrations = await this.integrationService.find({
36-
organization: { uuid: organizationId },
37-
status: true,
38-
});
28+
// Prior version fetched active integrations first, then issued
29+
// one `find({ integration: { uuid } })` per integration inside a
30+
// sequential loop — a textbook 1+N on the code-review hot path.
31+
// The repository method below collapses it to a single JOIN
32+
// query over integration_configs → integrations → organization.
33+
const integrationConfigs =
34+
await this.integrationConfigService.findByOrganizationAndConfigKey(
35+
organizationId,
36+
IntegrationConfigKey.REPOSITORIES,
37+
);
3938

40-
if (!integrations || integrations.length === 0) {
39+
if (!integrationConfigs || integrationConfigs.length === 0) {
4140
throw new Error('No active integrations found for organization');
4241
}
4342

44-
// 2. For each integration, fetch integration configs with REPOSITORIES key
45-
for (const integration of integrations) {
46-
const integrationConfigs = await this.integrationConfigService.find(
47-
{
48-
integration: { uuid: integration.uuid },
49-
configKey: IntegrationConfigKey.REPOSITORIES,
50-
},
51-
);
43+
for (const config of integrationConfigs) {
44+
const repositories = config.configValue;
5245

53-
if (!integrationConfigs || integrationConfigs.length === 0) {
54-
continue;
55-
}
46+
if (Array.isArray(repositories)) {
47+
const foundRepository = repositories.find(
48+
(repo: any) =>
49+
repo.id === repositoryId ||
50+
repo.id === repositoryId.toString(),
51+
);
5652

57-
// 3. Search the repository list for one with the same id
58-
for (const config of integrationConfigs) {
59-
const repositories = config.configValue;
60-
61-
if (Array.isArray(repositories)) {
62-
const foundRepository = repositories.find(
63-
(repo: any) =>
64-
repo.id === repositoryId ||
65-
repo.id === repositoryId.toString(),
66-
);
67-
68-
if (foundRepository) {
69-
// 4. Return the teamId from this integration config
70-
return config?.team?.uuid;
71-
}
53+
if (foundRepository) {
54+
return config?.team?.uuid;
7255
}
7356
}
7457
}

libs/core/infrastructure/config/axios/microservices/azureRepos.axios.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
import axios, { AxiosInstance } from 'axios';
22

3+
import { INTEGRATION_REQUEST_TIMEOUT_MS } from '@libs/core/infrastructure/http/integration-timeouts';
4+
35
export class AxiosAzureReposService {
46
private axiosInstance: AxiosInstance;
57

68
constructor({ tenantId = '', organization = '' }) {
79
this.axiosInstance = axios.create({
810
baseURL: process.env.KODUS_SERVICE_AZURE_REPOS,
11+
// axios default is 0 = infinite. Without this, a stalled
12+
// upstream microservice would keep the caller's HTTP handler
13+
// (and its pool connection) hanging until the global undici
14+
// 10-minute ceiling. Matches the pattern already used in
15+
// sibling microservice clients (license.axios, mcpManager.axios).
16+
timeout: INTEGRATION_REQUEST_TIMEOUT_MS,
917
headers: {
1018
'Content-Type': 'application/json',
1119
'x-tenant-id': tenantId,

0 commit comments

Comments
 (0)