Skip to content

Commit fc2cf10

Browse files
authored
Merge pull request #129 from bcgov/fix/benchmark-history-bloat-via-wrapper-workflow
fix(benchmark): wrapper child workflow to absorb per-sample payloads
2 parents b2d52c2 + e52cb22 commit fc2cf10

8 files changed

Lines changed: 1941 additions & 616 deletions

apps/temporal/src/activities/benchmark-execute.test.ts

Lines changed: 119 additions & 388 deletions
Large diffs are not rendered by default.

apps/temporal/src/activities/benchmark-execute.ts

Lines changed: 54 additions & 175 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,51 @@
11
/**
22
* Benchmark Workflow Execution
33
*
4-
* Executes a GraphWorkflowConfig against a single dataset sample by invoking
5-
* the existing `graphWorkflow` as a child workflow on the `benchmark-processing`
6-
* task queue. This ensures benchmarks test the actual execution path.
4+
* Per-sample dispatcher called from `benchmarkRunWorkflow`. Starts the wrapper
5+
* child workflow `benchmarkSampleWorkflow` (which internally runs the generic
6+
* `graphWorkflow` and writes prediction / persists OCR cache from inside its
7+
* own context) on the `benchmark-processing` task queue. The wrapper returns a
8+
* slim summary so the parent benchmark orchestrator's history stays small.
79
*
810
* NOTE: This module exports a workflow-level helper function (not a pure activity)
911
* because it uses `executeChild` to start child workflows, which is only available
10-
* in the Temporal workflow context. The benchmark orchestrator (US-022) calls this
11-
* directly from workflow code.
12-
*
13-
* See feature-docs/003-benchmarking-system/user-stories/US-019-workflow-execution-activity.md
14-
* See feature-docs/003-benchmarking-system/REQUIREMENTS.md Section 4.2, 13.1
12+
* in the Temporal workflow context.
1513
*/
1614

1715
import { executeChild, workflowInfo } from "@temporalio/workflow";
18-
import {
19-
GRAPH_RUNNER_VERSION,
20-
type GraphWorkflowConfig,
21-
type GraphWorkflowInput,
22-
type GraphWorkflowResult,
23-
} from "../graph-workflow-types";
16+
import type { BenchmarkSampleWorkflowOutput } from "../benchmark-sample-workflow";
17+
import type { GraphWorkflowConfig } from "../graph-workflow-types";
2418

2519
// ---------------------------------------------------------------------------
2620
// Types
2721
// ---------------------------------------------------------------------------
2822

2923
export interface BenchmarkExecuteInput {
30-
/** Unique sample identifier from the dataset manifest */
3124
sampleId: string;
32-
33-
/** The graph workflow configuration to execute */
3425
workflowConfig: GraphWorkflowConfig;
35-
36-
/** SHA-256 hash of the workflow config (for version pinning) */
3726
configHash: string;
38-
39-
/** Paths to input files for this sample (materialized on disk) */
4027
inputPaths: string[];
41-
42-
/** Base directory for writing per-sample output files */
4328
outputBaseDir: string;
44-
45-
/** Additional context to pass to the workflow */
4629
sampleMetadata: Record<string, unknown>;
47-
48-
/** Timeout for the child workflow execution in milliseconds */
30+
/** Directory under which the per-sample prediction JSON should be written. */
31+
predictionOutputDir: string;
32+
/** When set, wrapper persists OCR response under this benchmark run id. */
33+
persistOcrCache?: { sourceRunId: string };
4934
timeoutMs?: number;
50-
51-
/** Task queue to route the child workflow to (defaults to benchmark-processing) */
5235
taskQueue?: string;
36+
requestId?: string;
5337
}
5438

