Skip to content

Commit d3f6233

Browse files
committed
btrfs snapshots: api for *users* create and delete snapshots
1 parent f52d7e5 commit d3f6233

File tree

13 files changed

+200
-44
lines changed

13 files changed

+200
-44
lines changed

src/packages/backend/conat/test/files/file-server.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,17 @@ describe("create basic mocked file server and test it out", () => {
115115
project_id: string;
116116
name: string;
117117
}): Promise<void> => {},
118+
119+
updateSnapshots: async (_opts: {
120+
project_id: string;
121+
counts?: {
122+
frequent?: number;
123+
daily?: number;
124+
weekly?: number;
125+
monthly?: number;
126+
};
127+
limit?: number;
128+
}): Promise<void> => {},
118129
});
119130
});
120131

src/packages/backend/conat/test/project/jupyter/run-code.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,11 @@ describe("create mocked jupyter runner that does failover to backend output mana
266266
it("starts code running then closes the client, which causes output to have to be placed in the handler instead.", async () => {
267267
await client.run(cells);
268268
client.close();
269-
await wait({ until: () => handler.messages.length >= 3 });
269+
await wait({
270+
until: () => {
271+
return handler.messages.length >= 3;
272+
},
273+
});
270274
expect(handler.messages).toEqual([
271275
{ id: "a", output: 50 },
272276
{ id: "b", output: 100 },

src/packages/conat/core/client.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -652,7 +652,7 @@ export class Client extends EventEmitter {
652652
.emitWithAck("wait-for-interest", { subject, timeout });
653653
return response;
654654
} catch (err) {
655-
throw toConatError(err);
655+
throw toConatError(err, { subject });
656656
}
657657
};
658658

@@ -997,7 +997,7 @@ export class Client extends EventEmitter {
997997
});
998998
}
999999
} catch (err) {
1000-
throw toConatError(err);
1000+
throw toConatError(err, { subject });
10011001
}
10021002
if (response?.error) {
10031003
throw new ConatError(response.error, { code: response.code });
@@ -1137,7 +1137,13 @@ export class Client extends EventEmitter {
11371137
noThrow: true, // we're not catching this respond
11381138
headers: {
11391139
error,
1140-
error_attrs: { code: err.code },
1140+
error_attrs: {
1141+
code: err.code,
1142+
errno: err.errno,
1143+
path: err.path,
1144+
syscall: err.syscall,
1145+
subject: err.subject,
1146+
},
11411147
},
11421148
});
11431149
}
@@ -1187,7 +1193,7 @@ export class Client extends EventEmitter {
11871193
for await (const resp of sub) {
11881194
if (resp.headers?.error) {
11891195
yield new ConatError(`${resp.headers.error}`, {
1190-
code: resp.headers.code,
1196+
code: resp.headers.code as string | number,
11911197
});
11921198
} else {
11931199
yield resp.data;
@@ -1350,7 +1356,7 @@ export class Client extends EventEmitter {
13501356
return response;
13511357
}
13521358
} catch (err) {
1353-
throw toConatError(err);
1359+
throw toConatError(err, { subject });
13541360
}
13551361
} else {
13561362
return await this.conn.emitWithAck("publish", v);
@@ -1973,14 +1979,18 @@ function isEmpty(obj: object): boolean {
19731979
return true;
19741980
}
19751981

1976-
function toConatError(socketIoError) {
1982+
function toConatError(socketIoError, { subject }: { subject?: string } = {}) {
19771983
// only errors are "disconnected" and a timeout
19781984
const e = `${socketIoError}`;
19791985
if (e.includes("disconnected")) {
19801986
return e;
19811987
} else {
1982-
return new ConatError(`timeout - ${e}`, {
1983-
code: 408,
1984-
});
1988+
return new ConatError(
1989+
`timeout - ${e}${subject ? " subject:" + subject : ""}`,
1990+
{
1991+
code: 408,
1992+
subject,
1993+
},
1994+
);
19851995
}
19861996
}

src/packages/conat/files/file-server.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,18 @@ export interface Fileserver {
100100
limit?: number;
101101
}) => Promise<void>;
102102
deleteSnapshot: (opts: { project_id: string; name: string }) => Promise<void>;
103+
updateSnapshots: (opts: {
104+
project_id: string;
105+
counts?: {
106+
frequent?: number;
107+
daily?: number;
108+
weekly?: number;
109+
monthly?: number;
110+
};
111+
// global limit, same as with createSnapshot above; can prevent new snapshots from being
112+
// made if counts are too large!
113+
limit?: number;
114+
}) => Promise<void>;
103115
}
104116

105117
export interface Options extends Fileserver {

src/packages/conat/hub/api/projects.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export const projects = {
1515

1616
createSnapshot: authFirstRequireAccount,
1717
deleteSnapshot: authFirstRequireAccount,
18+
updateSnapshots: authFirstRequireAccount,
19+
getSnapshotQuota: authFirstRequireAccount,
1820
};
1921

2022
export type AddCollaborator =
@@ -126,4 +128,20 @@ export interface Projects {
126128
project_id: string;
127129
name: string;
128130
}) => Promise<void>;
131+
132+
updateSnapshots: (opts: {
133+
account_id?: string;
134+
project_id: string;
135+
counts?: {
136+
frequent?: number;
137+
daily?: number;
138+
weekly?: number;
139+
monthly?: number;
140+
};
141+
}) => Promise<void>;
142+
143+
getSnapshotQuota: (opts: {
144+
account_id?: string;
145+
project_id: string;
146+
}) => Promise<{ limit: number }>;
129147
}

