Setting the file. One moment.
Import Interruption · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page This file
Number 45.85
Position 85 of 182
Type JavaScript
Size 14 KB
Lines 250 lib/ import-interruption.js
JavaScript · 250 lines · 14 KB
11 // knowledge is not laid out yet.
12 const lazy = ( name ) => () => require (name);
13 const reportLib = lazy ( './completion-report.js' );
14 const localLib = lazy ( './local-state.js' );
15
16 const SIGNAL_EXIT_CODES = { SIGINT: 130 , SIGTERM: 143 };
17 // Long enough for one in-flight batch to settle; a supervisor's SIGKILL usually follows within
18 // a minute, and the journal fallback covers that case.
19 const FINALIZE_GRACE_MS = 60000 ;
20 const IMPORTED = new Set ([ 'imported' ]);
21 const PRESENT = new Set ([ 'skipped_already_imported' ]);
22 const FAILED = new Set ([ 'failed_retryable' , 'failed_terminal' ]);
23
24 function interruptionFromSignal ( signal , at = new Date ()) {
25 return { kind: 'signal' , signal, code: null , at: new Date (at). toISOString () };
26 }
27
28 // Codes only: an error message can echo a record value.
29 function interruptionFromError ( error , at = new Date ()) {
30 const auth = Boolean (error) && error.interruption === true && error.code === 'wix_auth_expired' ;
31 const code = error && typeof error.code === 'string' && error.code ? error.code : (error && error.name) || null ;
32 return { kind: auth ? 'wix_auth_expired' : 'error' , signal: null , code, at: new Date (at). toISOString () };
33 }
34
35 async function readJson ( file ) {
36 try { return JSON . parse ( await fs. readFile (file, 'utf8' )); } catch (error) { if (error.code === 'ENOENT' ) return null ; throw error; }
37 }
38
39 // Rebuilds per-entity counts from the attempt journal: the latest row per source key, grouped by
40 // target entity type. Records the run never reached have no row and are not counted; the
41 // interruption action says so.
42 function journalCompleteness ( rows ) {
43 const latest = new Map ();
44 for ( const row of rows) if (row && row.sourceStableKey && row.phase !== 'setup' ) latest. set ( `${ row . targetEntityType || row . writeSpecId } \0 ${ row . sourceStableKey }` , row);
45 const byEntity = new Map ();
46 for ( const row of latest. values ()) {
47 const entity = row.targetEntityType || row.writeSpecId || 'unknown' ;
48 const counts = byEntity. get (entity) || { entity, subtype: 'all' , inScope: 0 , attempted: 0 , imported: 0 , alreadyPresentByCrosswalk: 0 , deferred: 0 , failed: 0 , unresolved: 0 , sourceKeys: [] };
49 if (row.status === 'skipped_out_of_scope' ) { byEntity. set (entity, counts); continue ; }
50 counts.inScope += 1 ; counts.sourceKeys. push (row.sourceStableKey);
51 if ( IMPORTED . has (row.status)) { counts.imported += 1 ; counts.attempted += 1 ; }
52 else if ( PRESENT . has (row.status)) counts.alreadyPresentByCrosswalk += 1 ;
53 else if ( FAILED . has (row.status)) { counts.failed += 1 ; counts.attempted += 1 ; }
54 else if (row.status === 'deferred' || row.status === 'skipped_safe_mode_blocked' || row.status === 'skipped_unexpected' ) counts.deferred += 1 ;
55 // Uncertain writes (started, succeeded_unverified, needs_verification) are left out of every
56 // counter here: attachAttemptReasons fills the gap from the same rows, with their target ids.
57 else counts.attempted += 1 ;
58 byEntity. set (entity, counts);
59 }
60 return [ ... byEntity. values ()]. sort (( a , b ) => a.entity. localeCompare (b.entity));
61 }
62
63 async function writeAtomic ( file , text ) {
64 const temp = `${ file }.${ process . pid }.tmp` ;
65 await fs. mkdir (path. dirname (file), { recursive: true });
66 await fs. writeFile (temp, text);
67 await fs. rename (temp, file);
68 }
69
70 async function manifestDigest ( projectDir ) {
71 const manifest = await readJson (path. join (projectDir, 'execution/execution-manifest.json' ));
72 return manifest && typeof manifest.mappingPlanDigest === 'string' ? manifest.mappingPlanDigest : null ;
73 }
74
75
76 // Makes sure `execution/completion-report.json` records this stop. Three cases:
77 // - finalize already wrote this run's report with the interruption: nothing to do;
78 // - finalize wrote this run's report without it (an older generated importer): stamp it;
79 // - there is no report for this run: rebuild one from the attempt journal.
80 async function ensureInterruptedCompletionReport ( projectDir , { runId = null , interruption , siteId = null , mappingPlanDigest = null } = {}) {
81 const normalized = reportLib (). normalizeInterruption (interruption);
82 const existing = await readJson (path. join (projectDir, reportLib (). COMPLETION_REPORT_FILE ));
83 if (existing && existing.interruption && ( ! runId || existing.runId === runId)) return { action: 'kept' , report: existing };
84 if (existing && runId && existing.runId === runId) {
85 // Already joined by the writer: stamp in place, never re-join (that would repeat the joined
86 // operator actions).
87 const stamped = reportLib (). stampInterruption (existing, normalized);
88 await writeAtomic (path. join (projectDir, reportLib (). COMPLETION_REPORT_FILE ), `${ JSON . stringify ( stamped , null , 2 ) } \n ` );
89 await writeAtomic (path. join (projectDir, reportLib (). COMPLETION_SUMMARY_FILE ), reportLib (). renderCompletionSummary (stamped));
90 return { action: 'stamped' , report: stamped };
91 }
92 const { rows } = await localLib (). loadAttemptJournal (projectDir). catch (( error ) => { if (error.code === 'ENOENT' ) return { rows: [] }; throw error; });
93 const runIds = [ ...new Set (rows. map (( row ) => row.runId). filter (( id ) => typeof id === 'string' && id))];
94 const rebuiltRunId = runId || runIds[runIds. length - 1 ] || 'interrupted-run' ;
95 const completeness = journalCompleteness (rows);
96 const lastWriteAt = rows. map (( row ) => row.ts). filter (( ts ) => typeof ts === 'string' && ! Number. isNaN (Date. parse (ts))). sort (). pop () || normalized.at;
97 const rebuilt = reportLib (). createCompletionReport ({
98 runId: rebuiltRunId,
99 siteId,
100 lastWriteAt,
101 interruption: { ... normalized, rebuiltFromJournal: true },
102 entityCompleteness: runIds. length ? reportLib (). attachAttemptReasons (completeness, rows, { runIds }) : completeness,
103 stageResiduals: [],
104 orderHistoryInScope: completeness. some (( row ) => /( ^| [/:._-] )orders ?$ / i . test (row.entity)),
105 warnings: [ 'Rebuilt from the attempt journal after the import stopped before its completion step; records the run never reached are not counted.' ],
106 });
107 const written = await reportLib (). writeCompletionReport (projectDir, rebuilt, { runId: rebuiltRunId, mappingPlanDigest: mappingPlanDigest || await manifestDigest (projectDir) });
108 return { action: 'rebuilt' , report: written.report };
109 }
110
111 // The cutover-facing artifacts for a stopped run. Best effort: a refusal here (a stale census, an
112 // unapproved mapping) is the same refusal the router would hit, and it never hides the report.
113 async function refreshInterruptedHandoff ( projectDir , { stderr = process.stderr } = {}) {
114 const value = await readJson (path. join (projectDir, reportLib (). COMPLETION_REPORT_FILE ));
115 try {
116 if (value && value.managementImportMode !== 'quick' ) await require ( './discovered-set-status.js' ). buildDiscoveredSetStatus (projectDir);
117 } catch (error) {
118 stderr. write ( `interrupted handoff: discovered-set status not refreshed (${ error . code || error . message }) \n ` );
119 }
120 const outcome = await require ( './website-handoff.js' ). generatePostExecutionHandoff (projectDir);
121 if ( ! outcome.ok) stderr. write ( `interrupted handoff: post-execution handoff refused (${ outcome . reason }) \n ` );
122 return outcome;
123 }
124
125 async function recordInterruption ( projectDir , options , { stderr = process.stderr } = {}) {
126 try {
127 const result = await ensureInterruptedCompletionReport (projectDir, options);
128 await refreshInterruptedHandoff (projectDir, { stderr });
129 return result;
130 } catch (error) {
131 stderr. write ( `interrupted import: completion report not written (${ error . code || error . message }); run scripts/interrupted-completion-report.js \n ` );
132 return { action: 'failed' , error };
133 }
134 }
135
136 function delay ( ms ) {
137 let timer;
138 const promise = new Promise (( resolve ) => { timer = setTimeout (resolve, ms); });
139 return { promise, cancel : () => clearTimeout (timer) };
140 }
141
142 // Awaits `promise` for at most `ms`; a step that hangs past it is reported and abandoned, so the
143 // whole shutdown stays bounded.
144 async function bounded ( promise , ms , label , stderr ) {
145 const timer = delay (ms);
146 try {
147 const outcome = await Promise . race ([promise. then (() => 'done' , ( error ) => { stderr. write ( `${ label } failed (${ error . code || error . message }) \n ` ); return 'failed' ; }), timer.promise. then (() => 'timeout' )]);
148 if (outcome === 'timeout' ) stderr. write ( `${ label } did not finish within ${ ms } ms; the journal is the record \n ` );
149 return outcome;
150 } finally { timer. cancel (); }
151 }
152
153 function latchStop ( projectDir , signal ) {
154 if ( ! projectDir) return ;
155 try { require ( './wix-auth.js' ). latchStop (projectDir, signal); } catch (error) {
156 // A project that never vendored wix-auth.js has no token-source client to stop.
157 if ( ! (error && error.code === 'MODULE_NOT_FOUND' )) throw error;
158 }
159 }
160
161 // Runs `dispatch({ stopRequested })`, then `finalize(interruption, result)` at most once.
162 // - normal end: finalize(null, result), return the result. A stop during finalize, or a finalize
163 // that throws, never calls finalize again: the report is stamped or rebuilt instead.
164 // - dispatch throws: finalize(error interruption), record the stop, rethrow.
165 // - SIGTERM/SIGINT: latch the project (no new Wix request, no new `started` row), let in-flight
166 // writes settle for `graceMs`. If dispatch settled, finalize from memory with what it returned;
167 // if not, writes are still outstanding and only the journal is trusted. Record the stop, exit
168 // 143/130. Every step is bounded by `stepMs`; a second signal exits at once.
169 // `onInterrupted(interruption)` stamps or rebuilds the report and writes the handoff.
170 async function runImportWithFinalize ({ dispatch , finalize , onInterrupted = async () => {}, projectDir , graceMs = FINALIZE_GRACE_MS , stepMs = FINALIZE_GRACE_MS , proc = process, stderr = process.stderr }) {
171 // Without the project there is no latch, and a stopped run would keep starting writes.
172 if ( typeof projectDir !== 'string' || ! projectDir) throw new TypeError ( 'runImportWithFinalize requires projectDir' );
173 let finalized = false ;
174 const finalizeOnce = ( interruption , result ) => {
175 if (finalized) return Promise . resolve ();
176 finalized = true ;
177 return Promise . resolve (). then (() => finalize (interruption, result));
178 };
179 let signalled = null ;
180 let resolveSignal;
181 const signal = new Promise (( resolve ) => { resolveSignal = resolve; });
182 const handlers = Object. keys ( SIGNAL_EXIT_CODES ). map (( name ) => {
183 const handler = () => {
184 if (signalled) { proc. exit ( SIGNAL_EXIT_CODES [name]); return ; }
185 signalled = interruptionFromSignal (name);
186 latchStop (projectDir, name);
187 stderr. write ( `import stopping on ${ name }: no new writes; recording what is known before exit \n ` );
188 resolveSignal (signalled);
189 };
190 proc. on (name, handler);
191 return [name, handler];
192 });
193 const settled = { done: false , result: undefined };
194 const work = Promise . resolve ()
195 . then (() => dispatch ({ stopRequested : () => Boolean (signalled) }))
196 . then (( result ) => { settled.done = true ; settled.result = result; return result; }, ( error ) => { settled.done = true ; throw error; });
197 const recordStop = ( interruption ) => bounded ( Promise . resolve (). then (() => onInterrupted (interruption)), stepMs, 'recording the stop' , stderr);
198 const exitOnSignal = async ( interruption ) => {
199 await recordStop (interruption);
200 proc. exit ( SIGNAL_EXIT_CODES [interruption.signal]);
201 };
202 try {
203 const first = await Promise . race ([work. then (( result ) => ({ result })), signal. then (( interruption ) => ({ interruption }))]);
204 if ( ! first.interruption) {
205 let finalizeError = null ;
206 const finalizing = finalizeOnce ( null , first.result). then (() => 'done' , ( error ) => { finalizeError = error; return 'failed' ; });
207 const outcome = await Promise . race ([finalizing, signal. then (() => 'signal' )]);
208 if (outcome === 'signal' ) {
209 // Let the running finalizer land first (the writer's latch fence marks its report
210 // aborted), so recording the stop keeps or stamps that report instead of racing it.
211 await bounded (finalizing, stepMs, 'import finalization after the stop' , stderr);
212 await exitOnSignal (signalled);
213 return undefined ;
214 }
215 if (finalizeError) {
216 // Every write finished but no report was written: rebuild it, then fail the run.
217 await recordStop ( interruptionFromError (finalizeError));
218 throw finalizeError;
219 }
220 return first.result;
221 }
222 const grace = delay (graceMs);
223 await Promise . race ([work. catch (() => {}), grace.promise]);
224 grace. cancel ();
225 if (settled.done) await bounded ( finalizeOnce (first.interruption, settled.result), stepMs, 'import finalization after the stop' , stderr);
226 else stderr. write ( 'writes still in flight after the grace period; the report is rebuilt from the attempt journal \n ' );
227 await exitOnSignal (first.interruption);
228 return undefined ;
229 } catch (error) {
230 if (finalized) throw error;
231 const interruption = interruptionFromError (error);
232 await bounded ( finalizeOnce (interruption, undefined ), stepMs, 'import finalization after the error' , stderr);
233 await recordStop (interruption);
234 throw error;
235 } finally {
236 for ( const [ name , handler ] of handlers) proc. removeListener (name, handler);
237 }
238 }
239
240 module . exports = {
241 SIGNAL_EXIT_CODES,
242 FINALIZE_GRACE_MS,
243 interruptionFromSignal,
244 interruptionFromError,
245 journalCompleteness,
246 ensureInterruptedCompletionReport,
247 refreshInterruptedHandoff,
248 recordInterruption,
249 runImportWithFinalize,
250 };