Skip to content

Commit 3bdf44c

Browse files
committed
integ: opensearch-project#414 (goyamegh/run-resume-checkpoint)
2 parents cddb712 + ba72497 commit 3bdf44c

13 files changed

Lines changed: 1739 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Large diffs are not rendered by default.

components/evals3/EvalRunDetailPage.tsx

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import React, { useState, useEffect, useCallback } from 'react';
1616
import { useParams, useNavigate } from 'react-router-dom';
1717
import {
1818
Loader2, CheckCircle2, XCircle, Clock, AlertTriangle,
19-
ChevronDown, ChevronRight, ArrowLeft, Bookmark, RotateCcw,
19+
ChevronDown, ChevronRight, ArrowLeft, Bookmark, RotateCcw, Play,
2020
} from 'lucide-react';
2121
import { Button } from '@/components/ui/button';
2222
import { Badge } from '@/components/ui/badge';
@@ -33,6 +33,7 @@ import {
3333
getEvaluationRun,
3434
cancelEvaluationRun,
3535
promoteEvaluationRun,
36+
resumeEvaluationRun,
3637
} from '@/services/client/evaluationRunsApi';
3738
import { Breadcrumbs } from './Breadcrumbs';
3839

@@ -68,6 +69,19 @@ function SourceBadge({ source }: { source: any }) {
6869
);
6970
}
7071

72+
/**
73+
* Client-side heuristic mirroring the server's liveness check: a 'running'
74+
* run whose last liveness signal (heartbeat > resumed > created) is older
75+
* than 10 minutes probably lost its server. The server re-validates against
76+
* the authoritative EVALUATION_RUN_STALE_AFTER_MS on resume, so a false
77+
* positive here just gets a clear 409.
78+
*/
79+
function runLooksOrphaned(run: EvaluationRun): boolean {
80+
const last = new Date(run.heartbeatAt || run.resumedAt || run.createdAt || 0).getTime();
81+
if (!Number.isFinite(last) || last <= 0) return true;
82+
return Date.now() - last > 10 * 60 * 1000;
83+
}
84+
7185
// ─── Main Component ──────────────────────────────────────────────────────────
7286

