Skip to content

Commit 5e5e772

Browse files
committed
btrfs: refactor snapshot code
1 parent 636d589 commit 5e5e772

File tree

9 files changed

+189
-163
lines changed

9 files changed

+189
-163
lines changed

src/packages/file-server/btrfs/filesystem.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ a = require('@cocalc/file-server/storage-btrfs'); fs = await a.filesystem({devic
1313

1414
import refCache from "@cocalc/util/refcache";
1515
import { exists, isdir, listdir, mkdirp, rmdir, sudo } from "./util";
16-
import { subvolume, SNAPSHOTS, type Subvolume } from "./subvolume";
16+
import { subvolume, type Subvolume } from "./subvolume";
17+
import { SNAPSHOTS } from "./subvolume-snapshot";
1718
import { join, normalize } from "path";
1819

1920
// default size of btrfs filesystem if creating an image file.
@@ -201,10 +202,13 @@ export class Filesystem {
201202
command: "mv",
202203
args: [join(this.opts.mount, source, name), join(this.opts.mount, name)],
203204
});
204-
const snapshots = await listdir(join(this.opts.mount, name, SNAPSHOTS));
205-
await rmdir(
206-
snapshots.map((x) => join(this.opts.mount, name, SNAPSHOTS, x)),
207-
);
205+
const snapdir = join(this.opts.mount, name, SNAPSHOTS);
206+
if (await exists(snapdir)) {
207+
const snapshots = await listdir(snapdir);
208+
await rmdir(
209+
snapshots.map((x) => join(this.opts.mount, name, SNAPSHOTS, x)),
210+
);
211+
}
208212
const src = await this.subvolume(source);
209213
const vol = await this.subvolume(name);
210214
const { size } = await src.usage();

src/packages/file-server/btrfs/snapshots.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type Subvolume } from "./subvolume";
1+
import { type SubvolumeSnapshot } from "./subvolume-snapshot";
22
import getLogger from "@cocalc/backend/logger";
33

44
const logger = getLogger("file-server:storage-btrfs:snapshots");
@@ -30,17 +30,17 @@ export interface SnapshotCounts {
3030
}
3131

