// Rollback of an apply. // // Before an apply mutates anything, we capture a RESTORE POINT: the pre-apply // metadata pack (schema/views/pages/triggers/roles/config/code pages) plus the RAW // rows the apply can change or destroy: every managed/starter table (full rows), // every table whose drop the delete gate WILL allow (full rows), and for a // surviving table whose field drop the gate will allow, only the primary key and // the doomed columns. A rollback then: // 1. re-syncs the metadata + config back to the pre-apply pack with the normal // plan/apply engine in MIRROR mode (whatever the instance's sync mode, so the // apply's creates are removed), applying the detected inverse table renames; // 2. removes rows the apply inserted into managed/starter tables, then restores // the captured rows by primary key (update the captured columns of a row that // still exists, insert a missing row WITH its original pk so FKs keep // pointing at it), parents before children; // 3. un-marks starter tables the apply seeded, so they can be seeded again. // A plan is rolled back at most once (atomic status claim), under the site-wide // apply lock (lib/siteLock.js) like an apply: its metadata step is an apply run // under the same lease. // // Files need no byte capture: apply never deletes old file bytes (File.from_contents // writes NEW files), so restoring a row's old File-field VALUE reverts the file // reference to the still-present old file. (Exception: a managed row pruned from a // File field with Saltcorn's also_delete_file attribute takes its file with it.) const db = require("@saltcorn/data/db"); const Table = require("@saltcorn/data/models/table"); const capture = require("./capture"); const rowId = require("./rowIdentity"); const rowsLib = require("./rows"); const siteLock = require("./siteLock"); const layoutFiles = require("./layoutFiles"); const { gateDrop } = require("./deletes"); const { DATA_MODES, PLAN_STATUS, SYNC_MODES, isLockedTable, isSyncedMode } = require("./constants"); const { ROW_UUID_FIELD, withoutPk } = rowId; // Keep at most this many recent restore points (one is written per apply); each can // be a large JSON blob, so the cap is tighter than for plans. const ROLLBACK_RETENTION = 50; // The statuses a plan with a restore point can be rolled back from: applied, or // pending again after an apply that failed part-way. const ROLLBACK_FROM = [PLAN_STATUS.APPLIED, PLAN_STATUS.PENDING]; // name -> null (capture full rows) | [field names] (capture pk + these columns). const tablesToCapture = async (plan, gateOpts) => { const out = {}; for (const [name, mode] of Object.entries(await rowsLib.modeMap())) { if (isSyncedMode(mode) && !isLockedTable(name) && Table.findOne({ name })) { // Backfill identities BEFORE capturing: a row inserted locally since the // last backfill has no uuid yet, and rollback matches synced rows by uuid. await rowId.ensureManagedSchema(name); out[name] = null; } } const willDrop = async (name) => { const tbl = Table.findOne({ name }); if (!tbl || isLockedTable(name)) { return false; } const rowCount = await tbl.countRows(); return rowCount > 0 && gateDrop(Object.assign({ name, rowCount }, gateOpts)).allowed; }; const dropCandidates = plan.entities.tables.delete.map((d) => d.name).concat(plan.entities.tables.rename.map((r) => r.from)); for (const name of dropCandidates) { if (await willDrop(name)) { out[name] = null; } } for (const u of plan.entities.tables.update) { if (u.fields.delete.length > 0 && !(u.name in out) && (await willDrop(u.name))) { out[u.name] = u.fields.delete.map((f) => f.name); } } return out; }; const pickColumns = (row, names) => { const out = {}; for (const n of names) { if (Object.prototype.hasOwnProperty.call(row, n)) { out[n] = row[n]; } } return out; }; // gateOpts = the apply's { onDelete, prunable, confirmDataDrops } (what the delete // gate will allow decides which rows are worth capturing). const saveRestorePoint = async (planId, metadataPack, plan, gateOpts) => { const rawRows = {}; for (const [name, columns] of Object.entries(await tablesToCapture(plan, gateOpts))) { const tbl = Table.findOne({ name }); const rows = await tbl.getRows({}, {}); rawRows[name] = columns ? rows.map((r) => pickColumns(r, [tbl.pk_name].concat(columns))) : rows; } // Pre-apply LAYOUT/LOGO file index (content hash -> local file value). On // rollback, a pre-apply file (e.g. an old logo) is still on disk but no longer // REFERENCED, so it is absent from the target's live index and there is no peer to // fetch it from; this seed lets rollback resolve its placeholders back to the old // on-disk file. (Row-file rollback is handled separately by restoreTableRows.) const fileIndex = Object.fromEntries(await layoutFiles.indexLocalFiles(new Map())); const schema = db.getTenantSchemaPrefix(); const now = new Date().toISOString(); // upsert (a re-apply of the same plan_id replaces its restore point) await db.query(`DELETE FROM ${schema}_ss_rollback WHERE plan_id = $1`, [planId]); await db.query( `INSERT INTO ${schema}_ss_rollback (plan_id, restore_pack, restore_rows, restore_files, created_at) VALUES ($1,$2,$3,$4,$5)`, [planId, JSON.stringify(metadataPack), JSON.stringify(rawRows), JSON.stringify(fileIndex), now] ); // Retention: cap _ss_rollback to the most recent restore points. await db.query( `DELETE FROM ${schema}_ss_rollback WHERE plan_id NOT IN ` + `(SELECT plan_id FROM ${schema}_ss_rollback ORDER BY created_at DESC LIMIT ${ROLLBACK_RETENTION})` ).catch(() => {}); }; const hasRestorePoint = async (planId) => { const schema = db.getTenantSchemaPrefix(); const rs = await db.query(`SELECT 1 FROM ${schema}_ss_rollback WHERE plan_id = $1`, [planId]); return rs.rows.length > 0; }; // The plan ids that have a restore point (at most ROLLBACK_RETENTION). const restorePointIds = async () => { const schema = db.getTenantSchemaPrefix(); const rs = await db.query(`SELECT plan_id FROM ${schema}_ss_rollback`); return new Set(rs.rows.map((r) => r.plan_id)); }; // Before a peer is deleted (its plans go with it): detach from it the plans that // can still be rolled back, so their rollback survives the unpairing. A detached // plan (peer_id NULL) belongs to no peer, so a later pairing that is handed the same // peer_id can never apply it remotely. Returns how many were kept. const detachRollbackablePlans = async (peerId) => { const schema = db.getTenantSchemaPrefix(); const placeholders = ROLLBACK_FROM.map((_, i) => `$${i + 2}`).join(","); const rs = await db.query( `UPDATE ${schema}_ss_plans SET peer_id = NULL WHERE peer_id = $1 AND status IN (${placeholders}) ` + `AND plan_id IN (SELECT plan_id FROM ${schema}_ss_rollback) RETURNING plan_id`, [peerId].concat(ROLLBACK_FROM) ); return rs.rows.length; }; const loadRestorePoint = async (planId) => { const schema = db.getTenantSchemaPrefix(); const rs = await db.query(`SELECT * FROM ${schema}_ss_rollback WHERE plan_id = $1`, [planId]); if (rs.rows.length === 0) { return null; } const row = rs.rows[0]; return { restore_pack: JSON.parse(row.restore_pack), restore_rows: JSON.parse(row.restore_rows), restore_files: row.restore_files ? JSON.parse(row.restore_files) : {} }; }; // Restore one table's captured rows by primary key (see the header). A row still // present gets its captured columns back (only when they differ); a missing row is // re-inserted WITH its original pk unless another row now holds that pk. For a // managed/starter table a pk only matches when the row identity matches too. const restoreTableRows = async (tableName, rawRows, synced, warnings) => { if (synced) { await rowId.ensureManagedSchema(tableName); } const tbl = Table.findOne({ name: tableName }); const pk = tbl.pk_name; const fields = new Set(tbl.getFields().map((f) => f.name)); const current = await tbl.getRows({}, {}); const byPk = new Map(current.map((r) => [r[pk], r])); const byUuid = new Map(current.filter((r) => r[ROW_UUID_FIELD]).map((r) => [r[ROW_UUID_FIELD], r])); const lost = new Set(); let insertedWithPk = false; for (const raw of rawRows) { const row = {}; for (const [k, v] of Object.entries(raw)) { if (fields.has(k)) { row[k] = v; } else { lost.add(k); } } // A captured row without an identity (older restore points) matches by pk and // keeps the identity the row has been given since. let existing = byPk.get(raw[pk]); if (synced && !raw[ROW_UUID_FIELD]) { delete row[ROW_UUID_FIELD]; } else if (synced && existing && existing[ROW_UUID_FIELD] !== raw[ROW_UUID_FIELD]) { existing = byUuid.get(raw[ROW_UUID_FIELD]); } if (existing) { if (rowsLib.rowDiffers(existing, withoutPk(row, pk))) { const err = await tbl.updateRow(withoutPk(row, pk), existing[pk]); if (typeof err === "string") { warnings.push(`"${tableName}" row ${raw[pk]} not restored: ${err}`); } } continue; } const pkFree = !byPk.has(raw[pk]); const r = await tbl.tryInsertRow(pkFree ? row : withoutPk(row, pk)); if (r.success === undefined) { warnings.push(`"${tableName}" row ${raw[pk]} not restored: ${r.error}`); continue; } insertedWithPk = insertedWithPk || pkFree; } if (lost.size > 0) { warnings.push(`"${tableName}": captured column(s) ${[...lost].join(", ")} no longer exist and were not restored`); } if (insertedWithPk) { await tbl.resetSequence(); } }; // The rollback proper, run while this caller holds the site lock (lease) and the // plan's ROLLING_BACK claim. const runRollback = async (planId, rp, original, opts, lease) => { const { getEnv } = require("./env"); const planLib = require("./plan"); const applyLib = require("./apply"); const env = Object.assign({}, await getEnv(), { sync_mode: SYNC_MODES.MIRROR }); // 1. metadata + config + code pages (raw rows are restored below). const built = await planLib.buildPlan({ peerRow: null, env, desired: rp.restore_pack, desiredHash: capture.hashPack(rp.restore_pack), sourceEnvId: "rollback", direction: "rollback", sourceRows: {} }); const applied = await applyLib.applyPlan(built.plan_id, { applyDeletes: true, // the restore pack is this instance's own pre-apply state, so a detected // rename is the inverse of one the apply performed confirmRenames: built.plan.entities.tables.rename.map((r) => r.from), prunable: opts.prunable || [], confirmDataDrops: !!opts.confirmDataDrops, skipRestorePoint: true, // seed the file dedup index with pre-apply files so layout/logo placeholders // resolve back to the old on-disk files without needing a peer to fetch from. fileIndexSeed: rp.restore_files, lease: lease }); const warnings = applied.warnings.slice(); await lease.assertHeld(); // 2. rows: remove what the apply inserted into managed/starter tables (children // first), then restore the captured rows (parents first). const modes = await rowsLib.modeMap(); const names = Object.keys(rp.restore_rows).filter((n) => { const present = !!Table.findOne({ name: n }); if (!present) { warnings.push(`"${n}" is not present after the metadata rollback; its rows were not restored`); } return present; }); const synced = names.filter((n) => isSyncedMode(modes[n])); await rowsLib.pruneRowsNotIn(synced.map((n) => ({ name: n, keep: new Set(rp.restore_rows[n].map((r) => r[ROW_UUID_FIELD]).filter(Boolean)) })), warnings); for (const name of rowId.parentFirstOrder(names)) { await restoreTableRows(name, rp.restore_rows[name], isSyncedMode(modes[name]), warnings); } // 3. starters this apply seeded become unseeded again. for (const [name, c] of Object.entries(original.plan.rows ? original.plan.rows.perTable : {})) { if (c.mode === DATA_MODES.STARTER && c.inserts > 0) { await rowsLib.clearStarterShipped(name); } } return { rolledBack: true, plan_id: planId, restoredTables: names.length, warnings }; }; // Revert the apply identified by planId, under the site lock (siteLock.LockBusyError // while another apply or rollback runs). opts.prunable / opts.confirmDataDrops gate // dropping any table the apply CREATED and that has since gained rows. Allowed for // an applied plan, or a pending one whose apply failed part-way or was abandoned // (acquiring the lock recovers it to pending; it then has a restore point); a plan // is rolled back at most once. const rollbackApply = async (planId, opts = {}) => { return await siteLock.withSiteLock({ operation: "rollback", planId }, async (lease) => { const planLib = require("./plan"); const rp = await loadRestorePoint(planId); if (!rp) { throw new Error(`no restore point for plan ${planId}`); } const original = await planLib.loadPlan(planId); if (!original || !(await planLib.claimPlanStatus(planId, ROLLBACK_FROM, PLAN_STATUS.ROLLING_BACK))) { throw new Error(`plan ${planId} cannot be rolled back: it is ${original ? original.status : "unknown"}`); } const result = await siteLock.finishClaimed(lease, planId, { prior: original.status, done: PLAN_STATUS.ROLLED_BACK }, () => runRollback(planId, rp, original, opts, lease)); result.warnings = siteLock.recoveryWarnings(lease).concat(result.warnings); return result; }); }; module.exports = { ROLLBACK_FROM, saveRestorePoint, hasRestorePoint, restorePointIds, detachRollbackablePlans, rollbackApply };