5539
export interface BenchmarkExecuteOutput {
56-
/** The sample ID this result belongs to */
5740
sampleId: string;
58-
59-
/** Whether the workflow execution succeeded */
6041
success: boolean;
61-
62-
/** The workflow result if successful */
63-
workflowResult?: GraphWorkflowResult;
64-
65-
/** Paths to output files produced by the workflow */
42+
/** Path to per-sample prediction JSON written by the wrapper child. */
43+
predictionPath?: string;
44+
/** Per-field confidence map flattened from the inner workflow ctx. */
45+
confidenceData?: Record<string, number | null>;
46+
/** Output paths reported by the inner workflow ctx. */
6647
outputPaths: string[];
67-
68-
/** Error details if the workflow failed */
69-
error?: {
70-
message: string;
71-
failedNodeId?: string;
72-
type?: string;
73-
};
74-
75-
/** Duration of the child workflow execution in milliseconds */
48+
error?: { message: string; failedNodeId?: string; type?: string };
7649
durationMs: number;
7750
}
7851

@@ -87,13 +60,6 @@ const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
8760
// Main Function
8861
// ---------------------------------------------------------------------------
8962

90-
/**
91-
* Execute a GraphWorkflowConfig as a child workflow for a single benchmark sample.
92-
*
93-
* This function is called from the benchmark orchestrator workflow context.
94-
* It invokes `graphWorkflow` as a child workflow on the `benchmark-processing`
95-
* task queue to ensure isolation from production workloads.
96-
*/
9763
export async function benchmarkExecuteWorkflow(
9864
params: BenchmarkExecuteInput,
9965
): Promise<BenchmarkExecuteOutput> {
@@ -105,8 +71,11 @@ export async function benchmarkExecuteWorkflow(
10571
inputPaths,
10672
outputBaseDir,
10773
sampleMetadata,
74+
predictionOutputDir,
75+
persistOcrCache,
10876
timeoutMs = DEFAULT_TIMEOUT_MS,
10977
taskQueue = BENCHMARK_TASK_QUEUE,
78+
requestId,
11079
} = params;
11180

11281
const parentWorkflowId = workflowInfo().workflowId;
@@ -126,51 +95,25 @@ export async function benchmarkExecuteWorkflow(
12695
);
12796

12897
try {
129-
// Build initial context for the child workflow.
130-
// The graph workflow nodes expect fields like blobKey, documentId, fileName, etc.
131-
// In benchmark mode, we populate these from the materialized input files.
132-
const primaryInput = inputPaths[0] || "";
133-
const fileName = primaryInput.split("/").pop() || "document";
134-
const lowerName = fileName.toLowerCase();
135-
const isImage = /\.(jpg|jpeg|png|gif|bmp|tiff|webp)$/i.test(lowerName);
136-
const fileType = isImage ? "image" : "pdf";
137-
const contentType = isImage
138-
? lowerName.endsWith(".png")
139-
? "image/png"
140-
: "image/jpeg"
141-
: "application/pdf";
142-
143-
const initialCtx: Record<string, unknown> = {
144-
...sampleMetadata,
145-
inputPaths,
146-
outputBaseDir,
147-
sampleId,
148-
// Fields expected by graph workflow nodes (e.g., file.prepare)
149-
documentId: `benchmark-${sampleId}`,
150-
blobKey: primaryInput,
151-
fileName,
152-
fileType,
153-
contentType,
154-
};
155-
156-
const childWorkflowInput: GraphWorkflowInput = {
157-
graph: workflowConfig,
158-
initialCtx,
159-
configHash,
160-
runnerVersion: GRAPH_RUNNER_VERSION,
161-
parentWorkflowId,
162-
};
163-
164-
// Execute the graphWorkflow as a child workflow on the specified task queue
165-
const childResult = (await executeChild("graphWorkflow", {
166-
args: [childWorkflowInput],
98+
const childResult = (await executeChild("benchmarkSampleWorkflow", {
99+
args: [
100+
{
101+
sampleId,
102+
workflowConfig,
103+
configHash,
104+
inputPaths,
105+
outputBaseDir,
106+
sampleMetadata,
107+
predictionOutputDir,
108+
persistOcrCache,
109+
parentWorkflowId,
110+
requestId,
111+
},
112+
],
167113
taskQueue,
168114
workflowId: childWorkflowId,
169115
workflowExecutionTimeout: timeoutMs,
170-
})) as GraphWorkflowResult;
171-
172-
// Collect output paths from the workflow context
173-
const outputPaths = extractOutputPaths(childResult.ctx);
116+
})) as BenchmarkSampleWorkflowOutput;
174117

175118
const durationMs = Date.now() - startTime;
176119

@@ -179,33 +122,28 @@ export async function benchmarkExecuteWorkflow(
179122
activity: "benchmarkExecuteWorkflow",
180123
event: "complete",
181124
sampleId,
182-
status: childResult.status,
183-
completedNodes: childResult.completedNodes.length,
184-
outputPaths: outputPaths.length,
125+
status:
126+
childResult.graphStatus ??
127+
(childResult.success ? "completed" : "failed"),
128+
completedNodes: childResult.completedNodes ?? 0,
129+
outputPaths: childResult.outputPaths.length,
185130
durationMs,
186131
timestamp: new Date().toISOString(),
187132
}),
188133
);
189134

