Path: blob/master/src/packages/file-server/zfs/snapshots.ts
1447 views
/*1Manage creating and deleting rolling snapshots of a filesystem.23We keep track of all state in the sqlite database, so only have to touch4ZFS when we actually need to do something. Keep this in mind though since5if you try to mess with snapshots directly then the sqlite database won't6know you did that.7*/89import { exec } from "./util";10import { get, getRecent, set } from "./db";11import { filesystemDataset, filesystemMountpoint } from "./names";12import { splitlines } from "@cocalc/util/misc";13import getLogger from "@cocalc/backend/logger";14import {15SNAPSHOT_INTERVAL_MS,16SNAPSHOT_INTERVALS_MS,17SNAPSHOT_COUNTS,18} from "./config";19import { syncProperties } from "./properties";20import { primaryKey, type PrimaryKey } from "./types";21import { isEqual } from "lodash";2223const logger = getLogger("file-server:zfs/snapshots");2425export async function maintainSnapshots(cutoff?: Date) {26await deleteExtraSnapshotsOfActiveFilesystems(cutoff);27await snapshotActiveFilesystems(cutoff);28}2930// If there any changes to the filesystem since the last snapshot,31// and there are no snapshots since SNAPSHOT_INTERVAL_MS ms ago,32// make a new one. Always returns the most recent snapshot name.33// Error if filesystem is archived.34export async function createSnapshot({35force,36ifChanged,37...fs38}: PrimaryKey & {39force?: boolean;40// note -- ifChanged is VERY fast, but it's not instantaneous...41ifChanged?: boolean;42}): Promise<string> {43logger.debug("createSnapshot: ", fs);44const pk = primaryKey(fs);45const { pool, archived, snapshots } = get(pk);46if (archived) {47throw Error("cannot snapshot an archived filesystem");48}49if (!force && !ifChanged && snapshots.length > 0) {50// check for sufficiently recent snapshot51const last = new Date(snapshots[snapshots.length - 1]);52if (Date.now() - last.valueOf() < SNAPSHOT_INTERVAL_MS) {53// snapshot sufficiently recent54return snapshots[snapshots.length - 1];55}56}5758// Check to see if nothing change on disk since last snapshot - if so, don't make a new one:59if (!force && snapshots.length > 0) {60const written = await getWritten(pk);61if (written == 0) {62// for sure definitely nothing written, so no possible63// need to make a snapshot64return snapshots[snapshots.length - 1];65}66}6768const snapshot = new Date().toISOString();69await exec({70verbose: true,71command: "sudo",72args: [73"zfs",74"snapshot",75`${filesystemDataset({ ...pk, pool })}@${snapshot}`,76],77what: { ...pk, desc: "creating snapshot of project" },78});79set({80...pk,81snapshots: ({ snapshots }) => [...snapshots, snapshot],82});83syncProperties(pk);84return snapshot;85}8687async function getWritten(fs: PrimaryKey) {88const pk = primaryKey(fs);89const { pool } = get(pk);90const { stdout } = await exec({91verbose: true,92command: "zfs",93args: ["list", "-Hpo", "written", filesystemDataset({ ...pk, pool })],94what: {95...pk,96desc: "getting amount of newly written data in project since last snapshot",97},98});99return parseInt(stdout);100}101102export async function zfsGetSnapshots(dataset: string) {103const { stdout } = await exec({104command: "zfs",105args: ["list", "-j", "-o", "name", "-r", "-t", "snapshot", dataset],106});107const snapshots = Object.keys(JSON.parse(stdout).datasets).map(108(name) => name.split("@")[1],109);110return snapshots;111}112113// gets snapshots from disk via zfs *and* sets the list of snapshots114// in the database to match (and also updates sizes)115export async function getSnapshots(fs: PrimaryKey) {116const pk = primaryKey(fs);117const filesystem = get(fs);118const snapshots = await zfsGetSnapshots(filesystemDataset(filesystem));119if (!isEqual(snapshots, filesystem.snapshots)) {120set({ ...pk, snapshots });121syncProperties(fs);122}123return snapshots;124}125126export async function deleteSnapshot({127snapshot,128...fs129}: PrimaryKey & { snapshot: string }) {130const pk = primaryKey(fs);131logger.debug("deleteSnapshot: ", pk, snapshot);132const { pool, last_send_snapshot } = get(pk);133if (snapshot == last_send_snapshot) {134throw Error(135"can't delete snapshot since it is the last one used for a zfs send",136);137}138await exec({139verbose: true,140command: "sudo",141args: [142"zfs",143"destroy",144`${filesystemDataset({ ...pk, pool })}@${snapshot}`,145],146what: { ...pk, desc: "destroying a snapshot of a project" },147});148set({149...pk,150snapshots: ({ snapshots }) => snapshots.filter((x) => x != snapshot),151});152syncProperties(pk);153}154155/*156Remove snapshots according to our retention policy, and157never delete last_stream if set.158159Returns names of deleted snapshots.160*/161export async function deleteExtraSnapshots(fs: PrimaryKey): Promise<string[]> {162const pk = primaryKey(fs);163logger.debug("deleteExtraSnapshots: ", pk);164const { last_send_snapshot, snapshots } = get(pk);165if (snapshots.length == 0) {166// nothing to do167return [];168}169170// sorted from BIGGEST to smallest171const times = snapshots.map((x) => new Date(x).valueOf());172times.reverse();173const save = new Set<number>();174if (last_send_snapshot) {175save.add(new Date(last_send_snapshot).valueOf());176}177for (const type in SNAPSHOT_COUNTS) {178const count = SNAPSHOT_COUNTS[type];179const length_ms = SNAPSHOT_INTERVALS_MS[type];180181// Pick the first count newest snapshots at intervals of length182// length_ms milliseconds.183let n = 0,184i = 0,185last_tm = 0;186while (n < count && i < times.length) {187const tm = times[i];188if (!last_tm || tm <= last_tm - length_ms) {189save.add(tm);190last_tm = tm;191n += 1; // found one more192}193i += 1; // move to next snapshot194}195}196const toDelete = snapshots.filter((x) => !save.has(new Date(x).valueOf()));197for (const snapshot of toDelete) {198await deleteSnapshot({ ...pk, snapshot });199}200return toDelete;201}202203// Go through ALL projects with last_edited >= cutoff stored204// here and run trimActiveFilesystemSnapshots.205export async function deleteExtraSnapshotsOfActiveFilesystems(cutoff?: Date) {206const v = getRecent({ cutoff });207logger.debug(208`deleteSnapshotsOfActiveFilesystems: considering ${v.length} filesystems`,209);210let i = 0;211for (const fs of v) {212if (fs.archived) {213continue;214}215try {216await deleteExtraSnapshots(fs);217} catch (err) {218logger.debug(`deleteSnapshotsOfActiveFilesystems: error -- ${err}`);219}220i += 1;221if (i % 10 == 0) {222logger.debug(`deleteSnapshotsOfActiveFilesystems: ${i}/${v.length}`);223}224}225}226227// Go through ALL projects with last_edited >= cutoff and snapshot them228// if they are due a snapshot.229// cutoff = a Date (default = 1 week ago)230export async function snapshotActiveFilesystems(cutoff?: Date) {231logger.debug("snapshotActiveFilesystems: getting...");232const v = getRecent({ cutoff });233logger.debug(234`snapshotActiveFilesystems: considering ${v.length} projects`,235cutoff,236);237let i = 0;238for (const fs of v) {239if (fs.archived) {240continue;241}242try {243await createSnapshot(fs);244} catch (err) {245// error is already logged in error field of database246logger.debug(`snapshotActiveFilesystems: error -- ${err}`);247}248i += 1;249if (i % 10 == 0) {250logger.debug(`snapshotActiveFilesystems: ${i}/${v.length}`);251}252}253}254255/*256Get list of files modified since given snapshot (or last snapshot if not given).257258**There's probably no good reason to ever use this code!**259260The reason is because it's really slow, e.g., I added the261cocalc src directory (5000) files and it takes about 6 seconds262to run this. In contrast. "time find .", which lists EVERYTHING263takes less than 0.074s. You could do that before and after, then264compare them, and it'll be a fraction of a second.265*/266interface Mod {267time: number;268change: "-" | "+" | "M" | "R"; // remove/create/modify/rename269// see "man zfs diff":270type: "B" | "C" | "/" | ">" | "|" | "@" | "P" | "=" | "F";271path: string;272}273274export async function getModifiedFiles({275snapshot,276...fs277}: PrimaryKey & { snapshot: string }) {278const pk = primaryKey(fs);279logger.debug(`getModifiedFiles: `, pk);280const { pool, snapshots } = get(pk);281if (snapshots.length == 0) {282return [];283}284if (snapshot == null) {285snapshot = snapshots[snapshots.length - 1];286}287const { stdout } = await exec({288verbose: true,289command: "sudo",290args: [291"zfs",292"diff",293"-FHt",294`${filesystemDataset({ ...pk, pool })}@${snapshot}`,295],296what: { ...pk, desc: "getting files modified since last snapshot" },297});298const mnt = filesystemMountpoint(pk) + "/";299const files: Mod[] = [];300for (const line of splitlines(stdout)) {301const x = line.split(/\t/g);302let path = x[3];303if (path.startsWith(mnt)) {304path = path.slice(mnt.length);305}306files.push({307time: parseFloat(x[0]) * 1000,308change: x[1] as any,309type: x[2] as any,310path,311});312}313return files;314}315316317