3232
export async function updateRollingSnapshots({
33-
subvolume,
33+
snapshot,
3434
counts,
3535
}: {
36-
subvolume: Subvolume;
36+
snapshot: SubvolumeSnapshot;
3737
counts?: Partial<SnapshotCounts>;
3838
}) {
3939
counts = { ...DEFAULT_SNAPSHOT_COUNTS, ...counts };
4040

41-
const changed = await subvolume.hasUnsavedChanges();
41+
const changed = await snapshot.hasUnsavedChanges();
4242
logger.debug("updateRollingSnapshots", {
43-
name: subvolume.name,
43+
name: snapshot.subvolume.name,
4444
counts,
4545
changed,
4646
});
@@ -50,9 +50,9 @@ export async function updateRollingSnapshots({
5050
}
5151

5252
// get exactly the iso timestamp snapshot names:
53-
const snapshots = (await subvolume.snapshots()).filter((x) =>
54-
DATE_REGEXP.test(x),
55-
);
53+
const snapshots = (await snapshot.ls())
54+
.map((x) => x.name)
55+
.filter((name) => DATE_REGEXP.test(name));
5656
snapshots.sort();
5757
if (snapshots.length > 0) {
5858
const age = Date.now() - new Date(snapshots.slice(-1)[0]).valueOf();
@@ -61,7 +61,7 @@ export async function updateRollingSnapshots({
6161
if (age < SNAPSHOT_INTERVALS_MS[key]) {
6262
// no need to snapshot since there is already a sufficiently recent snapshot
6363
logger.debug("updateRollingSnapshots: no need to snapshot", {
64-
name: subvolume.name,
64+
name: snapshot.subvolume.name,
6565
});
6666
return;
6767
}
@@ -72,14 +72,14 @@ export async function updateRollingSnapshots({
7272
}
7373

7474
// make a new snapshot
75-
const snapshot = new Date().toISOString();
76-
await subvolume.createSnapshot(snapshot);
75+
const name = new Date().toISOString();
76+
await snapshot.create(name);
7777
// delete extra snapshots
78-
snapshots.push(snapshot);
78+
snapshots.push(name);
7979
const toDelete = snapshotsToDelete({ counts, snapshots });
80-
for (const snapshot of toDelete) {
80+
for (const expired of toDelete) {
8181
try {
82-
await subvolume.deleteSnapshot(snapshot);
82+
await snapshot.delete(expired);
8383
} catch {
8484
// some snapshots can't be deleted, e.g., they were used for the last send.
8585
}

src/packages/file-server/btrfs/subvolume-bup.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,17 @@ export class SubvolumeBup {
2323
// timeout used for bup index and bup save commands
2424
timeout = 30 * 60 * 1000,
2525
}: { timeout?: number } = {}) => {
26-
if (await this.subvolume.snapshotExists(BUP_SNAPSHOT)) {
26+
if (await this.subvolume.snapshot.exists(BUP_SNAPSHOT)) {
2727
logger.debug(`createBupBackup: deleting existing ${BUP_SNAPSHOT}`);
28-
await this.subvolume.deleteSnapshot(BUP_SNAPSHOT);
28+
await this.subvolume.snapshot.delete(BUP_SNAPSHOT);
2929
}
3030
try {
3131
logger.debug(
3232
`createBackup: creating ${BUP_SNAPSHOT} to get a consistent backup`,
3333
);
34-
await this.subvolume.createSnapshot(BUP_SNAPSHOT);
34+
await this.subvolume.snapshot.create(BUP_SNAPSHOT);
3535
const target = this.subvolume.normalize(
36-
this.subvolume.snapshotPath(BUP_SNAPSHOT),
36+
this.subvolume.snapshot.path(BUP_SNAPSHOT),
3737
);
3838

3939
logger.debug(`createBupBackup: indexing ${BUP_SNAPSHOT}`);
@@ -67,7 +67,7 @@ export class SubvolumeBup {
6767
});
6868
} finally {
6969
logger.debug(`createBupBackup: deleting temporary ${BUP_SNAPSHOT}`);
70-
await this.subvolume.deleteSnapshot(BUP_SNAPSHOT);
70+
await this.subvolume.snapshot.delete(BUP_SNAPSHOT);
7171
}
7272
};
7373

@@ -78,7 +78,7 @@ export class SubvolumeBup {
7878
}
7979
path = normalize(path);
8080
// ... but to avoid potential data loss, we make a snapshot before deleting it.
81-
await this.subvolume.createSnapshot();
81+
await this.subvolume.snapshot.create();
8282
const i = path.indexOf("/"); // remove the commit name
8383
// remove the target we're about to restore
8484
await this.subvolume.fs.rm(path.slice(i + 1), { recursive: true });
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { type Subvolume } from "./subvolume";
2+
import { btrfs } from "./util";
3+
import getLogger from "@cocalc/backend/logger";
4+
import { join } from "path";
5+
import { type DirectoryListingEntry } from "@cocalc/util/types";
6+
import { SnapshotCounts, updateRollingSnapshots } from "./snapshots";
7+
8+
export const SNAPSHOTS = ".snapshots";
9+
const logger = getLogger("file-server:storage-btrfs:subvolume-snapshot");
10+
11+
export class SubvolumeSnapshot {
12+
public readonly snapshotsDir: string;
13+
14+
constructor(public subvolume: Subvolume) {
15+
this.snapshotsDir = join(this.subvolume.path, SNAPSHOTS);
16+
}
17+
18+
path = (snapshot?: string, ...segments) => {
19+
if (!snapshot) {
20+
return SNAPSHOTS;
21+
}
22+
return join(SNAPSHOTS, snapshot, ...segments);
23+
};
24+
25+
private makeSnapshotsDir = async () => {
26+
if (await this.subvolume.fs.exists(SNAPSHOTS)) {
27+
return;
28+
}
29+
await this.subvolume.fs.mkdir(SNAPSHOTS);
30+
await this.subvolume.fs.chmod(SNAPSHOTS, "0555");
31+
};
32+
33+
create = async (name?: string) => {
34+
if (name?.startsWith(".")) {
35+
throw Error("snapshot name must not start with '.'");
36+
}
37+
name ??= new Date().toISOString();
38+
logger.debug("create", { name, subvolume: this.subvolume.name });
39+
await this.makeSnapshotsDir();
40+
await btrfs({
41+
args: [
42+
"subvolume",
43+
"snapshot",
44+
"-r",
45+
this.subvolume.path,
46+
join(this.snapshotsDir, name),
47+
],
48+
});
49+
};
50+
51+
ls = async (): Promise<DirectoryListingEntry[]> => {
52+
await this.makeSnapshotsDir();
53+
return await this.subvolume.fs.ls(SNAPSHOTS, { hidden: false });
54+
};
55+
56+
lock = async (name: string) => {
57+
if (await this.subvolume.fs.exists(this.path(name))) {
58+
this.subvolume.fs.writeFile(this.path(`.${name}.lock`), "");
59+
} else {
60+
throw Error(`snapshot ${name} does not exist`);
61+
}
62+
};
63+
64+
unlock = async (name: string) => {
65+
await this.subvolume.fs.rm(this.path(`.${name}.lock`));
66+
};
67+
68+
exists = async (name: string) => {
69+
return await this.subvolume.fs.exists(this.path(name));
70+
};
71+
72+
delete = async (name) => {
73+
if (await this.subvolume.fs.exists(this.path(`.${name}.lock`))) {
74+
throw Error(`snapshot ${name} is locked`);
75+
}
76+
await btrfs({
77+
args: ["subvolume", "delete", join(this.snapshotsDir, name)],
78+
});
79+
};
80+
81+
// update the rolling snapshots schedule
82+
update = async (counts?: Partial<SnapshotCounts>) => {
83+
return await updateRollingSnapshots({ snapshot: this, counts });
84+
};
85+
86+
// has newly written changes since last snapshot
87+
hasUnsavedChanges = async (): Promise<boolean> => {
88+
const s = await this.ls();
89+
if (s.length == 0) {
90+
// more than just the SNAPSHOTS directory?
91+
const v = await this.subvolume.fs.ls("", { hidden: true });
92+
if (v.length == 0 || (v.length == 1 && v[0].name == SNAPSHOTS)) {
93+
return false;
94+
}
95+
return true;
96+
}
97+
const pathGen = await getGeneration(this.subvolume.path);
98+
const snapGen = await getGeneration(
99+
join(this.snapshotsDir, s[s.length - 1].name),
100+
);
101+
return snapGen < pathGen;
102+
};
103+
}
104+
105+
async function getGeneration(path: string): Promise<number> {
106+
const { stdout } = await btrfs({
107+
args: ["subvolume", "show", path],
108+
verbose: false,
109+
});
110+
return parseInt(stdout.split("Generation:")[1].split("\n")[0].trim());
111+
}

0 commit comments

Comments
 (0)