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
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ jest.config.*
.DS_Store
worktrees/
scripts/
!scripts/write-build-info.mjs
docker-compose.override.yml*
.eslintrc*
.prettierrc*
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
},
"packageManager": "pnpm@9.15.4",
"scripts": {
"build": "prisma generate && nest build",
"build": "prisma generate && nest build && node scripts/write-build-info.mjs",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
Expand Down
34 changes: 34 additions & 0 deletions scripts/write-build-info.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env node
// Writes dist/build-info.json so /v1/health can report which commit dist was
// built from (see src/health/build-info.ts). Never fails the build: missing git
// (e.g. Docker, where .git is ignored) just records gitSha: null.
import { execFileSync } from 'node:child_process';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';

const SHA_RE = /^[0-9a-f]{7,40}$/i;

function gitSha() {
for (const name of ['ENGRAM_GIT_SHA', 'RAILWAY_GIT_COMMIT_SHA', 'SOURCE_COMMIT']) {
const v = process.env[name]?.trim();
if (v && SHA_RE.test(v)) return v.toLowerCase();
}
try {
const sha = execFileSync('git', ['rev-parse', 'HEAD'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
return SHA_RE.test(sha) ? sha : null;
} catch {
return null;
}
}

try {
const { version } = JSON.parse(readFileSync('package.json', 'utf8'));
const info = { version, gitSha: gitSha(), builtAt: new Date().toISOString() };
mkdirSync('dist', { recursive: true });
writeFileSync('dist/build-info.json', JSON.stringify(info, null, 2) + '\n');
console.log(`build-info: ${info.version} ${info.gitSha ?? '(no sha)'}`);
} catch (err) {
console.warn(`build-info: skipped (${err instanceof Error ? err.message : err})`);
}
37 changes: 36 additions & 1 deletion src/consolidation/dream-cycle-run-tracker.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DreamCycleRunTrackerService } from './dream-cycle-run-tracker.service';
import {
DreamCycleRunTrackerService,
DREAM_CYCLE_STALE_MS,
} from './dream-cycle-run-tracker.service';
import { ServicePrismaService } from '../prisma/service-prisma.service';

const mockRecord = {
Expand All @@ -15,6 +18,9 @@ const mockPrisma = {
create: jest.fn().mockResolvedValue(mockRecord),
update: jest.fn().mockResolvedValue({}),
},
dreamCycleReport: {
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
},
memory: {
count: jest.fn().mockResolvedValue(42),
},
Expand Down Expand Up @@ -179,4 +185,33 @@ describe('DreamCycleRunTrackerService', () => {
expect(count).toBe(0);
});
});

describe('failStaleReports', () => {
it('should mark RUNNING reports older than the threshold as FAILED', async () => {
mockPrisma.dreamCycleReport.updateMany.mockResolvedValueOnce({
count: 3,
});
const before = Date.now();

const count = await service.failStaleReports();

expect(count).toBe(3);
const call = mockPrisma.dreamCycleReport.updateMany.mock.calls[0][0];
expect(call.where.status).toBe('RUNNING');
const cutoff = call.where.startedAt.lt.getTime();
expect(cutoff).toBeLessThanOrEqual(Date.now() - DREAM_CYCLE_STALE_MS);
expect(cutoff).toBeGreaterThanOrEqual(before - DREAM_CYCLE_STALE_MS);
expect(call.data.status).toBe('FAILED');
expect(call.data.completedAt).toBeInstanceOf(Date);
expect(call.data.errors.push).toContain('orphaned');
});

it('should honour a custom threshold', async () => {
await service.failStaleReports(60_000);
const call = mockPrisma.dreamCycleReport.updateMany.mock.calls[0][0];
expect(Date.now() - call.where.startedAt.lt.getTime()).toBeLessThan(
60_000 + 5_000,
);
});
});
});
29 changes: 29 additions & 0 deletions src/consolidation/dream-cycle-run-tracker.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { Injectable, Logger } from '@nestjs/common';
import { ServicePrismaService } from '../prisma/service-prisma.service';

/**
* A per-user report still RUNNING after this long was orphaned by a crash or
* redeploy (local nightly runs finish in about an hour for 265 users).
*/
export const DREAM_CYCLE_STALE_MS = 6 * 60 * 60 * 1000;

export interface StageRunRecord {
id: string;
runId: string;
Expand Down Expand Up @@ -81,6 +87,29 @@ export class DreamCycleRunTrackerService {
});
}

