318 lines
14 KiB
JavaScript
318 lines
14 KiB
JavaScript
// 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
|
|
};
|