querysub 0.617.0 → 0.620.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/appSecrets.ts +4 -2
- package/package.json +5 -2
- package/src/-a-archives/archives2.ts +37 -4
- package/src/-f-node-discovery/NodeDiscovery.ts +2 -2
- package/src/0-path-value-core/archiveLocks/ArchiveLocks2.ts +6 -12
- package/src/0-path-value-core/archiveLocks/archiveSnapshots.ts +0 -5
- package/src/1-path-client/RemoteWatcher.ts +4 -2
- package/src/archiveapps/snapshotThinningEntry.ts +364 -0
- package/src/deployManager/MachinesPage.tsx +3 -0
- package/src/deployManager/components/storage/RouteConfigView.tsx +1 -1
- package/src/deployManager/components/storage/Tag.tsx +0 -1
- package/src/deployManager/urlParams.ts +1 -1
- package/src/diagnostics/logs/diskLogger.ts +3 -0
- package/src/diagnostics/managementPages.tsx +4 -6
- package/src/diagnostics/misc-pages/PathValueServersSection.tsx +46 -22
- package/src/diagnostics/misc-pages/RoutingTablePage.tsx +0 -3
- package/src/diagnostics/misc-pages/SnapshotViewer.tsx +35 -2
- package/src/misc/appPaths.ts +5 -0
- package/src/storageSetup.ts +7 -15
package/appSecrets.ts
CHANGED
|
@@ -2,7 +2,6 @@ import fs from "fs";
|
|
|
2
2
|
import { lazy } from "socket-function/src/caching";
|
|
3
3
|
import { ArchivesBackblaze } from "sliftutils/storage/backblaze";
|
|
4
4
|
import { STORAGE_DIR, getDomain, getBackblazePath } from "./src/misc/appPaths";
|
|
5
|
-
import { Querysub } from "./src/4-querysub/Querysub";
|
|
6
5
|
import { formatDateTime } from "socket-function/src/formatting/format";
|
|
7
6
|
|
|
8
7
|
const keysArchives = lazy(() => new ArchivesBackblaze({ bucketName: getDomain() }));
|
|
@@ -26,8 +25,11 @@ export const getCloudflareCreds = lazy(async (): Promise<{ key: string; email: s
|
|
|
26
25
|
export async function getAppSecret(key: string): Promise<string | undefined> {
|
|
27
26
|
// HACK: This is used by the storage server. Which we want to shut down immediately. That way the next instance can start up. The sword server already looks for notifications about when it's going to be shutting down and it flushes to disk early in this case, so it should be fairly safe to hard kill it.
|
|
28
27
|
doImmediateShutdown();
|
|
28
|
+
|
|
29
|
+
// Actually maybe the wrapper should do this instead.
|
|
29
30
|
// Import query sub so our logs go to our actual log server. Otherwise we won't know if we get any errors.
|
|
30
|
-
Querysub;
|
|
31
|
+
//await import("./src/4-querysub/Querysub");
|
|
32
|
+
|
|
31
33
|
if (key.startsWith("backblaze.json.")) {
|
|
32
34
|
let creds = JSON.parse(fs.readFileSync(getBackblazePath(), "utf8")) as { applicationKeyId?: string; applicationKey?: string };
|
|
33
35
|
if (key === "backblaze.json.applicationKeyId") return creds.applicationKeyId;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "querysub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.620.0",
|
|
4
4
|
"main": "index.js",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
"mcp2": "yarn typenode ./src/diagnostics/debugger/mcp-server.ts",
|
|
26
26
|
"mc2": "yarn typenode ./src/diagnostics/debugger/mcp-server.ts --cwd D:/repos/qs-cyoa/",
|
|
27
27
|
"autofix": "yarn typenode ./src/diagnostics/logs/errorTickets/autoFixerEntry.ts",
|
|
28
|
+
"thin": "yarn typenode ./src/archiveapps/snapshotThinningEntry.ts",
|
|
29
|
+
"thin-watch": "yarn typenode ./src/archiveapps/snapshotThinningEntry.ts --watch",
|
|
30
|
+
"thin-dry": "yarn typenode ./src/archiveapps/snapshotThinningEntry.ts --dry-run",
|
|
28
31
|
"parameters-timeline": "yarn typenode ./src/deployManager/parametersTimelineMain.ts",
|
|
29
32
|
"setup-machine": "yarn typenode ./src/deployManager/setupMachineMain.ts",
|
|
30
33
|
"stop-machine": "yarn typenode ./src/deployManager/stopMachineMain.ts",
|
|
@@ -75,7 +78,7 @@
|
|
|
75
78
|
"node-forge": "https://github.com/sliftist/forge#e618181b469b07bdc70b968b0391beb8ef5fecd6",
|
|
76
79
|
"pako": "^2.1.0",
|
|
77
80
|
"peggy": "^5.0.6",
|
|
78
|
-
"sliftutils": "^1.7.
|
|
81
|
+
"sliftutils": "^1.7.89",
|
|
79
82
|
"socket-function": "^1.2.30",
|
|
80
83
|
"terser": "^5.31.0",
|
|
81
84
|
"typenode": "^6.6.1",
|
|
@@ -44,13 +44,22 @@ function archiveBuilder(bucket: string, overrides: Partial<RemoteConfigBase>) {
|
|
|
44
44
|
const HETZNER = `https://65-109-93-113.querysubtest.com:5234/file/${STORAGE_ACCOUNT}/${bucket}/storage/storagerouting.json`;
|
|
45
45
|
const BACKBLAZE = `https://f002.backblazeb2.com/file/${bucket}/storage/storagerouting.json`;
|
|
46
46
|
|
|
47
|
-
function stripeSources(urls: string[]) {
|
|
47
|
+
function stripeSources(urls: string[], config: { routeVersion: number }) {
|
|
48
48
|
let sources: Omit<Exclude<RemoteConfigBase, string>, "validWindow">[] = [];
|
|
49
49
|
for (let j = 0; j < urls.length; j++) {
|
|
50
50
|
// Shift by j
|
|
51
51
|
let currentUrls = urls.slice(j).concat(urls.slice(0, j));
|
|
52
52
|
for (let i = 0; i < urls.length; i++) {
|
|
53
|
-
|
|
53
|
+
let url = new URL(currentUrls[i]);
|
|
54
|
+
let routeStart = i / urls.length;
|
|
55
|
+
let routeEnd = (i + 1) / urls.length;
|
|
56
|
+
sources.push({
|
|
57
|
+
type: "remote",
|
|
58
|
+
url: currentUrls[i],
|
|
59
|
+
route: [routeStart, routeEnd],
|
|
60
|
+
// IMPORTANT! name decides where our files are stored. We can't have two things writing to the same folder at the same time. But it also means if we reuse the name, then it has to be the same data.
|
|
61
|
+
name: url.hostname.replaceAll(/[^a-z0-9-]/g, "_") + "_v" + config.routeVersion + "_" + routeStart + "_" + routeEnd,
|
|
62
|
+
});
|
|
54
63
|
}
|
|
55
64
|
}
|
|
56
65
|
return sources;
|
|
@@ -66,8 +75,9 @@ function archiveBuilder(bucket: string, overrides: Partial<RemoteConfigBase>) {
|
|
|
66
75
|
sources: [
|
|
67
76
|
// NOTE: Once we add new sources, we need to add something at a new start time in the future so we have time to move the files.
|
|
68
77
|
// TODO: EVENTUALLY, Once we have enough data, instead of restriping, we need to bisect the data. That way we can slowly move over the data instead of trying to rewrite all of our data all at once. However, for the moment, moving all the data is probably fine. On the data center, we should be able to move terabytes of data every hour, so we should be able to handle quite a bit, especially if we have a few days to run the restriping.
|
|
69
|
-
|
|
70
|
-
|
|
78
|
+
// IMPORTANT! Every time we re-stripe, we need to increment the route version. Otherwise, we could reuse a folder that we previously used that's no longer in sync. which will cause us to resurrect old data, which is very bad. We don't want to do that...
|
|
79
|
+
...stripeSources([ONTARIO, HETZNER], { routeVersion: 1 }),
|
|
80
|
+
{ type: "backblaze", url: BACKBLAZE, name: "backblaze" },
|
|
71
81
|
]
|
|
72
82
|
},
|
|
73
83
|
]);
|
|
@@ -159,6 +169,29 @@ export function getArchives2Public(folder: string) {
|
|
|
159
169
|
return nestBucket(folder, archivesBuilderCache(domain + "-public", { public: true, allowedOrigins: getAllowedOrigins(getDomain()) }));
|
|
160
170
|
}
|
|
161
171
|
|
|
172
|
+
export function getArchives2Debug(folder: string) {
|
|
173
|
+
let routeVersion = 1;
|
|
174
|
+
let archives = createArchives({
|
|
175
|
+
version: 20,
|
|
176
|
+
sources: [
|
|
177
|
+
// IMPORTANT! If we start changing around routes, it could resurrect old data. We can fix this via incrementing the route version.
|
|
178
|
+
{
|
|
179
|
+
type: "remote", url: `https://99-250-124-91.querysubtest.com:5239/file/${STORAGE_ACCOUNT}/${folder}/storage/storagerouting.json`,
|
|
180
|
+
validWindow: [0, Number.MAX_SAFE_INTEGER],
|
|
181
|
+
route: [0, 0.5],
|
|
182
|
+
name: "storagea_v" + routeVersion,
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
type: "remote", url: `https://65-109-93-113.querysubtest.com:5240/file/${STORAGE_ACCOUNT}/${folder}/storage/storagerouting.json`,
|
|
186
|
+
validWindow: [0, Number.MAX_SAFE_INTEGER],
|
|
187
|
+
route: [0.5, 1],
|
|
188
|
+
name: "storageb_v" + routeVersion,
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
});
|
|
192
|
+
return nestBucket(folder, archives);
|
|
193
|
+
}
|
|
194
|
+
|
|
162
195
|
|
|
163
196
|
export async function initArchives2() {
|
|
164
197
|
let buckets = [
|
|
@@ -541,9 +541,9 @@ async function runServerSyncLoops() {
|
|
|
541
541
|
let suicideThreshold = Date.now() - SUICIDE_HEARTBEAT_THRESHOLD;
|
|
542
542
|
if (!lastTime || lastTime < suicideThreshold) {
|
|
543
543
|
if (!lastTime) {
|
|
544
|
-
console.error(red(`Self node was removed due to not
|
|
544
|
+
console.error(red(`Self node was removed due to heartbeat file not existing (${selfNodeId}). Terminating self process, as it likely has very stale data.`));
|
|
545
545
|
} else {
|
|
546
|
-
console.error(red(`Self node was has very old heartbeat. Terminating self process, as it likely has very stale data
|
|
546
|
+
console.error(red(`Self node was has very old heartbeat. Terminating self process, as it likely has very stale data. ${formatDateTime(lastTime)} < ${formatDateTime(suicideThreshold)}, now is ${formatDateTime(Date.now())}`));
|
|
547
547
|
}
|
|
548
548
|
process.exit();
|
|
549
549
|
}
|
|
@@ -4,7 +4,8 @@ import { decodeNodeId } from "sliftutils/misc/https/certs";
|
|
|
4
4
|
import { getOwnNodeId, getOwnNodeIdAssert } from "../../-f-node-discovery/NodeDiscovery";
|
|
5
5
|
import { pathValueArchives } from "../pathValueArchives";
|
|
6
6
|
import { ArchiveLocker, ArchiveTransaction } from "./ArchiveLocks";
|
|
7
|
-
import { ArchivesSyncStatus,
|
|
7
|
+
import { ArchivesSyncStatus, IArchives } from "sliftutils/storage/IArchives";
|
|
8
|
+
import { copyArchiveFile, moveArchiveFile } from "sliftutils/storage/archiveHelpers";
|
|
8
9
|
import { formatNumber, formatTime } from "socket-function/src/formatting/format";
|
|
9
10
|
import { blue, green, magenta, red } from "socket-function/src/formatting/logColors";
|
|
10
11
|
import { devDebugbreak, getDomain } from "../../config";
|
|
@@ -35,15 +36,7 @@ const ASSUME_CONFIRMED_BEFORE = Date.parse("2026-07-23T00:00:00.000Z");
|
|
|
35
36
|
const CONCURRENT_READ_COUNT = 128;
|
|
36
37
|
const CONCURRENT_WRITE_COUNT = 128;
|
|
37
38
|
const CONCURRENT_TRANSACTION_READS = 8;
|
|
38
|
-
// When getFiles fails, how long we wait on the lock store's sync status before assuming the store is unreachable and falling back to non-authoritative (backblaze) reads.
|
|
39
|
-
const FALLBACK_SYNC_STATUS_TIMEOUT = timeInSecond * 20;
|
|
40
39
|
|
|
41
|
-
/** Moves by copy-then-delete (IArchives has no move). copyArchiveFile stamps the target with the source's write time, so ordering-by-writeTime survives the move. A missing source means another process already moved it, which is fine. */
|
|
42
|
-
async function moveArchiveFile(from: IArchives, to: IArchives, path: string): Promise<void> {
|
|
43
|
-
let copied = await copyArchiveFile({ from, to, path, noFallbacks: true });
|
|
44
|
-
if (!copied) return;
|
|
45
|
-
await from.del(path);
|
|
46
|
-
}
|
|
47
40
|
|
|
48
41
|
export function createArchiveLocker2(config: {
|
|
49
42
|
archiveValues: IArchives;
|
|
@@ -97,7 +90,7 @@ export function createArchiveLocker2(config: {
|
|
|
97
90
|
return;
|
|
98
91
|
}
|
|
99
92
|
try {
|
|
100
|
-
await moveArchiveFile(archives, archiveRecycleBin, key);
|
|
93
|
+
await moveArchiveFile({ from: archives, to: archiveRecycleBin, path: key, noFallbacks: true });
|
|
101
94
|
logNodeStats(`archives|Deleted TΔ`, formatNumber, 1);
|
|
102
95
|
} catch {
|
|
103
96
|
// It was probably just moved by another process
|
|
@@ -134,7 +127,7 @@ export function createArchiveLocker2(config: {
|
|
|
134
127
|
if (correctFiles.has(file)) return;
|
|
135
128
|
let info = await archiveValues.getInfo(file);
|
|
136
129
|
console.log(`Moving file ${file}, ${moveCount}/${valuePaths.size}, ${formatNumber(info?.size || 0)} bytes`);
|
|
137
|
-
await moveArchiveFile(archiveValues, archiveRecycleBin, file);
|
|
130
|
+
await moveArchiveFile({ from: archiveValues, to: archiveRecycleBin, path: file, noFallbacks: true });
|
|
138
131
|
console.log(`Moved file ${file}, ${moveCount}/${valuePaths.size}`);
|
|
139
132
|
}
|
|
140
133
|
let moveFileParallel = runInParallel({ parallelCount: 8 }, moveFile);
|
|
@@ -155,7 +148,8 @@ export function createArchiveLocker2(config: {
|
|
|
155
148
|
let isInRecycleBin = !!(await archiveRecycleBin.getInfo(file));
|
|
156
149
|
if (isInRecycleBin) {
|
|
157
150
|
console.log(`Restoring ${file}, ${restoreCount++}/${files.length}`);
|
|
158
|
-
|
|
151
|
+
// We are really careful to never delete from the recycle bin. Restores use copy instead of move, because move could theoretically have a bug where it deletes the file instead of moving it, and then our backups are gone. Copying barely costs any extra storage and isn't much slower, so move is reserved for the other direction (moving files INTO the recycle bin), where the source is still recoverable if something goes wrong.
|
|
152
|
+
await copyArchiveFile({ from: archiveRecycleBin, to: archiveValues, path: file, noFallbacks: true });
|
|
159
153
|
} else {
|
|
160
154
|
console.log(`File ${file} is not in the recycle bin, skipping, ${restoreCount++}/${files.length}`);
|
|
161
155
|
}
|
|
@@ -215,9 +215,4 @@ export async function loadSnapshot(config: {
|
|
|
215
215
|
for (let i = 0; i < 10; i++) {
|
|
216
216
|
console.log(green(`!!!Finished loading snapshot!!!`));
|
|
217
217
|
}
|
|
218
|
-
|
|
219
|
-
if (!config.noExit && !isPublic()) {
|
|
220
|
-
// Exit so we know when this is done. Really, all the servers need to be restarted after we load the snapshot, anyway.
|
|
221
|
-
process.exit();
|
|
222
|
-
}
|
|
223
218
|
}
|
|
@@ -35,7 +35,9 @@ const SHUTDOWN_REHOME_WINDOW = timeInMinute * 3;
|
|
|
35
35
|
const SHUTDOWN_REHOME_POLL_INTERVAL = timeInSecond * 30;
|
|
36
36
|
|
|
37
37
|
// Rehome the watches on an authority when an equivalent authority has this factor lower latency. At 2 the router's full-confidence cutoff factor (also 2) then reliably routes everything to the better node on the rewatch.
|
|
38
|
-
const LATENCY_REHOME_FACTOR =
|
|
38
|
+
const LATENCY_REHOME_FACTOR = 1.5;
|
|
39
|
+
// Jitter floor added to the candidate's latency before the factor comparison. Below roughly this many milliseconds the difference between two nodes is noise, not speed (1ms vs 2ms isn't "twice as fast"), and without the floor a candidate that momentarily reads near-zero always looks infinitely faster, so we rehome on pure jitter. Added only to the candidate, keeping us biased against needless moves.
|
|
40
|
+
const LATENCY_REHOME_JITTER_MS = 50;
|
|
39
41
|
// Both latencies must be medians over this many samples — with fewer we don't trust either number enough to move watches over it.
|
|
40
42
|
const LATENCY_REHOME_HISTORY_COUNT = 10;
|
|
41
43
|
const LATENCY_REHOME_POLL_INTERVAL = timeInMinute;
|
|
@@ -186,7 +188,7 @@ export class RemoteWatcher {
|
|
|
186
188
|
if (!areAuthoritySpecsEquivalent(entry.authoritySpec, candidate.authoritySpec)) continue;
|
|
187
189
|
let candidateLatency = getNodeLatencyMedian({ nodeId: candidate.nodeId, historyCount: LATENCY_REHOME_HISTORY_COUNT });
|
|
188
190
|
if (!candidateLatency || candidateLatency.historyUsed < LATENCY_REHOME_HISTORY_COUNT) continue;
|
|
189
|
-
if (candidateLatency.latency * LATENCY_REHOME_FACTOR > current.latency) continue;
|
|
191
|
+
if ((candidateLatency.latency + LATENCY_REHOME_JITTER_MS) * LATENCY_REHOME_FACTOR > current.latency) continue;
|
|
190
192
|
console.log(yellow(`Authority ${authorityId} has high latency (${formatTime(current.latency)}), and the equivalent authority ${candidate.nodeId} is much faster (${formatTime(candidateLatency.latency)}), so we are rehoming all paths watched on it`));
|
|
191
193
|
logErrors(this.refreshAllWatches(authorityId));
|
|
192
194
|
break;
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import "../inject";
|
|
2
|
+
|
|
3
|
+
import { Querysub } from "../4-querysub/Querysub";
|
|
4
|
+
import { logErrors } from "../errors";
|
|
5
|
+
import { disablePathAuditer } from "../diagnostics/pathAuditerCallback";
|
|
6
|
+
import { isNodeTrue, sort, timeInDay, timeInHour, timeInWeek, timeInYear } from "socket-function/src/misc";
|
|
7
|
+
import { runInfinitePollCallAtStart, runInParallel } from "socket-function/src/batching";
|
|
8
|
+
import { green, magenta, red, yellow } from "socket-function/src/formatting/logColors";
|
|
9
|
+
import { formatNumber, formatTime } from "socket-function/src/formatting/format";
|
|
10
|
+
import { archiveJSONT } from "../-a-archives/archivesJSONT";
|
|
11
|
+
import { archives, archivesRecycleBin } from "../0-path-value-core/pathValueArchives";
|
|
12
|
+
import { lazy } from "socket-function/src/caching";
|
|
13
|
+
import yargs from "yargs";
|
|
14
|
+
import { getArchives2 } from "../-a-archives/archives2";
|
|
15
|
+
|
|
16
|
+
const timeInMonth = timeInDay * 30;
|
|
17
|
+
|
|
18
|
+
const POLL_INTERVAL = timeInHour * 24;
|
|
19
|
+
|
|
20
|
+
const MIN_PER_RANGE = 10;
|
|
21
|
+
const thinningRanges: {
|
|
22
|
+
timeRange: number;
|
|
23
|
+
modulus: number;
|
|
24
|
+
}[] = [
|
|
25
|
+
{ timeRange: timeInDay * 3, modulus: 1 },
|
|
26
|
+
{ timeRange: timeInWeek * 2, modulus: 2 },
|
|
27
|
+
{ timeRange: timeInMonth * 2, modulus: 8 },
|
|
28
|
+
{ timeRange: timeInYear * 100, modulus: 64 },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
// Unreferenced recycle bin files younger than this are kept. Deleting an unreferenced recycle file too eagerly would destroy the backup of anything recycled since the last kept snapshot, so orphans only die once they have sat unreferenced long enough that nothing could still want them back.
|
|
32
|
+
const RECYCLE_ORPHAN_MIN_AGE = timeInMonth * 2;
|
|
33
|
+
|
|
34
|
+
const INDEX_KEY = "index";
|
|
35
|
+
const PARALLEL_DELETE_COUNT = 32;
|
|
36
|
+
const PARALLEL_READ_COUNT = 32;
|
|
37
|
+
const PROGRESS_LOG_INTERVAL = 10 * 1000;
|
|
38
|
+
|
|
39
|
+
const snapshotsArchive = lazy(() => getArchives2("snapshots"));
|
|
40
|
+
const snapshotThinningArchive = lazy(() => getArchives2("snapshot-thinning-index"));
|
|
41
|
+
|
|
42
|
+
type IndexMappings = {
|
|
43
|
+
nextIndex: number;
|
|
44
|
+
indexes: { [snapshotFile: string]: number };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const indexMappingStore = archiveJSONT<IndexMappings>(snapshotThinningArchive);
|
|
48
|
+
|
|
49
|
+
let yargObj = isNodeTrue() && yargs(process.argv)
|
|
50
|
+
.option("watch", { type: "boolean", desc: "Watch, and run thinning every hour." })
|
|
51
|
+
.option("dry-run", { type: "boolean", desc: "Don't actually delete anything, just log what would happen." })
|
|
52
|
+
.argv || {};
|
|
53
|
+
|
|
54
|
+
type SnapshotInfo = {
|
|
55
|
+
file: string;
|
|
56
|
+
time: number;
|
|
57
|
+
size: number;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function parseSnapshotTime(file: string): number {
|
|
61
|
+
let parts = file.split(" ");
|
|
62
|
+
for (let part of parts) {
|
|
63
|
+
let [key, value] = part.split("=");
|
|
64
|
+
if (key === "time") return +value;
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`Snapshot file name has no time field: ${file}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function listSnapshots(): Promise<SnapshotInfo[]> {
|
|
70
|
+
let infos = await snapshotsArchive().findInfo("");
|
|
71
|
+
let result: SnapshotInfo[] = [];
|
|
72
|
+
for (let info of infos) {
|
|
73
|
+
result.push({
|
|
74
|
+
file: info.path,
|
|
75
|
+
time: parseSnapshotTime(info.path),
|
|
76
|
+
size: info.size,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function loadIndexMappings(): Promise<IndexMappings> {
|
|
83
|
+
let existing = await indexMappingStore.get(INDEX_KEY);
|
|
84
|
+
if (existing) return existing;
|
|
85
|
+
return { nextIndex: 0, indexes: {} };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function assignIndexes(snapshots: SnapshotInfo[], mappings: IndexMappings): {
|
|
89
|
+
mappings: IndexMappings;
|
|
90
|
+
changed: boolean;
|
|
91
|
+
} {
|
|
92
|
+
let changed = false;
|
|
93
|
+
let nextMappings: IndexMappings = {
|
|
94
|
+
nextIndex: mappings.nextIndex,
|
|
95
|
+
indexes: { ...mappings.indexes },
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
let currentFiles = new Set(snapshots.map(s => s.file));
|
|
99
|
+
for (let file of Object.keys(nextMappings.indexes)) {
|
|
100
|
+
if (!currentFiles.has(file)) {
|
|
101
|
+
delete nextMappings.indexes[file];
|
|
102
|
+
changed = true;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let unassigned = snapshots.filter(s => !(s.file in nextMappings.indexes));
|
|
107
|
+
sort(unassigned, s => s.time);
|
|
108
|
+
for (let snap of unassigned) {
|
|
109
|
+
nextMappings.indexes[snap.file] = nextMappings.nextIndex++;
|
|
110
|
+
changed = true;
|
|
111
|
+
}
|
|
112
|
+
return { mappings: nextMappings, changed };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function pickThinningRange(ageMs: number) {
|
|
116
|
+
for (let range of thinningRanges) {
|
|
117
|
+
if (ageMs <= range.timeRange) return range;
|
|
118
|
+
}
|
|
119
|
+
return thinningRanges[thinningRanges.length - 1];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function decideThinning(snapshots: SnapshotInfo[], mappings: IndexMappings, now: number): {
|
|
123
|
+
keep: SnapshotInfo[];
|
|
124
|
+
remove: SnapshotInfo[];
|
|
125
|
+
} {
|
|
126
|
+
let bucketed = new Map<typeof thinningRanges[number], SnapshotInfo[]>();
|
|
127
|
+
for (let snap of snapshots) {
|
|
128
|
+
let range = pickThinningRange(now - snap.time);
|
|
129
|
+
let list = bucketed.get(range);
|
|
130
|
+
if (!list) {
|
|
131
|
+
list = [];
|
|
132
|
+
bucketed.set(range, list);
|
|
133
|
+
}
|
|
134
|
+
list.push(snap);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let keep: SnapshotInfo[] = [];
|
|
138
|
+
let remove: SnapshotInfo[] = [];
|
|
139
|
+
for (let [range, list] of bucketed) {
|
|
140
|
+
if (list.length <= MIN_PER_RANGE || range.modulus <= 1) {
|
|
141
|
+
keep.push(...list);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
let rangeKeep: SnapshotInfo[] = [];
|
|
145
|
+
let rangeRemove: SnapshotInfo[] = [];
|
|
146
|
+
for (let snap of list) {
|
|
147
|
+
let idx = mappings.indexes[snap.file];
|
|
148
|
+
if (idx % range.modulus === 0) {
|
|
149
|
+
rangeKeep.push(snap);
|
|
150
|
+
} else {
|
|
151
|
+
rangeRemove.push(snap);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
sort(rangeRemove, s => s.time);
|
|
155
|
+
while (rangeRemove.length > 0 && rangeKeep.length < MIN_PER_RANGE) {
|
|
156
|
+
let restored = rangeRemove.pop();
|
|
157
|
+
if (!restored) break;
|
|
158
|
+
rangeKeep.push(restored);
|
|
159
|
+
}
|
|
160
|
+
keep.push(...rangeKeep);
|
|
161
|
+
remove.push(...rangeRemove);
|
|
162
|
+
}
|
|
163
|
+
return { keep, remove };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function readSnapshotFiles(file: string): Promise<string[]> {
|
|
167
|
+
let buf = await snapshotsArchive().get(file);
|
|
168
|
+
if (!buf) {
|
|
169
|
+
throw new Error(`Snapshot file unreadable: ${file}`);
|
|
170
|
+
}
|
|
171
|
+
let text = buf.toString();
|
|
172
|
+
if (!text) return [];
|
|
173
|
+
return text.split("\n");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function runThinningIteration(config: { dryRun: boolean }) {
|
|
177
|
+
let { dryRun } = config;
|
|
178
|
+
let prefix = dryRun ? yellow(`[DRY RUN] `) : ``;
|
|
179
|
+
let now = Date.now();
|
|
180
|
+
|
|
181
|
+
console.log(magenta(`${prefix}Starting snapshot thinning iteration (dryRun=${dryRun})`));
|
|
182
|
+
|
|
183
|
+
let snapshots = await listSnapshots();
|
|
184
|
+
sort(snapshots, s => s.time);
|
|
185
|
+
|
|
186
|
+
let mappingsBase = await loadIndexMappings();
|
|
187
|
+
let { mappings, changed } = assignIndexes(snapshots, mappingsBase);
|
|
188
|
+
|
|
189
|
+
if (changed && !dryRun) {
|
|
190
|
+
await indexMappingStore.set(INDEX_KEY, mappings);
|
|
191
|
+
} else if (changed) {
|
|
192
|
+
console.log(`${prefix}Would update index mappings (next index ${mappings.nextIndex}, ${Object.keys(mappings.indexes).length} entries)`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let totalSnapshotBytes = snapshots.reduce((s, x) => s + x.size, 0);
|
|
196
|
+
console.log(`${prefix}Snapshots: ${formatNumber(snapshots.length)}, total snapshot file bytes: ${formatNumber(totalSnapshotBytes)}`);
|
|
197
|
+
|
|
198
|
+
let { keep, remove } = decideThinning(snapshots, mappings, now);
|
|
199
|
+
let removedBytes = remove.reduce((s, x) => s + x.size, 0);
|
|
200
|
+
console.log(`${prefix}Snapshots to keep: ${formatNumber(keep.length)}, to remove: ${formatNumber(remove.length)} (${formatNumber(removedBytes)} bytes)`);
|
|
201
|
+
|
|
202
|
+
for (let range of thinningRanges) {
|
|
203
|
+
let inRangeAll = snapshots.filter(s => pickThinningRange(now - s.time) === range);
|
|
204
|
+
let inRangeKeep = keep.filter(s => pickThinningRange(now - s.time) === range);
|
|
205
|
+
console.log(` Range age<=${formatTime(range.timeRange)} modulus=${range.modulus}: ${formatNumber(inRangeAll.length)} => ${formatNumber(inRangeKeep.length)}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let recycleListStart = Date.now();
|
|
209
|
+
let recycleInfos = await archivesRecycleBin().findInfo("");
|
|
210
|
+
let recycleListDuration = Date.now() - recycleListStart;
|
|
211
|
+
let recycleSet = new Map<string, { size: number; createTime: number }>();
|
|
212
|
+
for (let info of recycleInfos) {
|
|
213
|
+
recycleSet.set(info.path, { size: info.size, createTime: info.createTime });
|
|
214
|
+
}
|
|
215
|
+
let totalRecycleBytes = 0;
|
|
216
|
+
for (let info of recycleSet.values()) totalRecycleBytes += info.size;
|
|
217
|
+
console.log(`${prefix}Recycle bin files: ${formatNumber(recycleSet.size)}, total bytes: ${formatNumber(totalRecycleBytes)}`);
|
|
218
|
+
|
|
219
|
+
let parallelRead = runInParallel({ parallelCount: PARALLEL_READ_COUNT }, readSnapshotFiles);
|
|
220
|
+
let referencedFiles = new Set<string>();
|
|
221
|
+
let keepFileLists = await Promise.all(keep.map(s => parallelRead(s.file)));
|
|
222
|
+
for (let list of keepFileLists) {
|
|
223
|
+
for (let f of list) referencedFiles.add(f);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let liveInfos = await archives().findInfo("");
|
|
227
|
+
let totalLiveBytes = 0;
|
|
228
|
+
for (let info of liveInfos) totalLiveBytes += info.size;
|
|
229
|
+
console.log(`${prefix}Production archives (live path-values): ${formatNumber(liveInfos.length)} files, ${formatNumber(totalLiveBytes)} bytes`);
|
|
230
|
+
|
|
231
|
+
for (let info of liveInfos) {
|
|
232
|
+
referencedFiles.add(info.path);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let recycleToDelete: { file: string; size: number }[] = [];
|
|
236
|
+
let youngOrphanCount = 0;
|
|
237
|
+
let youngOrphanBytes = 0;
|
|
238
|
+
for (let [file, info] of recycleSet) {
|
|
239
|
+
if (referencedFiles.has(file)) continue;
|
|
240
|
+
if (now - info.createTime < RECYCLE_ORPHAN_MIN_AGE) {
|
|
241
|
+
youngOrphanCount++;
|
|
242
|
+
youngOrphanBytes += info.size;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
recycleToDelete.push({ file, size: info.size });
|
|
246
|
+
}
|
|
247
|
+
let recycleDeleteBytes = recycleToDelete.reduce((s, x) => s + x.size, 0);
|
|
248
|
+
console.log(`${prefix}Planned: delete ${formatNumber(remove.length)} snapshots (${formatNumber(removedBytes)} bytes), ${formatNumber(recycleToDelete.length)} recycle bin files (${formatNumber(recycleDeleteBytes)} bytes). Keeping ${formatNumber(youngOrphanCount)} unreferenced recycle bin files (${formatNumber(youngOrphanBytes)} bytes) that are younger than ${formatTime(RECYCLE_ORPHAN_MIN_AGE)}`);
|
|
249
|
+
|
|
250
|
+
function logSummary() {
|
|
251
|
+
let snapshotCountAfter = keep.length;
|
|
252
|
+
let snapshotBytesAfter = totalSnapshotBytes - removedBytes;
|
|
253
|
+
let recycleCountAfter = recycleSet.size - recycleToDelete.length;
|
|
254
|
+
let recycleBytesAfter = totalRecycleBytes - recycleDeleteBytes;
|
|
255
|
+
let beforeTotalBytes = totalSnapshotBytes + totalRecycleBytes;
|
|
256
|
+
let afterTotalBytes = snapshotBytesAfter + recycleBytesAfter;
|
|
257
|
+
let beforeTotalFiles = snapshots.length + recycleSet.size;
|
|
258
|
+
let afterTotalFiles = snapshotCountAfter + recycleCountAfter;
|
|
259
|
+
|
|
260
|
+
function pct(after: number, before: number) {
|
|
261
|
+
if (!before) return `100%`;
|
|
262
|
+
return `${(after / before * 100).toFixed(1)}%`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
console.log(magenta(`${prefix}===== Summary =====`));
|
|
266
|
+
console.log(`${prefix}Snapshots: ${formatNumber(snapshots.length)} => ${formatNumber(snapshotCountAfter)} files (${pct(snapshotCountAfter, snapshots.length)} kept), ${formatNumber(totalSnapshotBytes)} => ${formatNumber(snapshotBytesAfter)} bytes (${pct(snapshotBytesAfter, totalSnapshotBytes)} kept)`);
|
|
267
|
+
console.log(`${prefix}Recycle bin: ${formatNumber(recycleSet.size)} => ${formatNumber(recycleCountAfter)} files (${pct(recycleCountAfter, recycleSet.size)} kept), ${formatNumber(totalRecycleBytes)} => ${formatNumber(recycleBytesAfter)} bytes (${pct(recycleBytesAfter, totalRecycleBytes)} kept)`);
|
|
268
|
+
console.log(`${prefix}Total: ${formatNumber(beforeTotalFiles)} => ${formatNumber(afterTotalFiles)} files (${pct(afterTotalFiles, beforeTotalFiles)} kept), ${formatNumber(beforeTotalBytes)} => ${formatNumber(afterTotalBytes)} bytes (${pct(afterTotalBytes, beforeTotalBytes)} kept)`);
|
|
269
|
+
console.log(`${prefix}Live archives (unchanged): ${formatNumber(liveInfos.length)} files, ${formatNumber(totalLiveBytes)} bytes`);
|
|
270
|
+
console.log(`${prefix}Recycle bin findInfo("") took: ${formatTime(recycleListDuration)} (for ${formatNumber(recycleSet.size)} files)`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (dryRun) {
|
|
274
|
+
logSummary();
|
|
275
|
+
console.log(green(`${prefix}Dry run complete. No changes made.`));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function deleteWithProgress(label: string, files: string[], deleter: (file: string) => Promise<void>) {
|
|
280
|
+
if (files.length === 0) {
|
|
281
|
+
console.log(`No ${label} to delete`);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
let done = 0;
|
|
285
|
+
let failed = 0;
|
|
286
|
+
let startTime = Date.now();
|
|
287
|
+
let lastLog = startTime;
|
|
288
|
+
let lastLogDone = 0;
|
|
289
|
+
let peakRate = 0;
|
|
290
|
+
function logProgress() {
|
|
291
|
+
let nowTs = Date.now();
|
|
292
|
+
let elapsed = nowTs - startTime;
|
|
293
|
+
let avgRate = done / (elapsed / 1000);
|
|
294
|
+
let intervalMs = nowTs - lastLog;
|
|
295
|
+
let intervalRate = intervalMs > 0 ? (done - lastLogDone) / (intervalMs / 1000) : 0;
|
|
296
|
+
if (intervalRate > peakRate) peakRate = intervalRate;
|
|
297
|
+
let remaining = files.length - done;
|
|
298
|
+
let etaMs = avgRate > 0 ? remaining / avgRate * 1000 : 0;
|
|
299
|
+
console.log(` ${label}: ${formatNumber(done)}/${formatNumber(files.length)} deleted (${(done / files.length * 100).toFixed(1)}%), ${formatNumber(failed)} failed, elapsed ${formatTime(elapsed)}, ETA ${formatTime(etaMs)}, avg ${avgRate.toFixed(1)}/s, peak ${peakRate.toFixed(1)}/s`);
|
|
300
|
+
lastLog = nowTs;
|
|
301
|
+
lastLogDone = done;
|
|
302
|
+
}
|
|
303
|
+
let parallel = runInParallel({ parallelCount: PARALLEL_DELETE_COUNT }, async (file: string) => {
|
|
304
|
+
try {
|
|
305
|
+
await deleter(file);
|
|
306
|
+
} catch (e) {
|
|
307
|
+
failed++;
|
|
308
|
+
console.error(`Failed to delete ${file}: ${String((e as Error)?.stack || e)}`);
|
|
309
|
+
}
|
|
310
|
+
done++;
|
|
311
|
+
if (Date.now() - lastLog >= PROGRESS_LOG_INTERVAL) {
|
|
312
|
+
logProgress();
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
console.log(`Deleting ${formatNumber(files.length)} ${label}...`);
|
|
316
|
+
await Promise.all(files.map(f => parallel(f)));
|
|
317
|
+
logProgress();
|
|
318
|
+
console.log(green(`Deleted ${formatNumber(done - failed)} ${label} (${formatNumber(failed)} failed) in ${formatTime(Date.now() - startTime)}`));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
await deleteWithProgress("snapshots", remove.map(s => s.file), async file => {
|
|
322
|
+
await snapshotsArchive().del(file);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
if (remove.length > 0) {
|
|
326
|
+
let mappingsAfter: IndexMappings = {
|
|
327
|
+
nextIndex: mappings.nextIndex,
|
|
328
|
+
indexes: { ...mappings.indexes },
|
|
329
|
+
};
|
|
330
|
+
for (let snap of remove) {
|
|
331
|
+
delete mappingsAfter.indexes[snap.file];
|
|
332
|
+
}
|
|
333
|
+
await indexMappingStore.set(INDEX_KEY, mappingsAfter);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
await deleteWithProgress("recycle bin files", recycleToDelete.map(x => x.file), async file => {
|
|
337
|
+
await archivesRecycleBin().del(file);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
logSummary();
|
|
341
|
+
console.log(green(`Snapshot thinning complete.`));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function main() {
|
|
345
|
+
disablePathAuditer();
|
|
346
|
+
await Querysub.hostService("snapshot-thinning");
|
|
347
|
+
|
|
348
|
+
let dryRun = !!yargObj["dry-run"];
|
|
349
|
+
|
|
350
|
+
if (yargObj.watch) {
|
|
351
|
+
console.log("Running in watch mode.");
|
|
352
|
+
await runInfinitePollCallAtStart(POLL_INTERVAL, () => runThinningIteration({ dryRun }));
|
|
353
|
+
} else {
|
|
354
|
+
try {
|
|
355
|
+
await runThinningIteration({ dryRun });
|
|
356
|
+
} catch (error) {
|
|
357
|
+
console.error((error as Error).stack ?? error);
|
|
358
|
+
} finally {
|
|
359
|
+
process.exit();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
main().catch(logErrors);
|
|
@@ -10,6 +10,7 @@ import { MachineDetailPage } from "./components/MachineDetailPage";
|
|
|
10
10
|
import { Anchor } from "../library-components/ATag";
|
|
11
11
|
import { DeployPage } from "./components/DeployPage";
|
|
12
12
|
import { StoragePage } from "./components/storage/StoragePage";
|
|
13
|
+
import { RoutingTablePage } from "../diagnostics/misc-pages/RoutingTablePage";
|
|
13
14
|
|
|
14
15
|
export class MachinesPage extends qreact.Component {
|
|
15
16
|
private renderTabs() {
|
|
@@ -22,6 +23,7 @@ export class MachinesPage extends qreact.Component {
|
|
|
22
23
|
{ key: "services", label: "Services", otherKeys: ["service-detail"] },
|
|
23
24
|
{ key: "deploy", label: "Deploy", otherKeys: [] },
|
|
24
25
|
{ key: "storage", label: "Storage", otherKeys: [] },
|
|
26
|
+
{ key: "routing", label: "PathValues / Functions Routing", otherKeys: [] },
|
|
25
27
|
].map(tab => {
|
|
26
28
|
let isActive = currentViewParam.value === tab.key || tab.otherKeys.includes(currentViewParam.value);
|
|
27
29
|
return <Anchor noStyles key={tab.key}
|
|
@@ -50,6 +52,7 @@ export class MachinesPage extends qreact.Component {
|
|
|
50
52
|
{currentViewParam.value === "machine-detail" && <MachineDetailPage />}
|
|
51
53
|
{currentViewParam.value === "deploy" && <DeployPage />}
|
|
52
54
|
{currentViewParam.value === "storage" && <StoragePage />}
|
|
55
|
+
{currentViewParam.value === "routing" && <RoutingTablePage />}
|
|
53
56
|
</div>
|
|
54
57
|
</div>;
|
|
55
58
|
}
|
|
@@ -45,7 +45,7 @@ const JSON_INDENT = 4;
|
|
|
45
45
|
/** String sources are the shorthand for an always-valid, unsharded backblaze bucket. */
|
|
46
46
|
function normalizeSource(source: RemoteConfigBase): Source {
|
|
47
47
|
if (typeof source !== "string") return source;
|
|
48
|
-
return { type: "backblaze", url: source, validWindow: FULL_VALID_WINDOW };
|
|
48
|
+
return { type: "backblaze", url: source, validWindow: FULL_VALID_WINDOW, name: "from url" };
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/** Where a source lives, with the bucket the config belongs to masked out so buckets that differ only by their own name group together. */
|
|
@@ -22,7 +22,6 @@ export function getSourceTags(source: HostedConfig | BackblazeConfig): SourceTag
|
|
|
22
22
|
if (source.intermediate) tags.push({ icon: "⏳", text: "intermediate" });
|
|
23
23
|
if (source.type === "remote") {
|
|
24
24
|
if (source.fast) tags.push({ icon: "⚡", text: "fast" });
|
|
25
|
-
if (source.rawDisk) tags.push({ icon: "💽", text: "rawDisk" });
|
|
26
25
|
if (source.writeDelay !== undefined) tags.push({ icon: "⏱", text: `writeDelay ${formatTime(source.writeDelay)}` });
|
|
27
26
|
}
|
|
28
27
|
if (source.readerDiskLimit !== undefined) tags.push({ icon: "📦", text: `readCache ${formatNumber(source.readerDiskLimit)}B` });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { URLParam } from "../library-components/URLParam";
|
|
2
2
|
|
|
3
|
-
export type ViewType = "services" | "machines" | "service-detail" | "machine-detail" | "deploy" | "storage";
|
|
3
|
+
export type ViewType = "services" | "machines" | "service-detail" | "machine-detail" | "deploy" | "storage" | "routing";
|
|
4
4
|
|
|
5
5
|
export const currentViewParam = new URLParam<ViewType>("machineview", "machines");
|
|
6
6
|
export const selectedServiceIdParam = new URLParam("serviceId", "");
|
|
@@ -80,12 +80,6 @@ export async function registerManagementPages2(config: {
|
|
|
80
80
|
let inputPages: typeof config.pages = [];
|
|
81
81
|
|
|
82
82
|
|
|
83
|
-
inputPages.push({
|
|
84
|
-
title: "Routing Table",
|
|
85
|
-
componentName: "RoutingTablePage",
|
|
86
|
-
controllerName: "RoutingTablePageController",
|
|
87
|
-
getModule: () => import("./misc-pages/RoutingTablePage"),
|
|
88
|
-
});
|
|
89
83
|
inputPages.push({
|
|
90
84
|
title: "LOG VIEWER",
|
|
91
85
|
componentName: "LogViewer3",
|
|
@@ -404,6 +398,10 @@ class ManagementRoot extends qreact.Component {
|
|
|
404
398
|
managementPageURL.getOverride("MachinesPage"),
|
|
405
399
|
currentViewParam.getOverride("storage"),
|
|
406
400
|
]}>Storage</ATag>
|
|
401
|
+
<ATag values={[
|
|
402
|
+
managementPageURL.getOverride("MachinesPage"),
|
|
403
|
+
currentViewParam.getOverride("routing"),
|
|
404
|
+
]}>PathValues / Functions Routing</ATag>
|
|
407
405
|
{pages.map(page =>
|
|
408
406
|
<ATag values={[{ param: managementPageURL, value: page.componentName }]}>{page.title}</ATag>
|
|
409
407
|
)}
|
|
@@ -45,19 +45,18 @@ class PathSummaryTable extends qreact.Component<{ nodeId: string; direction: Pat
|
|
|
45
45
|
|
|
46
46
|
// One row per path-value server, grouped by machine. Reads/writes lead the line; expanding reveals that server's
|
|
47
47
|
// per-path breakdown for both directions.
|
|
48
|
-
class AuthorityNodeRow extends qreact.Component<{ info: NodeAuthorityInfo
|
|
48
|
+
class AuthorityNodeRow extends qreact.Component<{ info: NodeAuthorityInfo }> {
|
|
49
49
|
state = {
|
|
50
50
|
expanded: false,
|
|
51
51
|
};
|
|
52
52
|
render() {
|
|
53
53
|
let info = this.props.info;
|
|
54
|
-
let spec = info.spec
|
|
54
|
+
let spec = info.spec;
|
|
55
55
|
let expanded = this.state.expanded;
|
|
56
56
|
let totals = RoutingTableSynced(getBrowserUrlNode()).getNodePathTotals(info.nodeId);
|
|
57
57
|
let machineId = machineIdOf(info.nodeId);
|
|
58
58
|
return <div
|
|
59
|
-
className={css.button.vbox(6).pad2(10).fillWidth.hsl(0, 0, 99)
|
|
60
|
-
.borderTop(this.props.firstOfMachine ? "2px solid hsl(0, 0%, 78%)" : "1px solid hsl(0, 0%, 90%)")}
|
|
59
|
+
className={css.button.vbox(6).pad2(10).fillWidth.hsl(0, 0, 99).borderTop("1px solid hsl(0, 0%, 88%)")}
|
|
61
60
|
onClick={() => this.state.expanded = !expanded}
|
|
62
61
|
>
|
|
63
62
|
<div className={css.hbox(10).fillWidth}>
|
|
@@ -66,12 +65,12 @@ class AuthorityNodeRow extends qreact.Component<{ info: NodeAuthorityInfo; first
|
|
|
66
65
|
↓{totals ? formatNumber(totals.received) : "…"} ↑{totals ? formatNumber(totals.sent) : "…"}
|
|
67
66
|
</span>
|
|
68
67
|
<span className={css.width(`${MACHINE_CELL_WIDTH_CH}ch`).flexShrink0.colorhsl(0, 0, 45)} title={machineId}>
|
|
69
|
-
{
|
|
68
|
+
{machineId.slice(0, ID_CHARS)}
|
|
70
69
|
</span>
|
|
71
70
|
<span className={css.boldStyle} title={info.nodeId}>{threadLabel(info.nodeId)}</span>
|
|
72
|
-
<span className={css.colorhsl(0, 0, 45)}>{spec.routeStart.toFixed(4)} - {spec.routeEnd.toFixed(4)}</span>
|
|
73
|
-
<AuthorityRangeBar start={spec.routeStart} end={spec.routeEnd} />
|
|
74
|
-
{spec
|
|
71
|
+
{spec && <span className={css.colorhsl(0, 0, 45)}>{spec.routeStart.toFixed(4)} - {spec.routeEnd.toFixed(4)}</span>}
|
|
72
|
+
{spec && <AuthorityRangeBar start={spec.routeStart} end={spec.routeEnd} />}
|
|
73
|
+
{spec?.excludeDefault && <span className={css.colorhsl(0, 70, 35)}>(excludes default)</span>}
|
|
75
74
|
</div>
|
|
76
75
|
{expanded &&
|
|
77
76
|
<div className={css.hbox(16).wrap.fillWidth.alignItems("flex-start")} onClick={e => e.stopPropagation()}>
|
|
@@ -89,42 +88,67 @@ class AuthorityNodeRow extends qreact.Component<{ info: NodeAuthorityInfo; first
|
|
|
89
88
|
}
|
|
90
89
|
}
|
|
91
90
|
|
|
92
|
-
// Invalidate: drop every cached synced call for this node so the whole page's data disappears and refetches.
|
|
93
|
-
// wipe the in-memory path-value stats (totals and trees) on every node, then invalidate so the now-empty stats reload.
|
|
91
|
+
// Invalidate: drop every cached synced call for this node so the whole page's data disappears and refetches.
|
|
94
92
|
function invalidateRoutingTable(): void {
|
|
95
|
-
RoutingTableSynced(getBrowserUrlNode())
|
|
93
|
+
let synced = RoutingTableSynced(getBrowserUrlNode());
|
|
94
|
+
synced.getNodePathTotals.resetAll();
|
|
95
|
+
synced.getNodePathSummary.resetAll();
|
|
96
96
|
}
|
|
97
|
+
// Clear: wipe the in-memory path-value stats (totals and trees) on every node, then drop only the stats caches so the
|
|
98
|
+
// now-empty stats reload — leaving the specs/latency/traffic caches untouched.
|
|
97
99
|
async function clearAllPathStats(nodeIds: string[]): Promise<void> {
|
|
98
100
|
let synced = RoutingTableSynced(getBrowserUrlNode());
|
|
99
101
|
await Promise.all(nodeIds.map(nodeId => synced.clearNodePathStats.promise(nodeId).catch(() => { })));
|
|
100
|
-
synced.resetAll();
|
|
102
|
+
synced.getNodePathTotals.resetAll();
|
|
103
|
+
synced.getNodePathSummary.resetAll();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A list of server rows, most-active first (by total path values sent + received).
|
|
107
|
+
class PathServerList extends qreact.Component<{ infos: NodeAuthorityInfo[]; emptyText: string }> {
|
|
108
|
+
render() {
|
|
109
|
+
let synced = RoutingTableSynced(getBrowserUrlNode());
|
|
110
|
+
let sumOf = (nodeId: string) => {
|
|
111
|
+
let totals = synced.getNodePathTotals(nodeId);
|
|
112
|
+
return (totals?.sent ?? 0) + (totals?.received ?? 0);
|
|
113
|
+
};
|
|
114
|
+
let infos = this.props.infos.slice();
|
|
115
|
+
// Two stable passes: machine/thread as a tiebreak, then sent+received descending as the primary order (so rows
|
|
116
|
+
// with equal — or still-loading, hence 0 — totals keep a stable machine/thread order instead of jittering).
|
|
117
|
+
let sep = String.fromCharCode(1);
|
|
118
|
+
sort(infos, x => `${machineIdOf(x.nodeId)}${sep}${threadLabel(x.nodeId)}`);
|
|
119
|
+
sort(infos, x => -sumOf(x.nodeId));
|
|
120
|
+
if (!infos.length) return <div className={css.colorhsl(0, 0, 50)}>{this.props.emptyText}</div>;
|
|
121
|
+
return <div className={css.vbox(0).fillWidth.bord2(0, 0, 88)}>
|
|
122
|
+
{infos.map(info => <AuthorityNodeRow key={info.nodeId} info={info} />)}
|
|
123
|
+
</div>;
|
|
124
|
+
}
|
|
101
125
|
}
|
|
102
126
|
|
|
103
127
|
export class PathValueServersSection extends qreact.Component<{ infos: NodeAuthorityInfo[] }> {
|
|
104
128
|
render() {
|
|
105
129
|
let infos = this.props.infos;
|
|
106
130
|
let allNodeIds = infos.map(x => x.nodeId);
|
|
107
|
-
|
|
108
|
-
//
|
|
109
|
-
let
|
|
110
|
-
|
|
131
|
+
// Path value servers are the authorities (they own a route range). Every other node can still send/receive
|
|
132
|
+
// path values, so it gets its own list below.
|
|
133
|
+
let serverInfos = infos.filter(x => x.spec && x.spec.routeStart >= 0 && x.spec.routeEnd >= 0);
|
|
134
|
+
let otherInfos = infos.filter(x => !(x.spec && x.spec.routeStart >= 0 && x.spec.routeEnd >= 0));
|
|
111
135
|
|
|
112
136
|
let buttonClass = css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 100).whiteSpace("nowrap");
|
|
113
137
|
return <div className={css.vbox(8).fillWidth}>
|
|
114
138
|
<div className={css.hbox(12).alignItems("center")}>
|
|
115
|
-
<h2 className={css.margin(0)}>Path Value Servers ({
|
|
139
|
+
<h2 className={css.margin(0)}>Path Value Servers ({serverInfos.length})</h2>
|
|
116
140
|
<button className={buttonClass} onClick={() => invalidateRoutingTable()}>Invalidate</button>
|
|
117
141
|
<button className={buttonClass} onClick={() => void clearAllPathStats(allNodeIds)}>Clear stats</button>
|
|
118
142
|
</div>
|
|
119
143
|
<div className={css.fontSize(13).colorhsl(0, 0, 45)}>
|
|
120
144
|
Path values each server has received (↓) and sent (↑) since startup or the last clear. Expand a server for its top {PATH_SUMMARY_LIMIT} paths in each direction.
|
|
121
145
|
</div>
|
|
122
|
-
<
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
})}
|
|
146
|
+
<PathServerList infos={serverInfos} emptyText="(no path value servers)" />
|
|
147
|
+
<h2 className={css.margin(0)}>Other Senders ({otherInfos.length})</h2>
|
|
148
|
+
<div className={css.fontSize(13).colorhsl(0, 0, 45)}>
|
|
149
|
+
Nodes that aren't path value servers but still send/receive path values.
|
|
127
150
|
</div>
|
|
151
|
+
<PathServerList infos={otherInfos} emptyText="(no other senders)" />
|
|
128
152
|
</div>;
|
|
129
153
|
}
|
|
130
154
|
}
|
|
@@ -107,9 +107,6 @@ export const RoutingTablePageController = SocketFunction.register(
|
|
|
107
107
|
() => ({
|
|
108
108
|
hooks: [assertIsManagementUser],
|
|
109
109
|
}),
|
|
110
|
-
{
|
|
111
|
-
noAutoExpose: true,
|
|
112
|
-
}
|
|
113
110
|
);
|
|
114
111
|
|
|
115
112
|
export const RoutingTableSynced = getSyncedController(RoutingTablePageController, {
|
|
@@ -124,9 +124,23 @@ export class SnapshotViewer extends qreact.Component {
|
|
|
124
124
|
<div>Byte Count: {formatNumber(entry.byteCount)}</div>
|
|
125
125
|
</div>
|
|
126
126
|
}
|
|
127
|
+
{snapshotInfo &&
|
|
128
|
+
<div class={css.vbox(4)}>
|
|
129
|
+
<div class={css.boldStyle}>By status</div>
|
|
130
|
+
<Table
|
|
131
|
+
rows={summarizeSnapshotByStatus(snapshotInfo.files)}
|
|
132
|
+
columns={{
|
|
133
|
+
status: { title: "Status" },
|
|
134
|
+
files: { title: "Files" },
|
|
135
|
+
values: { title: "Values" },
|
|
136
|
+
bytes: { title: "Bytes" },
|
|
137
|
+
}}
|
|
138
|
+
/>
|
|
139
|
+
</div>
|
|
140
|
+
}
|
|
127
141
|
<Button onClick={async () => {
|
|
128
142
|
let didConfirm = await seriousConfirm({
|
|
129
|
-
message: `Restoring will
|
|
143
|
+
message: `Restoring will put the servers in a bad state. You're gonna need to restart all services after this.`,
|
|
130
144
|
});
|
|
131
145
|
if (!didConfirm) {
|
|
132
146
|
console.log(red("Aborted restore"));
|
|
@@ -137,7 +151,6 @@ export class SnapshotViewer extends qreact.Component {
|
|
|
137
151
|
overview: entry
|
|
138
152
|
});
|
|
139
153
|
console.log(green("Restored snapshot"));
|
|
140
|
-
window.location.reload();
|
|
141
154
|
}}>
|
|
142
155
|
Restore
|
|
143
156
|
</Button>
|
|
@@ -169,6 +182,26 @@ export class SnapshotViewer extends qreact.Component {
|
|
|
169
182
|
}
|
|
170
183
|
}
|
|
171
184
|
|
|
185
|
+
// Canonical status order, so the per-status summary rows stay in a stable, readable order.
|
|
186
|
+
const SNAPSHOT_STATUS_ORDER = ["live", "zombie", "recycled", "missing"];
|
|
187
|
+
type SnapshotStatusRow = { status: string; files: number; values: number; bytes: number };
|
|
188
|
+
function summarizeSnapshotByStatus(files: ArchiveSnapshotRead["files"]): SnapshotStatusRow[] {
|
|
189
|
+
let byStatus = new Map<string, SnapshotStatusRow>();
|
|
190
|
+
for (let file of files) {
|
|
191
|
+
let row = byStatus.get(file.state);
|
|
192
|
+
if (!row) {
|
|
193
|
+
row = { status: file.state, files: 0, values: 0, bytes: 0 };
|
|
194
|
+
byStatus.set(file.state, row);
|
|
195
|
+
}
|
|
196
|
+
row.files++;
|
|
197
|
+
row.values += file.valueCount;
|
|
198
|
+
row.bytes += file.byteCount;
|
|
199
|
+
}
|
|
200
|
+
let rows = Array.from(byStatus.values());
|
|
201
|
+
sort(rows, row => SNAPSHOT_STATUS_ORDER.indexOf(row.status));
|
|
202
|
+
return rows;
|
|
203
|
+
}
|
|
204
|
+
|
|
172
205
|
async function downloadSnapshotFile(component: SnapshotViewer, file: string) {
|
|
173
206
|
if (component.state.downloading[file]) return;
|
|
174
207
|
Querysub.localCommit(() => {
|
package/src/misc/appPaths.ts
CHANGED
|
@@ -13,6 +13,7 @@ export const STORAGE_DIR = "./database-storage/";
|
|
|
13
13
|
|
|
14
14
|
let yargObj = parseArgsFactory()
|
|
15
15
|
.option("domain", { type: "string", desc: `Sets the domain` })
|
|
16
|
+
.option("port", { type: "number", desc: `Sets the port` })
|
|
16
17
|
.argv
|
|
17
18
|
;
|
|
18
19
|
|
|
@@ -42,6 +43,10 @@ export function getDomain() {
|
|
|
42
43
|
return yargObj.domain || querysubConfig().domain || "querysub.com";
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
export function getPort(): number | undefined {
|
|
47
|
+
return yargObj.port;
|
|
48
|
+
}
|
|
49
|
+
|
|
45
50
|
export function getBackblazePath() {
|
|
46
51
|
let testPaths = [
|
|
47
52
|
STORAGE_DIR + "backblaze.json",
|
package/src/storageSetup.ts
CHANGED
|
@@ -1,23 +1,15 @@
|
|
|
1
1
|
process.env.PRODUCTION = "true";
|
|
2
|
-
|
|
3
|
-
import
|
|
4
|
-
import { getTrustedMachineIds } from "./-d-trust/NetworkTrust2";
|
|
5
|
-
import { getExternalIP } from "socket-function/src/networking";
|
|
2
|
+
// TODO: Add this back
|
|
3
|
+
//import "./inject";
|
|
6
4
|
|
|
7
5
|
// The storage server entry point (see bin/storageserve.js): sets up the CLI args the sliftutils storage server expects, synchronizes our network trust list into its trust store, and runs its CLI.
|
|
8
6
|
|
|
9
7
|
async function main() {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
let trustedMachineIds = await getTrustedMachineIds();
|
|
17
|
-
|
|
18
|
-
let { setTrustedMachines } = await import("sliftutils/storage/remoteStorage/serverConfig");
|
|
19
|
-
await setTrustedMachines({ account: "root", machineIds: trustedMachineIds });
|
|
20
|
-
console.log(`Synchronized ${trustedMachineIds.length} trusted machines`);
|
|
8
|
+
// TODO: Add this back
|
|
9
|
+
// let trustedMachineIds = await getTrustedMachineIds();
|
|
10
|
+
// let { setTrustedMachines } = await import("sliftutils/storage/remoteStorage/serverConfig");
|
|
11
|
+
// await setTrustedMachines({ account: "root", machineIds: trustedMachineIds });
|
|
12
|
+
// console.log(`Synchronized ${trustedMachineIds.length} trusted machines`);
|
|
21
13
|
|
|
22
14
|
await import("sliftutils/storage/remoteStorage/storageServerCli");
|
|
23
15
|
}
|