190-
if (childResult.status === "failed") {
191-
return {
192-
sampleId,
193-
success: false,
194-
workflowResult: childResult,
195-
outputPaths,
196-
error: {
197-
message: `Workflow completed with status: failed`,
198-
failedNodeId: findFailedNodeId(childResult),
199-
},
200-
durationMs,
201-
};
202-
}
203-
204135
return {
205136
sampleId,
206-
success: true,
207-
workflowResult: childResult,
208-
outputPaths,
137+
success: childResult.success,
138+
predictionPath: childResult.predictionPath,
139+
confidenceData: childResult.confidenceData,
140+
outputPaths: childResult.outputPaths,
141+
error: childResult.error
142+
? {
143+
message: childResult.error.message,
144+
failedNodeId: childResult.error.failedNodeId,
145+
}
146+
: undefined,
209147
durationMs,
210148
};
211149
} catch (error) {
@@ -243,15 +181,11 @@ export async function benchmarkExecuteWorkflow(
243181
}),
244182
);
245183

246-
// Return failure result without crashing the parent benchmark workflow
247184
return {
248185
sampleId,
249186
success: false,
250187
outputPaths: [],
251-
error: {
252-
message: errorMessage,
253-
type: errorType,
254-
},
188+
error: { message: errorMessage, type: errorType },
255189
durationMs,
256190
};
257191
}
@@ -261,61 +195,6 @@ export async function benchmarkExecuteWorkflow(
261195
// Helpers
262196
// ---------------------------------------------------------------------------
263197

264-
/**
265-
* Extract output file paths from the workflow context.
266-
* Looks for common output path patterns in the context.
267-
*/
268-
function extractOutputPaths(ctx: Record<string, unknown>): string[] {
269-
const paths: string[] = [];
270-
271-
// Check for explicit outputPaths in context
272-
if (Array.isArray(ctx.outputPaths)) {
273-
for (const p of ctx.outputPaths) {
274-
if (typeof p === "string") {
275-
paths.push(p);
276-
}
277-
}
278-
}
279-
280-
// Check for outputPath (singular) in context
281-
if (typeof ctx.outputPath === "string") {
282-
paths.push(ctx.outputPath);
283-
}
284-
285-
// Check for results that contain file paths
286-
if (Array.isArray(ctx.results)) {
287-
for (const result of ctx.results) {
288-
if (result && typeof result === "object" && "outputPath" in result) {
289-
const resultObj = result as Record<string, unknown>;
290-
if (typeof resultObj.outputPath === "string") {
291-
paths.push(resultObj.outputPath);
292-
}
293-
}
294-
}
295-
}
296-
297-
// If no paths found but outputBaseDir is in ctx, use that
298-
if (paths.length === 0 && typeof ctx.outputBaseDir === "string") {
299-
paths.push(ctx.outputBaseDir);
300-
}
301-
302-
return paths;
303-
}
304-
305-
/**
306-
* Try to extract the failed node ID from a workflow result.
307-
*/
308-
function findFailedNodeId(result: GraphWorkflowResult): string | undefined {
309-
// Check if there's error info in the context that might contain a failed node ID
310-
if (result.ctx && typeof result.ctx.failedNodeId === "string") {
311-
return result.ctx.failedNodeId;
312-
}
313-
return undefined;
314-
}
315-
316-
/**
317-
* Extract error type string from an error.
318-
*/
319198
function extractErrorType(error: unknown): string {
320199
if (error instanceof Error) {
321200
if (

0 commit comments

Comments
 (0)