src/packages/conat/persist/auth.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,13 @@ export function assertHasWritePermission({
4949
if (path.length > MAX_PATH_LENGTH) {
5050
throw new ConatError(
5151
`permission denied: path (of length ${path.length}) is too long (limit is '${MAX_PATH_LENGTH}' characters)`,
52-
{ code: 403 },
52+
{ code: 403, subject },
5353
);
5454
}
5555
if (path.startsWith("/") || path.endsWith("/")) {
5656
throw new ConatError(
5757
`permission denied: path '${path}' must not start or end with '/'`,
58-
{ code: 403 },
58+
{ code: 403, subject },
5959
);
6060
}
6161
const v = subject.split(".");
@@ -79,10 +79,10 @@ export function assertHasWritePermission({
7979
} else {
8080
throw new ConatError(
8181
`permission denied: subject '${subject}' does not grant write permission to path='${path}' since it is not under '${base}'`,
82-
{ code: 403 },
82+
{ code: 403, subject },
8383
);
8484
}
8585
}
8686
}
87-
throw new ConatError(`invalid subject: '${subject}'`, { code: 403 });
87+
throw new ConatError(`invalid subject: '${subject}'`, { code: 403, subject });
8888
}

src/packages/conat/persist/client.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ class PersistStreamClient extends EventEmitter {
143143
});
144144
if (resp.headers?.error) {
145145
throw new ConatError(`${resp.headers?.error}`, {
146-
code: resp.headers?.code,
146+
code: resp.headers?.code as string | number,
147147
});
148148
}
149149
if (this.changefeeds.length == 0 || this.state != "ready") {
@@ -218,7 +218,7 @@ class PersistStreamClient extends EventEmitter {
218218
});
219219
if (resp.headers?.error) {
220220
throw new ConatError(`${resp.headers?.error}`, {
221-
code: resp.headers?.code,
221+
code: resp.headers?.code as string | number,
222222
});
223223
}
224224
// an iterator over any updates that are published.
@@ -384,7 +384,9 @@ class PersistStreamClient extends EventEmitter {
384384
let seq = 0; // next expected seq number for the sub (not the data)
385385
for await (const { data, headers } of sub) {
386386
if (headers?.error) {
387-
throw new ConatError(`${headers.error}`, { code: headers.code });
387+
throw new ConatError(`${headers.error}`, {
388+
code: headers.code as string | number,
389+
});
388390
}
389391
if (data == null || this.socket.state == "closed") {
390392
// done

src/packages/conat/util.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,14 @@ import { reuseInFlight } from "@cocalc/util/reuse-in-flight";
55

66
export class ConatError extends Error {
77
code?: string | number;
8-
constructor(mesg: string, { code }) {
8+
subject?: string;
9+
constructor(
10+
mesg: string,
11+
{ code, subject }: { code?: string | number; subject?: string } = {},
12+
) {
913
super(mesg);
1014
this.code = code;
15+
this.subject = subject;
1116
}
1217
}
1318

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

Lines changed: 54 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ export const DEFAULT_SNAPSHOT_COUNTS = {
2222
monthly: 4,
2323
} as SnapshotCounts;
2424

25+
// We have at least one snapshot for each interval, assuming
26+
// there are actual changes since the last snapshot, and at
27+
// most the listed number.
2528
export interface SnapshotCounts {
2629
frequent: number;
2730
daily: number;
@@ -32,9 +35,12 @@ export interface SnapshotCounts {
3235
export async function updateRollingSnapshots({
3336
snapshots,
3437
counts,
38+
opts,
3539
}: {
3640
snapshots: SubvolumeSnapshots;
3741
counts?: Partial<SnapshotCounts>;
42+
// options to create
43+
opts?;
3844
}) {
3945
counts = { ...DEFAULT_SNAPSHOT_COUNTS, ...counts };
4046

@@ -44,46 +50,71 @@ export async function updateRollingSnapshots({
4450
counts,
4551
changed,
4652
});
47-
if (!changed) {
48-
// definitely no data written since most recent snapshot, so nothing to do
49-
return;
50-
}
5153

5254
// get exactly the iso timestamp snapshot names:
5355
const snapshotNames = (await snapshots.readdir()).filter((name) =>
5456
DATE_REGEXP.test(name),
5557
);
5658
snapshotNames.sort();
57-
if (snapshotNames.length > 0) {
58-
const age = Date.now() - new Date(snapshotNames.slice(-1)[0]).valueOf();
59+
let needNewSnapshot = false;
60+
if (changed) {
61+
const timeSinceLastSnapshot =
62+
snapshotNames.length == 0
63+
? 1e12 // infinitely old
64+
: Date.now() - new Date(snapshotNames.slice(-1)[0]).valueOf();
5965
for (const key in SNAPSHOT_INTERVALS_MS) {
60-
if (counts[key]) {
61-
if (age < SNAPSHOT_INTERVALS_MS[key]) {
62-
// no need to snapshot since there is already a sufficiently recent snapshot
63-
logger.debug("updateRollingSnapshots: no need to snapshot", {
64-
name: snapshots.subvolume.name,
65-
});
66-
return;
67-
}
68-
// counts[key] nonzero and snapshot is old enough so we'll be making a snapshot
66+
if (counts[key] && timeSinceLastSnapshot > SNAPSHOT_INTERVALS_MS[key]) {
67+
// there is NOT a sufficiently recent snapshot to satisfy the constraint
68+
// of having at least one snapshot for the given interval.
69+
needNewSnapshot = true;
6970
break;
7071
}
7172
}
7273
}
7374

74-
// make a new snapshot
75-
const name = new Date().toISOString();
76-
await snapshots.create(name);
75+
// Regarding error reporting we try to do everything below and throw the
76+
// create error or last delete error...
77+
78+
let createError: any = undefined;
79+
if (changed && needNewSnapshot) {
80+
// make a new snapshot -- but only bother
81+
// definitely no data written since most recent snapshot, so nothing to do
82+
const name = new Date().toISOString();
83+
logger.debug(
84+
"updateRollingSnapshots: creating snapshot of",
85+
snapshots.subvolume.name,
86+
);
87+
try {
88+
await snapshots.create(name, opts);
89+
snapshotNames.push(name);
90+
} catch (err) {
91+
createError = err;
92+
}
93+
}
94+
7795
// delete extra snapshots
78-
snapshotNames.push(name);
7996
const toDelete = snapshotsToDelete({ counts, snapshots: snapshotNames });
80-
for (const expired of toDelete) {
97+
let deleteError: any = undefined;
98+
for (const name of toDelete) {
8199
try {
82-
await snapshots.delete(expired);
83-
} catch {
84-
// some snapshots can't be deleted, e.g., they were used for the last send.
100+
logger.debug(
101+
"updateRollingSnapshots: deleting snapshot of",
102+
snapshots.subvolume.name,
103+
name,
104+
);
105+
await snapshots.delete(name);
106+
} catch (err) {
107+
// ONLY report this if create doesn't error, to give both delete and create a chance to run.
108+
deleteError = err;
85109
}
86110
}
111+
112+
if (createError) {
113+
throw createError;
114+
}
115+
if (deleteError) {
116+
throw deleteError;
117+
}
87118
}
88119

89120
function snapshotsToDelete({ counts, snapshots }): string[] {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,8 @@ export class SubvolumeSnapshots {
8989
};
9090

9191
// update the rolling snapshots schedule
92-
update = async (counts?: Partial<SnapshotCounts>) => {
93-
return await updateRollingSnapshots({ snapshots: this, counts });
92+
update = async (counts?: Partial<SnapshotCounts>, opts?) => {
93+
return await updateRollingSnapshots({ snapshots: this, counts, opts });
9494
};
9595

9696
// has newly written changes since last snapshot

0 commit comments

Comments
 (0)