7387
export const EvalRunDetailPage: React.FC = () => {
@@ -82,6 +96,7 @@ export const EvalRunDetailPage: React.FC = () => {
8296
const [promoteName, setPromoteName] = useState('');
8397
const [promoting, setPromoting] = useState(false);
8498
const [cancelling, setCancelling] = useState(false);
99+
const [resuming, setResuming] = useState(false);
85100

86101
const loadRun = useCallback(async () => {
87102
if (!runId) return;
@@ -119,6 +134,20 @@ export const EvalRunDetailPage: React.FC = () => {
119134
}
120135
};
121136

137+
// Resume: re-execute only the test cases without a persisted report.
138+
// Fire-and-poll — the SSE stream runs in the background; the existing
139+
// running-status poller picks up per-test-case progress.
140+
const handleResume = async () => {
141+
if (!runId) return;
142+
setResuming(true);
143+
setError(null);
144+
resumeEvaluationRun(runId, () => {})
145+
.catch((err: any) => setError(err.message))
146+
.finally(() => loadRun());
147+
// Give the server a moment to flip status to running, then refresh
148+
setTimeout(() => { loadRun(); setResuming(false); }, 1000);
149+
};
150+
122151
const handlePromote = async () => {
123152
if (!runId || !promoteName.trim()) return;
124153
setPromoting(true);
@@ -211,6 +240,23 @@ export const EvalRunDetailPage: React.FC = () => {
211240

212241
{/* Actions */}
213242
<div className="flex items-center gap-2">
243+
{/* Resume: continue THIS run — re-executes only test cases without
244+
a persisted report (checkpoint-resume). Shown for interrupted
245+
runs (failed / cancelled) and for 'running' runs whose
246+
liveness heartbeat went silent (orphaned by a dead server —
247+
the server re-validates staleness and 409s if it's alive). */}
248+
{(run.status !== 'running' || runLooksOrphaned(run)) && (run.testCaseSnapshots || []).some(s => !run.results?.[s.id]?.reportId) && (
249+
<Button
250+
variant="default"
251+
size="sm"
252+
data-testid="resume-run-btn"
253+
onClick={handleResume}
254+
disabled={resuming}
255+
>
256+
{resuming ? <Loader2 size={14} className="mr-1 animate-spin" /> : <Play size={14} className="mr-1" />}
257+
Resume ({(run.testCaseSnapshots || []).filter(s => !run.results?.[s.id]?.reportId).length} left)
258+
</Button>
259+
)}
214260
{/* Re-run: restart this run with its stored config (same agent,
215261
test-case sources, evaluator, judge model) via the New-Run
216262
flow, pre-filled. Available for any run that isn't currently

server/adapters/file/StorageModule.ts

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,21 @@ export class FileSessionMetadataOperations implements ISessionMetadataOperations
827827

828828
class FileEvaluationRunOperations implements IEvaluationRunOperations {
829829
private readonly dir: string;
830+
/**
831+
* Per-run write serialization. update()/updateResult() are
832+
* read-modify-write with an `await` between read and write — with
833+
* run.concurrency > 1 two in-flight updates could interleave at that
834+
* boundary and the second write clobbers the first (lost per-test-case
835+
* results). Chain writes per run id so they apply in order.
836+
*/
837+
private writeQueues = new Map<string, Promise<unknown>>();
838+
839+
private serialized<T>(id: string, op: () => Promise<T>): Promise<T> {
840+
const prev = this.writeQueues.get(id) ?? Promise.resolve();
841+
const next = prev.then(op, op);
842+
this.writeQueues.set(id, next.catch(() => {}));
843+
return next;
844+
}
830845

831846
constructor(baseDir: string) {
832847
// Stored in same directory as benchmarks (same "index" concept)
@@ -857,11 +872,13 @@ class FileEvaluationRunOperations implements IEvaluationRunOperations {
857872
}
858873

859874
async update(id: string, updates: Partial<EvaluationRun>): Promise<EvaluationRun> {
860-
const existing = await this.getById(id);
861-
if (!existing) throw new Error(`Evaluation run ${id} not found`);
862-
const updated = { ...existing, ...updates } as EvaluationRun;
863-
writeJsonFile(path.join(this.dir, `${id}.json`), updated);
864-
return updated;
875+
return this.serialized(id, async () => {
876+
const existing = await this.getById(id);
877+
if (!existing) throw new Error(`Evaluation run ${id} not found`);
878+
const updated = { ...existing, ...updates } as EvaluationRun;
879+
writeJsonFile(path.join(this.dir, `${id}.json`), updated);
880+
return updated;
881+
});
865882
}
866883

867884
async delete(id: string): Promise<{ deleted: boolean }> {
@@ -912,11 +929,13 @@ class FileEvaluationRunOperations implements IEvaluationRunOperations {
912929
status: RunResultStatus;
913930
error?: string;
914931
}): Promise<boolean> {
915-
const existing = await this.getById(runId);
916-
if (!existing) return false;
917-
existing.results[testCaseId] = result;
918-
writeJsonFile(path.join(this.dir, `${runId}.json`), existing);
919-
return true;
932+
return this.serialized(runId, async () => {
933+
const existing = await this.getById(runId);
934+
if (!existing) return false;
935+
existing.results[testCaseId] = result;
936+
writeJsonFile(path.join(this.dir, `${runId}.json`), existing);
937+
return true;
938+
});
920939
}
921940
}
922941

server/adapters/opensearch/StorageModule.ts

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1195,18 +1195,27 @@ class OpenSearchEvaluationRunOperations implements IEvaluationRunOperations {
11951195

11961196
async update(id: string, updates: Partial<EvaluationRun>): Promise<EvaluationRun> {
11971197
assertNotMigrating(this.index);
1198-
const existing = await this.getById(id);
1199-
if (!existing) throw new Error(`Evaluation run ${id} not found`);
1200-
1201-
const updated = { ...existing, ...updates };
1202-
await this.client.index({
1203-
index: this.index,
1204-
id,
1205-
body: updated,
1206-
refresh: 'wait_for',
1207-
});
1198+
// Partial doc-merge via the _update API instead of read-modify-write +
1199+
// full reindex: a stale read here would silently clobber concurrent
1200+
// per-test-case `updateResult` script updates (seen under concurrency>1
1201+
// and with the periodic run heartbeat). Only the provided fields are
1202+
// touched, and OpenSearch retries CAS conflicts server-side.
1203+
try {
1204+
await this.client.update({
1205+
index: this.index,
1206+
id,
1207+
retry_on_conflict: 10,
1208+
body: { doc: updates },
1209+
refresh: 'wait_for',
1210+
});
1211+
} catch (error: any) {
1212+
if (error.meta?.statusCode === 404) throw new Error(`Evaluation run ${id} not found`);
1213+
throw error;
1214+
}
12081215

1209-
return updated as EvaluationRun;
1216+
const updated = await this.getById(id);
1217+
if (!updated) throw new Error(`Evaluation run ${id} not found`);
1218+
return updated;
12101219
}
12111220

12121221
async delete(id: string): Promise<{ deleted: boolean }> {
@@ -1293,6 +1302,10 @@ class OpenSearchEvaluationRunOperations implements IEvaluationRunOperations {
12931302
await this.client.update({
12941303
index: this.index,
12951304
id: runId,
1305+
// Concurrent test cases (run.concurrency > 1) update the same run
1306+
// doc — without CAS retries these script updates fail with
1307+
// version_conflict_engine_exception and the whole run aborts.
1308+
retry_on_conflict: 10,
12961309
body: {
12971310
script: {
12981311
source: `ctx._source.results.put(params.testCaseId, params.result)`,

server/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { findObservioRoot, spawnObservioAgent, OBSERVIO_DEFAULT_PORT, resetObser
1717
import { validateAwsCredentials } from './services/tracesService.js';
1818
import { resumePendingTracePollsSafely } from './services/traceRecoveryOnBoot.js';
1919
import { recoverOrphanBenchmarkRunsSafely } from './services/benchmarkRunRecoveryOnBoot.js';
20+
import { recoverOrphanEvaluationRunsSafely } from './services/evaluationRunRecoveryOnBoot.js';
2021
import { getStorageModule } from './adapters/index.js';
2122

2223
// Register server-side connectors (subprocess, claude-code)
@@ -170,6 +171,10 @@ async function startServer() {
170171
// with the runner long dead). Different bug class — see
171172
// server/services/benchmarkRunRecoveryOnBoot.ts.
172173
recoverOrphanBenchmarkRunsSafely(storage);
174+
// Same failure class for run-first EvaluationRun docs — flip stale
175+
// 'running' orphans to failed-with-note so they become resumable
176+
// via POST /api/storage/evaluation-runs/:id/resume.
177+
recoverOrphanEvaluationRunsSafely(storage);
173178
}
174179
} catch (err: any) {
175180
console.warn(`[bootRecovery] Could not start: ${err?.message || err}`);

0 commit comments

Comments
 (0)