@@ -3,7 +3,9 @@ import { trace } from "@internal/tracing";
33import { generateFriendlyId } from "@trigger.dev/core/v3/isomorphic" ;
44import { setTimeout } from "node:timers/promises" ;
55import { describe , expect } from "vitest" ;
6+ import type { PrismaClient } from "@trigger.dev/database" ;
67import { RunEngine } from "../index.js" ;
8+ import { getExecutionSnapshotsSince } from "../systems/executionSnapshotSystem.js" ;
79import { copySnapshotsToReplica , createTestMetricsMeter } from "./helpers/replicaTestHelpers.js" ;
810import { setupTestScenario } from "./helpers/snapshotTestHelpers.js" ;
911import { setupAuthenticatedEnvironment , setupBackgroundWorker } from "./setup.js" ;
@@ -1398,4 +1400,147 @@ describe("RunEngine getSnapshotsSince", () => {
13981400 }
13991401 }
14001402 ) ;
1403+
1404+ // This models a BATCH resume: production only populates completedWaitpointOrder for waitpoints that
1405+ // carry a batch index, so the read-repair only ever engages for batch resumes. Single-waitpoint
1406+ // continues have an empty order (repair is a no-op) and are covered instead by the atomic write
1407+ // (snapshot + join commit in one tx, which a replica applies atomically).
1408+ containerTest (
1409+ "repairs a batch resume's completed-waitpoints from the primary when the replica lags the join rows" ,
1410+ async ( { prisma, schemaOnlyPrisma, redisOptions } ) => {
1411+ const authenticatedEnvironment = await setupAuthenticatedEnvironment ( prisma , "PRODUCTION" ) ;
1412+
1413+ const engine = new RunEngine ( {
1414+ prisma,
1415+ // Replica has the snapshot rows but lags on the _completedWaitpoints join (see below).
1416+ readOnlyPrisma : schemaOnlyPrisma ,
1417+ readReplicaSnapshotsSinceEnabled : true ,
1418+ readReplicaSnapshotsSinceRetryDelay : { minMs : 1 , maxMs : 2 } ,
1419+ worker : { redis : redisOptions , workers : 1 , tasksPerWorker : 10 , pollIntervalMs : 100 } ,
1420+ queue : { redis : redisOptions } ,
1421+ runLock : { redis : redisOptions } ,
1422+ machines : {
1423+ defaultMachine : "small-1x" ,
1424+ machines : {
1425+ "small-1x" : {
1426+ name : "small-1x" as const ,
1427+ cpu : 0.5 ,
1428+ memory : 0.5 ,
1429+ centsPerMs : 0.0001 ,
1430+ } ,
1431+ } ,
1432+ baseCostInCents : 0.0001 ,
1433+ } ,
1434+ tracer : trace . getTracer ( "test" , "0.0.0" ) ,
1435+ } ) ;
1436+
1437+ try {
1438+ // Primary: a run whose latest snapshot is a warm-continue carrying one completed waitpoint -
1439+ // completedWaitpointOrder is set on the row AND the _completedWaitpoints join + waitpoint exist.
1440+ const scenario = await setupTestScenario ( prisma , authenticatedEnvironment , {
1441+ totalWaitpoints : 1 ,
1442+ outputSizeKB : 1 ,
1443+ snapshotConfigs : [
1444+ { status : "RUN_CREATED" , completedWaitpointCount : 0 } ,
1445+ { status : "EXECUTING" , completedWaitpointCount : 0 } ,
1446+ { status : "EXECUTING_WITH_WAITPOINTS" , completedWaitpointCount : 0 } ,
1447+ { status : "EXECUTING" , completedWaitpointCount : 1 } ,
1448+ ] ,
1449+ } ) ;
1450+ const waitpointId = scenario . waitpoints [ 0 ] . id ;
1451+
1452+ // Replica lag: it receives the snapshot ROWS (incl. completedWaitpointOrder) but NOT the
1453+ // _completedWaitpoints join rows or the waitpoint - the exact state that drops the resume.
1454+ await copySnapshotsToReplica ( prisma , schemaOnlyPrisma , scenario . run . id ) ;
1455+
1456+ const result = await engine . getSnapshotsSince ( {
1457+ runId : scenario . run . id ,
1458+ snapshotId : scenario . snapshots [ 0 ] . id ,
1459+ } ) ;
1460+
1461+ expect ( result ) . not . toBeNull ( ) ;
1462+ const latest = result ! [ result ! . length - 1 ] ;
1463+ // The runner must receive the completed waitpoint (repaired from the primary), not an empty set.
1464+ expect ( latest . completedWaitpoints . map ( ( w ) => w . id ) ) . toEqual ( [ waitpointId ] ) ;
1465+ } finally {
1466+ await engine . quit ( ) ;
1467+ }
1468+ }
1469+ ) ;
1470+
1471+ // completedWaitpointOrder can contain the same waitpoint id twice (a run batched under one idempotency
1472+ // key), while the _completedWaitpoints join is deduped. The read-repair must compare DISTINCT ids, or it
1473+ // fires on every poll for such a snapshot even against a caught-up replica - silently defeating offload.
1474+ containerTest (
1475+ "does not repair from the primary when completedWaitpointOrder has duplicates but the join is complete" ,
1476+ async ( { prisma, redisOptions } ) => {
1477+ const authenticatedEnvironment = await setupAuthenticatedEnvironment ( prisma , "PRODUCTION" ) ;
1478+ const engine = new RunEngine ( {
1479+ prisma,
1480+ worker : { redis : redisOptions , workers : 1 , tasksPerWorker : 10 , pollIntervalMs : 100 } ,
1481+ queue : { redis : redisOptions } ,
1482+ runLock : { redis : redisOptions } ,
1483+ machines : {
1484+ defaultMachine : "small-1x" ,
1485+ machines : {
1486+ "small-1x" : { name : "small-1x" as const , cpu : 0.5 , memory : 0.5 , centsPerMs : 0.0001 } ,
1487+ } ,
1488+ baseCostInCents : 0.0001 ,
1489+ } ,
1490+ tracer : trace . getTracer ( "test" , "0.0.0" ) ,
1491+ } ) ;
1492+
1493+ try {
1494+ const scenario = await setupTestScenario ( prisma , authenticatedEnvironment , {
1495+ totalWaitpoints : 1 ,
1496+ outputSizeKB : 1 ,
1497+ snapshotConfigs : [
1498+ { status : "RUN_CREATED" , completedWaitpointCount : 0 } ,
1499+ { status : "EXECUTING" , completedWaitpointCount : 1 } ,
1500+ ] ,
1501+ } ) ;
1502+ const waitpointId = scenario . waitpoints [ 0 ] . id ;
1503+ const latestSnapshot = scenario . snapshots [ scenario . snapshots . length - 1 ] ;
1504+ // The join stays a single row; the order column lists the same id twice (the batch-dup case).
1505+ await prisma . taskRunExecutionSnapshot . update ( {
1506+ where : { id : latestSnapshot . id } ,
1507+ data : { completedWaitpointOrder : [ waitpointId , waitpointId ] } ,
1508+ } ) ;
1509+
1510+ // Count join reads on the repair client; the replica read (`prisma`) is already complete, so a
1511+ // correct repair must NEVER touch the repair client for this snapshot.
1512+ const repairCalls = { queryRaw : 0 } ;
1513+ const repairClient = new Proxy ( prisma , {
1514+ get ( target , prop ) {
1515+ if ( prop === "$queryRaw" ) {
1516+ return ( ...args : unknown [ ] ) => {
1517+ repairCalls . queryRaw ++ ;
1518+ return ( target as unknown as { $queryRaw : ( ...a : unknown [ ] ) => unknown } ) . $queryRaw (
1519+ ...args
1520+ ) ;
1521+ } ;
1522+ }
1523+ const value = ( target as Record < string | symbol , unknown > ) [ prop ] ;
1524+ return typeof value === "function" ? value . bind ( target ) : value ;
1525+ } ,
1526+ } ) as unknown as PrismaClient ;
1527+
1528+ const result = await getExecutionSnapshotsSince (
1529+ prisma ,
1530+ scenario . run . id ,
1531+ scenario . snapshots [ 0 ] . id ,
1532+ undefined ,
1533+ repairClient
1534+ ) ;
1535+
1536+ const latest = result [ result . length - 1 ] ;
1537+ // The duplicate is intentional (batch dup) - enhance expands by completedWaitpointOrder.
1538+ expect ( latest . completedWaitpoints . map ( ( w ) => w . id ) ) . toEqual ( [ waitpointId , waitpointId ] ) ;
1539+ // No spurious primary read: distinct(order) === join count, so the repair must not fire.
1540+ expect ( repairCalls . queryRaw ) . toBe ( 0 ) ;
1541+ } finally {
1542+ await engine . quit ( ) ;
1543+ }
1544+ }
1545+ ) ;
14011546} ) ;
0 commit comments