/**
* Marks orphaned RUNNING reports as FAILED. Without this an orphan stays the
* newest report and /v1/health shows lastRun=null, status=RUNNING until the
* next cycle starts.
*/
async failStaleReports(staleMs = DREAM_CYCLE_STALE_MS): Promise<number> {
const { count } = await this.prisma.dreamCycleReport.updateMany({
where: {
status: 'RUNNING',
startedAt: { lt: new Date(Date.now() - staleMs) },
},
data: {
status: 'FAILED',
completedAt: new Date(),
errors: { push: 'orphaned: still RUNNING past stale threshold' },
},
});
if (count > 0) {
this.logger.warn(`Marked ${count} orphaned Dream Cycle report(s) FAILED`);
}
return count;
}

async getTotalMemoryCount(userId?: string): Promise<number> {
return this.prisma.memory.count({
where: { deletedAt: null, ...(userId ? { userId } : {}) },
Expand Down
34 changes: 34 additions & 0 deletions src/consolidation/dream-cycle-scheduler.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { SchedulerRegistry } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config';
import { DreamCycleSchedulerService } from './dream-cycle-scheduler.service';
import { DreamCycleService } from './dream-cycle.service';
import { DreamCycleRunTrackerService } from './dream-cycle-run-tracker.service';
import { Logger } from '@nestjs/common';

describe('DreamCycleSchedulerService', () => {
let service: DreamCycleSchedulerService;
let dreamCycle: { run: jest.Mock; runAsync: jest.Mock };
let runTracker: { failStaleReports: jest.Mock };
let configValues: Record<string, string>;
let logSpy: jest.SpyInstance;
let errorSpy: jest.SpyInstance;
Expand All @@ -21,11 +23,13 @@ describe('DreamCycleSchedulerService', () => {
};

dreamCycle = { run: jest.fn(), runAsync: jest.fn() };
runTracker = { failStaleReports: jest.fn().mockResolvedValue(0) };

const module: TestingModule = await Test.createTestingModule({
providers: [
DreamCycleSchedulerService,
{ provide: DreamCycleService, useValue: dreamCycle },
{ provide: DreamCycleRunTrackerService, useValue: runTracker },
{
provide: ConfigService,
useValue: {
Expand Down Expand Up @@ -63,6 +67,7 @@ describe('DreamCycleSchedulerService', () => {
providers: [
DreamCycleSchedulerService,
{ provide: DreamCycleService, useValue: dreamCycle },
{ provide: DreamCycleRunTrackerService, useValue: runTracker },
{
provide: ConfigService,
useValue: {
Expand Down Expand Up @@ -115,13 +120,42 @@ describe('DreamCycleSchedulerService', () => {
);
});

it('should fail orphaned reports before starting a run', async () => {
const order: string[] = [];
runTracker.failStaleReports.mockImplementation(async () => {
order.push('reap');
return 2;
});
dreamCycle.runAsync.mockImplementation(async () => {
order.push('run');
return { runId: 'run-1', mode: 'queued' };
});

await service.handleDreamCycleCron();

expect(order).toEqual(['reap', 'run']);
});

it('should still run when orphan cleanup throws', async () => {
runTracker.failStaleReports.mockRejectedValue(new Error('db down'));
dreamCycle.runAsync.mockResolvedValue({ runId: 'run-2', mode: 'queued' });

await service.handleDreamCycleCron();

expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('Stale Dream Cycle cleanup failed'),
);
expect(dreamCycle.runAsync).toHaveBeenCalledTimes(1);
});

it('should skip when disabled', async () => {
configValues.DREAM_CYCLE_ENABLED = 'false';

const module = await Test.createTestingModule({
providers: [
DreamCycleSchedulerService,
{ provide: DreamCycleService, useValue: dreamCycle },
{ provide: DreamCycleRunTrackerService, useValue: runTracker },
{
provide: ConfigService,
useValue: {
Expand Down
8 changes: 8 additions & 0 deletions src/consolidation/dream-cycle-scheduler.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { SchedulerRegistry } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config';
import { CronJob } from 'cron';
import { DreamCycleService } from './dream-cycle.service';
import { DreamCycleRunTrackerService } from './dream-cycle-run-tracker.service';

@Injectable()
export class DreamCycleSchedulerService implements OnModuleInit {
Expand All @@ -12,6 +13,7 @@ export class DreamCycleSchedulerService implements OnModuleInit {

constructor(
private readonly dreamCycle: DreamCycleService,
private readonly runTracker: DreamCycleRunTrackerService,
private readonly config: ConfigService,
private readonly schedulerRegistry: SchedulerRegistry,
) {
Expand Down Expand Up @@ -47,6 +49,12 @@ export class DreamCycleSchedulerService implements OnModuleInit {
this.logger.log('Starting scheduled Dream Cycle run');
const start = Date.now();

try {
await this.runTracker.failStaleReports();
} catch (error) {
this.logger.error(`Stale Dream Cycle cleanup failed: ${error.message}`);
}

try {
// ENG-97: Prefer BullMQ-based execution; falls back to sequential automatically
const { runId, mode } = await this.dreamCycle.runAsync();
Expand Down
Loading
Loading