Skip to content

Commit caac6dd

Browse files
committed
btrfs: adding more testing and fs operations support
1 parent 91da892 commit caac6dd

File tree

4 files changed

+193
-57
lines changed

4 files changed

+193
-57
lines changed

src/packages/backend/get-listing.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55

66
/*
7-
Server directory listing through the HTTP server and Websocket API.
7+
This is used by backends to serve directory listings to clients:
88
99
{files:[..., {size:?,name:?,mtime:?,isdir:?}]}
1010

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

Lines changed: 108 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ A subvolume
44

55
import { type Filesystem, DEFAULT_SUBVOLUME_SIZE } from "./filesystem";
66
import refCache from "@cocalc/util/refcache";
7-
import { exists, listdir, mkdirp, sudo } from "./util";
7+
import { readFile, writeFile, unlink } from "node:fs/promises";
8+
import { exists, isdir, listdir, mkdirp, sudo } from "./util";
89
import { join, normalize } from "path";
910
import { updateRollingSnapshots, type SnapshotCounts } from "./snapshots";
10-
//import { human_readable_size } from "@cocalc/util/misc";
11+
import { DirectoryListingEntry } from "@cocalc/util/types";
12+
import getListing from "@cocalc/backend/get-listing";
1113
import getLogger from "@cocalc/backend/logger";
1214

1315
export const SNAPSHOTS = ".snapshots";
@@ -23,10 +25,11 @@ interface Options {
2325
}
2426

2527
export class Subvolume {
26-
private filesystem: Filesystem;
2728
public readonly name: string;
28-
public readonly path: string;
29-
public readonly snapshotsDir: string;
29+
30+
private filesystem: Filesystem;
31+
private readonly path: string;
32+
private readonly snapshotsDir: string;
3033

3134
constructor({ filesystem, name }: Options) {
3235
this.filesystem = filesystem;
@@ -70,6 +73,72 @@ export class Subvolume {
7073
});
7174
};
7275

76+
// this should provide a path that is guaranteed to be
77+
// inside this.path on the filesystem or throw error
78+
// [ ] TODO: not sure if the code here is sufficient!!
79+
private normalize = (path: string) => {
80+
return join(this.path, normalize(path));
81+
};
82+
83+
/////////////
84+
// Files
85+
/////////////
86+
ls = async (
87+
path: string,
88+
{ hidden, limit }: { hidden?: boolean; limit?: number } = {},
89+
): Promise<DirectoryListingEntry[]> => {
90+
path = normalize(path);
91+
return await getListing(this.normalize(path), hidden, {
92+
limit,
93+
home: "/",
94+
});
95+
};
96+
97+
readFile = async (path: string, encoding?: any): Promise<string | Buffer> => {
98+
path = normalize(path);
99+
return await readFile(this.normalize(path), encoding);
100+
};
101+
102+
writeFile = async (path: string, data: string | Buffer) => {
103+
path = normalize(path);
104+
return await writeFile(this.normalize(path), data);
105+
};
106+
107+
unlink = async (path: string) => {
108+
await unlink(this.normalize(path));
109+
};
110+
111+
rsync = async ({
112+
src,
113+
target,
114+
args = ["-axH"],
115+
timeout = 5 * 60 * 1000,
116+
}: {
117+
src: string;
118+
target: string;
119+
args?: string[];
120+
timeout?: number;
121+
}): Promise<{ stdout: string; stderr: string; exit_code: number }> => {
122+
let srcPath = this.normalize(src);
123+
let targetPath = this.normalize(target);
124+
if (!srcPath.endsWith("/") && (await isdir(srcPath))) {
125+
srcPath += "/";
126+
if (!targetPath.endsWith("/")) {
127+
targetPath += "/";
128+
}
129+
}
130+
return await sudo({
131+
command: "rsync",
132+
args: [...args, srcPath, targetPath],
133+
err_on_exit: false,
134+
timeout: timeout / 1000,
135+
});
136+
};
137+
138+
/////////////
139+
// QUOTA
140+
/////////////
141+
73142
private quotaInfo = async () => {
74143
const { stdout } = await sudo({
75144
verbose: false,
@@ -150,6 +219,13 @@ export class Subvolume {
150219
return { used, free, size };
151220
};
152221

222+
/////////////
223+
// SNAPSHOTS
224+
/////////////
225+
snapshotPath = (snapshot: string, ...segments) => {
226+
return join(SNAPSHOTS, snapshot, ...segments);
227+
};
228+
153229
private makeSnapshotsDir = async () => {
154230
if (await exists(this.snapshotsDir)) {
155231
return;
@@ -233,6 +309,13 @@ export class Subvolume {
233309
return snapGen < pathGen;
234310
};
235311

312+
/////////////
313+
// BACKUPS
314+
// There is a single global dedup'd backup archive stored in the btrfs filesystem.
315+
// Obviously, admins should rsync this regularly to a separate location as a genuine
316+
// backup strategy.
317+
/////////////
318+
236319
// create a new bup backup
237320
createBupBackup = async ({
238321
// timeout used for bup index and bup save commands
@@ -304,7 +387,7 @@ export class Subvolume {
304387
const i = path.indexOf("/"); // remove the commit name
305388
await sudo({
306389
command: "rm",
307-
args: ["-rf", join(this.path, path.slice(i + 1))],
390+
args: ["-rf", this.normalize(path.slice(i + 1))],
308391
});
309392
await sudo({
310393
command: "bup",
@@ -320,16 +403,7 @@ export class Subvolume {
320403
});
321404
};
322405

323-
bupLs = async (
324-
path: string,
325-
): Promise<
326-
{
327-
path: string;
328-
size: number;
329-
timestamp: number;
330-
isdir: boolean;
331-
}[]
332-
> => {
406+
bupLs = async (path: string): Promise<DirectoryListingEntry[]> => {
333407
path = normalize(path);
334408
const { stdout } = await sudo({
335409
command: "bup",
@@ -343,30 +417,25 @@ export class Subvolume {
343417
join(`/${this.name}`, path),
344418
],
345419
});
346-
const v: {
347-
path: string;
348-
size: number;
349-
timestamp: number;
350-
isdir: boolean;
351-
}[] = [];
420+
const v: DirectoryListingEntry[] = [];
352421
for (const x of stdout.split("\n")) {
353422
// [-rw-------","6b851643360e435eb87ef9a6ab64a8b1/6b851643360e435eb87ef9a6ab64a8b1","5","2025-07-15","06:12","a.txt"]
354423
const w = x.split(/\s+/);
355424
if (w.length >= 6) {
356-
let isdir, path;
425+
let isdir, name;
357426
if (w[5].endsWith("@") || w[5].endsWith("=") || w[5].endsWith("|")) {
358427
w[5] = w[5].slice(0, -1);
359428
}
360429
if (w[5].endsWith("/")) {
361430
isdir = true;
362-
path = w[5].slice(0, -1);
431+
name = w[5].slice(0, -1);
363432
} else {
364-
path = w[5];
433+
name = w[5];
365434
isdir = false;
366435
}
367436
const size = parseInt(w[2]);
368-
const timestamp = new Date(w[3] + "T" + w[4]).valueOf();
369-
v.push({ path, size, timestamp, isdir });
437+
const mtime = new Date(w[3] + "T" + w[4]).valueOf() / 1000;
438+
v.push({ name, size, mtime, isdir });
370439
}
371440
}
372441
return v;
@@ -392,6 +461,18 @@ export class Subvolume {
392461
});
393462
};
394463

464+
/////////////
465+
// BTRFS send/recv
466+
// Not used. Instead we will rely on bup (and snapshots of the underlying disk) for backups, since:
467+
// - much easier to check they are valid
468+
// - decoupled from any btrfs issues
469+
// - not tied to any specific filesystem at all
470+
// - easier to offsite via incremntal rsync
471+
// - much more space efficient with *global* dedup and compression
472+
// - bup is really just git, which is very proven
473+
// The drawback is speed.
474+
/////////////
475+
395476
// this was just a quick proof of concept -- I don't like it. Should switch to using
396477
// timestamps and a lock.
397478
// To recover these, doing recv for each in order does work. Then you have to
Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,84 @@
11
import { before, after, fs } from "./setup";
2-
import { writeFile } from "fs/promises";
2+
import { mkdir, writeFile } from "fs/promises";
33
import { join } from "path";
44

5+
const DEBUG = false;
6+
const log = DEBUG ? console.log : (..._args) => {};
7+
8+
const numSnapshots = 25;
9+
const numFiles = 1000;
10+
511
beforeAll(before);
6-
//const log = console.log;
7-
const log = (..._args) => {};
812

9-
describe("stress test creating many snapshots", () => {
13+
describe(`stress test creating ${numSnapshots} snapshots`, () => {
1014
let vol;
1115
it("creates a volume and write a file to it", async () => {
1216
vol = await fs.subvolume("stress");
1317
});
1418

15-
const count = 25;
16-
it(`create file and snapshot the volume ${count} times`, async () => {
19+
it(`create file and snapshot the volume ${numSnapshots} times`, async () => {
1720
const snaps: string[] = [];
1821
const start = Date.now();
19-
for (let i = 0; i < count; i++) {
22+
for (let i = 0; i < numSnapshots; i++) {
2023
await writeFile(join(vol.path, `${i}.txt`), "world");
2124
await vol.createSnapshot(`snap${i}`);
2225
snaps.push(`snap${i}`);
2326
}
2427
log(
25-
`created ${Math.round((count / (Date.now() - start)) * 1000)} snapshots per second in serial`,
28+
`created ${Math.round((numSnapshots / (Date.now() - start)) * 1000)} snapshots per second in serial`,
2629
);
2730
snaps.sort();
2831
expect(await vol.snapshots()).toEqual(snaps);
2932
});
3033

31-
it(`delete our ${count} snapshots`, async () => {
32-
for (let i = 0; i < count; i++) {
34+
it(`delete our ${numSnapshots} snapshots`, async () => {
35+
for (let i = 0; i < numSnapshots; i++) {
3336
await vol.deleteSnapshot(`snap${i}`);
3437
}
3538
expect(await vol.snapshots()).toEqual([]);
3639
});
3740
});
3841

42+
describe(`create ${numFiles} files`, () => {
43+
let vol;
44+
it("creates a volume", async () => {
45+
vol = await fs.subvolume("many-files");
46+
});
47+
48+
it(`creates ${numFiles} files`, async () => {
49+
const names: string[] = [];
50+
const start = Date.now();
51+
for (let i = 0; i < numFiles; i++) {
52+
await writeFile(join(vol.path, `${i}`), "world");
53+
names.push(`${i}`);
54+
}
55+
log(
56+
`created ${Math.round((numFiles / (Date.now() - start)) * 1000)} files per second in serial`,
57+
);
58+
const v = await vol.ls("");
59+
const w = v.map(({ name }) => name);
60+
expect(w.sort()).toEqual(names.sort());
61+
});
62+
63+
it(`creates ${numFiles} files in parallel`, async () => {
64+
await mkdir(join(vol.path, "p"));
65+
const names: string[] = [];
66+
const start = Date.now();
67+
const z: any[] = [];
68+
for (let i = 0; i < numFiles; i++) {
69+
z.push(writeFile(join(vol.path, `p/${i}`), "world"));
70+
names.push(`${i}`);
71+
}
72+
await Promise.all(z);
73+
log(
74+
`created ${Math.round((numFiles / (Date.now() - start)) * 1000)} files per second in parallel`,
75+
);
76+
const t0 = Date.now();
77+
const v = await vol.ls("p");
78+
log("get listing of files took", Date.now() - t0, "ms");
79+
const w = v.map(({ name }) => name);
80+
expect(w.sort()).toEqual(names.sort());
81+
});
82+
});
83+
3984
afterAll(after);

0 commit comments

Comments
 (0)