@@ -18,7 +18,14 @@ import {
1818 type ResourceMonitor ,
1919} from "./resourceMonitor.js" ;
2020import { KubernetesWorkloadManager } from "./workloadManager/kubernetes.js" ;
21- import { RunCrdWorkloadManager } from "./workloadManager/runCrd.js" ;
21+ import {
22+ RunCrdWorkloadManager ,
23+ RunnerRestoreInformer ,
24+ restoreHeartbeat ,
25+ settleRestoreFailure ,
26+ type RestoreRunner ,
27+ type RestoreWatchResult ,
28+ } from "./workloadManager/runCrd.js" ;
2229import { DockerWorkloadManager } from "./workloadManager/docker.js" ;
2330import { ComputeWorkloadManager } from "./workloadManager/compute.js" ;
2431import {
@@ -81,6 +88,37 @@ const outboundRequestDuration = new Histogram({
8188 registers : [ register ] ,
8289} ) ;
8390
91+ const restoreWatchesInFlight = new Gauge ( {
92+ name : "supervisor_restore_watches_in_flight" ,
93+ help : "Restore Runners the supervisor is waiting on to start or fail." ,
94+ registers : [ register ] ,
95+ } ) ;
96+
97+ const restoreOutcomesTotal = new Counter ( {
98+ name : "supervisor_restore_outcomes_total" ,
99+ help : "Restore watches ended, by outcome and reason: the operator's failure reason, or Timeout, RunnerGone or ReadFailed from the watch itself." ,
100+ labelNames : [ "outcome" , "reason" ] ,
101+ registers : [ register ] ,
102+ } ) ;
103+
104+ const restoreDuration = new Histogram ( {
105+ name : "supervisor_restore_duration_seconds" ,
106+ help : "Time from a restore Runner's creation to the supervisor seeing it Running or Failed." ,
107+ labelNames : [ "outcome" ] ,
108+ buckets : [ 1 , 2.5 , 5 , 10 , 20 , 30 , 60 , 120 , 300 , 600 , 900 ] ,
109+ registers : [ register ] ,
110+ } ) ;
111+
112+ const restoreReportsTotal = new Counter ( {
113+ name : "supervisor_restore_reports_total" ,
114+ help : "Failed restores reported to the platform, by outcome (requeue or fail) and result: ok, conflict when the run had already moved on, or error." ,
115+ labelNames : [ "outcome" , "result" ] ,
116+ registers : [ register ] ,
117+ } ) ;
118+
119+ /** `uid` is the Runner the watch is bound to; `abort` ends a watch a newer Runner replaced. */
120+ type WatchedRestore = { uid ?: string ; snapshotFriendlyId : string ; abort : AbortController } ;
121+
84122class ManagedSupervisor {
85123 private readonly workerSession : SupervisorSession ;
86124 private readonly metricsServer ?: HttpServer ;
@@ -89,6 +127,7 @@ class ManagedSupervisor {
89127 private readonly workloadManagerBackend : "compute" | "kubernetes" | "run-crd" | "docker" ;
90128 private readonly computeManager ?: ComputeWorkloadManager ;
91129 private readonly runCrdManager ?: RunCrdWorkloadManager ;
130+ private readonly restoreInformer ?: RunnerRestoreInformer ;
92131 private readonly logger = new SimpleStructuredLogger ( "managed-supervisor" ) ;
93132 private readonly resourceMonitor : ResourceMonitor ;
94133 private readonly checkpointClient ?: CheckpointClient ;
@@ -98,7 +137,8 @@ class ManagedSupervisor {
98137 private readonly failedPodHandler ?: FailedPodHandler ;
99138 private readonly tracing ?: OtlpTraceService ;
100139 private readonly backpressureMonitors : BackpressureMonitor [ ] = [ ] ;
101- private readonly watchedRestores = new Set < string > ( ) ;
140+ /** Restore Runners being watched, each with the latest snapshot dequeued for it. */
141+ private readonly watchedRestores = new Map < string , WatchedRestore > ( ) ;
102142 private readonly backpressureRedis ?: Redis ;
103143
104144 private readonly isKubernetes = isKubernetesEnvironment ( env . KUBERNETES_FORCE_ENABLED ) ;
@@ -198,6 +238,27 @@ class ManagedSupervisor {
198238 this . workloadManager = computeManager ;
199239 this . workloadManagerBackend = "compute" ;
200240 } else if ( this . isKubernetes && env . KUBERNETES_RUN_CRD_ENABLED ) {
241+ // Only a process that dequeues creates resumes, and only the microvm lane restores.
242+ if (
243+ env . KUBERNETES_RUNNER_RESTORE_INFORMER_ENABLED &&
244+ env . TRIGGER_DEQUEUE_ENABLED &&
245+ env . KUBERNETES_RUNNER_RUNTIME === "microvm"
246+ ) {
247+ this . restoreInformer = new RunnerRestoreInformer ( {
248+ namespace : env . KUBERNETES_NAMESPACE ,
249+ onUnwatchedFailure : ( { runFriendlyId, snapshotFriendlyId, runnerId, outcome } ) =>
250+ void this . reportRestoreFailure ( runFriendlyId , snapshotFriendlyId , runnerId , outcome ) ,
251+ onUnwatchedRestore : ( { runnerId, uid, runFriendlyId, snapshotFriendlyId } ) => {
252+ if ( this . runCrdManager && runFriendlyId && snapshotFriendlyId ) {
253+ void this . watchRestore (
254+ this . runCrdManager ,
255+ { runFriendlyId, snapshotFriendlyId } ,
256+ { runnerId, uid }
257+ ) ;
258+ }
259+ } ,
260+ } ) ;
261+ }
201262 const runCrdManager = new RunCrdWorkloadManager ( {
202263 ...workloadManagerOptions ,
203264 namespace : env . KUBERNETES_NAMESPACE ,
@@ -207,6 +268,9 @@ class ManagedSupervisor {
207268 delayMs : env . COMPUTE_SNAPSHOT_DELAY_MS ,
208269 dispatchLimit : env . COMPUTE_SNAPSHOT_DISPATCH_LIMIT ,
209270 } ,
271+ suspendPollMs : env . KUBERNETES_RUNNER_POLL_INTERVAL_MS ,
272+ restoreTimeoutMs : env . KUBERNETES_RUNNER_RESTORE_TIMEOUT_MS ,
273+ restoreInformer : this . restoreInformer ,
210274 } ) ;
211275 this . runCrdManager = runCrdManager ;
212276 this . workloadManager = runCrdManager ;
@@ -710,12 +774,16 @@ class ManagedSupervisor {
710774 ) {
711775 const restoreStart = performance . now ( ) ;
712776 try {
713- const runnerId = await manager . restore ( await this . createOptionsFor ( message ) , checkpoint ) ;
777+ const restore = await manager . restore ( await this . createOptionsFor ( message ) , checkpoint ) ;
714778 recordPhaseSince ( "restore" , restoreStart , undefined ) ;
715779 setExtra ( fromContext ( ) , "did_restore" , true ) ;
716780 this . logger . debug ( "Runner restore created" , { runId : message . run . id } ) ;
717781 // Not awaited: the resume can take minutes, and the dequeue is done.
718- void this . watchRestore ( manager , message . run . friendlyId , runnerId ) ;
782+ void this . watchRestore (
783+ manager ,
784+ { runFriendlyId : message . run . friendlyId , snapshotFriendlyId : message . snapshot . friendlyId } ,
785+ restore
786+ ) ;
719787 } catch ( error ) {
720788 recordPhaseSince (
721789 "restore" ,
@@ -730,31 +798,157 @@ class ManagedSupervisor {
730798 /** A resume that fails on the node is otherwise silent until the run's heartbeat stalls. */
731799 private async watchRestore (
732800 manager : RunCrdWorkloadManager ,
733- runFriendlyId : string ,
734- runnerId : string
801+ run : { runFriendlyId : string ; snapshotFriendlyId : string } ,
802+ restore : RestoreRunner
735803 ) {
736- // A redelivered restore finds the same Runner, which needs only one watch.
737- if ( this . watchedRestores . has ( runnerId ) ) {
804+ const { runnerId, uid } = restore ;
805+ const { runFriendlyId } = run ;
806+ // A redelivered restore finds the same Runner, which needs only one watch,
807+ // but its beats and report must name the snapshot dequeued last.
808+ const watched = this . watchedRestores . get ( runnerId ) ;
809+ if ( watched && ( watched . uid === undefined || uid === undefined || watched . uid === uid ) ) {
810+ watched . snapshotFriendlyId = run . snapshotFriendlyId ;
738811 return ;
739812 }
740- this . watchedRestores . add ( runnerId ) ;
813+ // The redelivery replaced the Runner, so the watch on the old one has nothing left to say.
814+ watched ?. abort . abort ( ) ;
815+ const entry : WatchedRestore = {
816+ uid,
817+ snapshotFriendlyId : run . snapshotFriendlyId ,
818+ abort : new AbortController ( ) ,
819+ } ;
820+ this . watchedRestores . set ( runnerId , entry ) ;
821+ restoreWatchesInFlight . inc ( ) ;
822+ const heartbeat = restoreHeartbeat (
823+ ( ) => this . heartbeatRestore ( runFriendlyId , entry . snapshotFriendlyId , runnerId ) ,
824+ env . KUBERNETES_RUNNER_RESTORE_HEARTBEAT_INTERVAL_MS
825+ ) ;
741826 const outcome = await manager
742- . awaitRestore ( runnerId )
743- . finally ( ( ) => this . watchedRestores . delete ( runnerId ) ) ;
827+ . awaitRestore ( restore , heartbeat . onPhase , entry . abort . signal )
828+ . finally ( ( ) => {
829+ heartbeat . stop ( ) ;
830+ restoreWatchesInFlight . dec ( ) ;
831+ if ( this . watchedRestores . get ( runnerId ) === entry ) {
832+ this . watchedRestores . delete ( runnerId ) ;
833+ }
834+ } ) ;
835+ if ( entry . abort . signal . aborted ) {
836+ return ;
837+ }
838+ const { snapshotFriendlyId } = entry ;
839+ const outcomeLabel = outcome . ok ? "started" : "failed" ;
840+ restoreOutcomesTotal . inc ( {
841+ outcome : outcomeLabel ,
842+ reason : outcome . ok ? "Started" : outcome . reason ,
843+ } ) ;
844+ if ( outcome . createdAt ) {
845+ restoreDuration . observe (
846+ { outcome : outcomeLabel } ,
847+ ( Date . now ( ) - outcome . createdAt . getTime ( ) ) / 1000
848+ ) ;
849+ }
744850 if ( outcome . ok ) {
745851 this . logger . debug ( "Runner restore started" , { runFriendlyId, runnerId } ) ;
746852 return ;
747853 }
748- this . logger . error ( "Runner restore failed (run-crd)" , {
854+ await this . reportRestoreFailure ( runFriendlyId , snapshotFriendlyId , runnerId , outcome ) ;
855+ }
856+
857+ /** Keeps the dequeued snapshot from stalling while the operator brings the resume up. */
858+ private async heartbeatRestore (
859+ runFriendlyId : string ,
860+ snapshotFriendlyId : string ,
861+ runnerId : string
862+ ) {
863+ const result = await this . workerSession . httpClient . heartbeatRun (
749864 runFriendlyId ,
750- runnerId,
751- error : outcome . error ,
752- } ) ;
865+ snapshotFriendlyId ,
866+ { } ,
867+ runnerId
868+ ) ;
869+ if ( ! result . success ) {
870+ this . logger . warn ( "Restore heartbeat failed" , {
871+ runFriendlyId,
872+ snapshotFriendlyId,
873+ runnerId,
874+ error : result . error ,
875+ } ) ;
876+ }
877+ }
878+
879+ private async reportRestoreFailure (
880+ runFriendlyId : string | undefined ,
881+ snapshotFriendlyId : string | undefined ,
882+ runnerId : string ,
883+ outcome : RestoreWatchResult & { ok : false }
884+ ) {
885+ const fields = { runFriendlyId, snapshotFriendlyId, runnerId, reason : outcome . reason } ;
886+ this . logger . error ( "Runner restore failed (run-crd)" , { ...fields , error : outcome . error } ) ;
887+ if ( ! runFriendlyId ) {
888+ return ;
889+ }
753890 await this . workerSession . httpClient . sendDebugLog ( runFriendlyId , {
754891 time : new Date ( ) ,
755892 message : "restore failed on the node" ,
756893 properties : { runnerId, error : outcome . error } ,
757894 } ) ;
895+ const manager = this . runCrdManager ;
896+ if ( ! snapshotFriendlyId || ! manager ) {
897+ return ;
898+ }
899+ const settled = await settleRestoreFailure (
900+ { runnerId, outcome } ,
901+ {
902+ deleteRunner : ( name , uid ) => manager . deleteRestoreRunner ( name , uid ) ,
903+ report : ( body ) =>
904+ this . workerSession . httpClient . reportRestoreOutcome (
905+ runFriendlyId ,
906+ snapshotFriendlyId ,
907+ body ,
908+ runnerId
909+ ) ,
910+ }
911+ ) ;
912+ switch ( settled . action ) {
913+ case "none" :
914+ this . logger . info ( "Restore failure left to the stall timeout" , fields ) ;
915+ return ;
916+ case "kept" :
917+ if ( settled . forbidden ) {
918+ this . logger . error (
919+ "Restore Runner delete forbidden, so the run was left to the stall timeout; grant delete on runners" ,
920+ { ...fields , error : settled . error }
921+ ) ;
922+ return ;
923+ }
924+ this . logger . warn ( "Restore Runner not deleted, so the run was not requeued" , {
925+ ...fields ,
926+ error : settled . error ,
927+ } ) ;
928+ return ;
929+ case "reported" :
930+ restoreReportsTotal . inc ( { outcome : settled . outcome , result : settled . result } ) ;
931+ if ( settled . result === "error" ) {
932+ this . logger . warn ( "Restore outcome report failed" , {
933+ ...fields ,
934+ outcome : settled . outcome ,
935+ error : settled . error ,
936+ } ) ;
937+ } else {
938+ this . logger . info (
939+ settled . result === "conflict"
940+ ? "Restore outcome not applied, the run has moved on"
941+ : "Restore outcome reported" ,
942+ { ...fields , outcome : settled . outcome }
943+ ) ;
944+ }
945+ if ( settled . cleanupError ) {
946+ this . logger . warn ( "Failed restore Runner not deleted after its report" , {
947+ ...fields ,
948+ error : settled . cleanupError ,
949+ } ) ;
950+ }
951+ }
758952 }
759953
760954 private async createWorkload ( message : DequeuedMessage , timings : WarmStartTimings ) {
@@ -868,6 +1062,7 @@ class ManagedSupervisor {
8681062 this . backpressureMonitors . forEach ( ( m ) => m . start ( ) ) ;
8691063 await this . podCleaner ?. start ( ) ;
8701064 await this . failedPodHandler ?. start ( ) ;
1065+ await this . restoreInformer ?. start ( ) ;
8711066 await this . metricsServer ?. start ( ) ;
8721067
8731068 if ( env . TRIGGER_WORKLOAD_API_ENABLED ) {
@@ -897,6 +1092,7 @@ class ManagedSupervisor {
8971092 await this . backpressureRedis ?. quit ( ) ;
8981093 await this . podCleaner ?. stop ( ) ;
8991094 await this . failedPodHandler ?. stop ( ) ;
1095+ await this . restoreInformer ?. stop ( ) ;
9001096 await this . metricsServer ?. stop ( ) ;
9011097 }
9021098}
0 commit comments