javascript-solid-server 0.0.150 → 0.0.151
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/package.json +1 -1
- package/src/storage/quota.js +44 -8
- package/test/quota-race.test.js +143 -0
package/package.json
CHANGED
package/src/storage/quota.js
CHANGED
|
@@ -8,6 +8,10 @@ import { join } from 'path';
|
|
|
8
8
|
import { getDataRoot } from '../utils/url.js';
|
|
9
9
|
|
|
10
10
|
const QUOTA_FILE = '.quota.json';
|
|
11
|
+
// Temp files created by saveQuota live next to the quota file; they must be
|
|
12
|
+
// excluded from disk-usage reconciliation so an orphan (e.g. from a crash
|
|
13
|
+
// between writeFile and rename) doesn't inflate pod usage.
|
|
14
|
+
const QUOTA_TMP_PREFIX = `${QUOTA_FILE}.tmp.`;
|
|
11
15
|
|
|
12
16
|
/**
|
|
13
17
|
* Get quota file path for a pod
|
|
@@ -16,6 +20,18 @@ function getQuotaPath(podName) {
|
|
|
16
20
|
return join(getDataRoot(), podName, QUOTA_FILE);
|
|
17
21
|
}
|
|
18
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Coerce a parsed quota object to a sane shape so all callers can do
|
|
25
|
+
* arithmetic on it without worrying about undefined/NaN/negative values.
|
|
26
|
+
*/
|
|
27
|
+
function sanitizeQuota(q) {
|
|
28
|
+
const toNum = (v) => {
|
|
29
|
+
const n = Number(v);
|
|
30
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
31
|
+
};
|
|
32
|
+
return { limit: toNum(q?.limit), used: toNum(q?.used) };
|
|
33
|
+
}
|
|
34
|
+
|
|
19
35
|
/**
|
|
20
36
|
* Load quota data for a pod
|
|
21
37
|
* @param {string} podName - The pod name
|
|
@@ -24,11 +40,15 @@ function getQuotaPath(podName) {
|
|
|
24
40
|
export async function loadQuota(podName) {
|
|
25
41
|
try {
|
|
26
42
|
const data = await fs.readFile(getQuotaPath(podName), 'utf-8');
|
|
27
|
-
|
|
43
|
+
// Empty/partial file can appear if a write was interrupted (or from a
|
|
44
|
+
// racing non-atomic save on older versions). Treat as missing and
|
|
45
|
+
// reconcile against on-disk usage so we don't under-count.
|
|
46
|
+
if (!data.trim()) return { limit: 0, used: await calculatePodSize(podName) };
|
|
47
|
+
return sanitizeQuota(JSON.parse(data));
|
|
28
48
|
} catch (err) {
|
|
29
|
-
if (err.code === 'ENOENT') {
|
|
30
|
-
|
|
31
|
-
return { limit: 0, used:
|
|
49
|
+
if (err.code === 'ENOENT') return { limit: 0, used: 0 };
|
|
50
|
+
if (err instanceof SyntaxError) {
|
|
51
|
+
return { limit: 0, used: await calculatePodSize(podName) };
|
|
32
52
|
}
|
|
33
53
|
throw err;
|
|
34
54
|
}
|
|
@@ -40,7 +60,19 @@ export async function loadQuota(podName) {
|
|
|
40
60
|
* @param {object} quota - Quota data
|
|
41
61
|
*/
|
|
42
62
|
export async function saveQuota(podName, quota) {
|
|
43
|
-
|
|
63
|
+
// Atomic write: fs.writeFile truncates before writing, which lets concurrent
|
|
64
|
+
// readers see an empty file. Write to a temp file and rename (POSIX-atomic).
|
|
65
|
+
// Any failure (writeFile or rename) cleans up the temp file so failed writes
|
|
66
|
+
// don't accumulate orphans.
|
|
67
|
+
const finalPath = getQuotaPath(podName);
|
|
68
|
+
const tmpPath = `${finalPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
|
|
69
|
+
try {
|
|
70
|
+
await fs.writeFile(tmpPath, JSON.stringify(quota, null, 2));
|
|
71
|
+
await fs.rename(tmpPath, finalPath);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
await fs.unlink(tmpPath).catch(() => {});
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
44
76
|
}
|
|
45
77
|
|
|
46
78
|
/**
|
|
@@ -76,8 +108,9 @@ async function calculateDirSize(dirPath) {
|
|
|
76
108
|
for (const entry of entries) {
|
|
77
109
|
const fullPath = join(dirPath, entry.name);
|
|
78
110
|
|
|
79
|
-
// Skip quota file
|
|
111
|
+
// Skip the quota file and any orphaned temp files from saveQuota.
|
|
80
112
|
if (entry.name === QUOTA_FILE) continue;
|
|
113
|
+
if (entry.name.startsWith(QUOTA_TMP_PREFIX)) continue;
|
|
81
114
|
|
|
82
115
|
if (entry.isDirectory()) {
|
|
83
116
|
total += await calculateDirSize(fullPath);
|
|
@@ -106,9 +139,12 @@ async function calculateDirSize(dirPath) {
|
|
|
106
139
|
export async function checkQuota(podName, additionalBytes, defaultQuota) {
|
|
107
140
|
let quota = await loadQuota(podName);
|
|
108
141
|
|
|
109
|
-
// Initialize if no quota set
|
|
142
|
+
// Initialize if no quota set. loadQuota already sanitizes shape, so `used`
|
|
143
|
+
// is a finite non-negative number here — preserve it so reconciled usage
|
|
144
|
+
// from a recovered corrupt/empty file is not reset to 0.
|
|
110
145
|
if (quota.limit === 0 && defaultQuota > 0) {
|
|
111
|
-
quota =
|
|
146
|
+
quota = { limit: defaultQuota, used: quota.used };
|
|
147
|
+
await saveQuota(podName, quota);
|
|
112
148
|
}
|
|
113
149
|
|
|
114
150
|
// No quota enforcement if limit is 0
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for #309 — concurrent quota updates causing 500s.
|
|
3
|
+
*
|
|
4
|
+
* Without atomic writes, two concurrent updateQuotaUsage calls race on
|
|
5
|
+
* .quota.json — the second load can read an empty/partial file and
|
|
6
|
+
* JSON.parse throws "Unexpected end of JSON input".
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, before, after } from 'node:test';
|
|
10
|
+
import assert from 'node:assert';
|
|
11
|
+
import fs from 'fs-extra';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import {
|
|
15
|
+
initializeQuota,
|
|
16
|
+
updateQuotaUsage,
|
|
17
|
+
loadQuota,
|
|
18
|
+
saveQuota,
|
|
19
|
+
checkQuota,
|
|
20
|
+
calculatePodSize
|
|
21
|
+
} from '../src/storage/quota.js';
|
|
22
|
+
|
|
23
|
+
const POD = 'testpod';
|
|
24
|
+
let TEST_ROOT;
|
|
25
|
+
let originalDataRoot;
|
|
26
|
+
|
|
27
|
+
describe('quota — concurrent updates (#309)', () => {
|
|
28
|
+
before(async () => {
|
|
29
|
+
originalDataRoot = process.env.DATA_ROOT;
|
|
30
|
+
TEST_ROOT = await fs.mkdtemp(path.join(os.tmpdir(), 'jss-quota-'));
|
|
31
|
+
process.env.DATA_ROOT = TEST_ROOT;
|
|
32
|
+
await fs.ensureDir(path.join(TEST_ROOT, POD));
|
|
33
|
+
await initializeQuota(POD, 50 * 1024 * 1024);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
after(async () => {
|
|
37
|
+
if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
|
|
38
|
+
else process.env.DATA_ROOT = originalDataRoot;
|
|
39
|
+
await fs.remove(TEST_ROOT);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('many concurrent updates do not throw', async () => {
|
|
43
|
+
const N = 50;
|
|
44
|
+
const updates = Array.from({ length: N }, (_, i) =>
|
|
45
|
+
updateQuotaUsage(POD, 10 + i)
|
|
46
|
+
);
|
|
47
|
+
await assert.doesNotReject(Promise.all(updates));
|
|
48
|
+
const final = await loadQuota(POD);
|
|
49
|
+
assert.strictEqual(typeof final.used, 'number');
|
|
50
|
+
assert.ok(final.used > 0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
async function withResourceAndBrokenQuota(brokenContent) {
|
|
54
|
+
const resourcePath = path.join(TEST_ROOT, POD, 'resource.txt');
|
|
55
|
+
const quotaPath = path.join(TEST_ROOT, POD, '.quota.json');
|
|
56
|
+
const size = 321;
|
|
57
|
+
await fs.writeFile(resourcePath, 'x'.repeat(size));
|
|
58
|
+
await fs.writeFile(quotaPath, brokenContent);
|
|
59
|
+
return { size, cleanup: () => fs.remove(resourcePath) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
it('loadQuota reconciles usage from disk when quota file is empty', async () => {
|
|
63
|
+
const { size, cleanup } = await withResourceAndBrokenQuota('');
|
|
64
|
+
try {
|
|
65
|
+
const q = await loadQuota(POD);
|
|
66
|
+
assert.strictEqual(q.limit, 0);
|
|
67
|
+
assert.strictEqual(q.used, size);
|
|
68
|
+
} finally { await cleanup(); }
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('loadQuota reconciles usage from disk when quota file is corrupt', async () => {
|
|
72
|
+
const { size, cleanup } = await withResourceAndBrokenQuota('{"limit":524');
|
|
73
|
+
try {
|
|
74
|
+
const q = await loadQuota(POD);
|
|
75
|
+
assert.strictEqual(q.limit, 0);
|
|
76
|
+
assert.strictEqual(q.used, size);
|
|
77
|
+
} finally { await cleanup(); }
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('loadQuota sanitizes malformed fields (missing/null/negative)', async () => {
|
|
81
|
+
const quotaPath = path.join(TEST_ROOT, POD, '.quota.json');
|
|
82
|
+
const cases = [
|
|
83
|
+
['{"limit":0}', { limit: 0, used: 0 }],
|
|
84
|
+
['{"limit":null,"used":null}', { limit: 0, used: 0 }],
|
|
85
|
+
['{"limit":-10,"used":-5}', { limit: 0, used: 0 }],
|
|
86
|
+
// Numeric strings are coerced — tolerates legacy/manually-edited files.
|
|
87
|
+
['{"limit":"100","used":"50"}', { limit: 100, used: 50 }]
|
|
88
|
+
];
|
|
89
|
+
for (const [body, expected] of cases) {
|
|
90
|
+
await fs.writeFile(quotaPath, body);
|
|
91
|
+
const q = await loadQuota(POD);
|
|
92
|
+
assert.deepStrictEqual(q, expected, `for ${body}`);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('calculatePodSize ignores orphaned quota temp files', async () => {
|
|
97
|
+
// Simulate an orphan from a crashed saveQuota (process died between
|
|
98
|
+
// writeFile and rename) — must not be counted toward pod usage.
|
|
99
|
+
const resource = path.join(TEST_ROOT, POD, 'data.txt');
|
|
100
|
+
const orphan = path.join(TEST_ROOT, POD, '.quota.json.tmp.999.1.abc');
|
|
101
|
+
await fs.writeFile(resource, 'x'.repeat(50));
|
|
102
|
+
await fs.writeFile(orphan, 'x'.repeat(9999));
|
|
103
|
+
try {
|
|
104
|
+
const size = await calculatePodSize(POD);
|
|
105
|
+
assert.strictEqual(size, 50, 'orphan temp file must not be counted');
|
|
106
|
+
} finally {
|
|
107
|
+
await fs.remove(resource);
|
|
108
|
+
await fs.remove(orphan);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('checkQuota preserves reconciled usage when re-initializing limit', async () => {
|
|
113
|
+
const { size, cleanup } = await withResourceAndBrokenQuota('');
|
|
114
|
+
try {
|
|
115
|
+
const defaultQuota = 10 * 1024 * 1024;
|
|
116
|
+
const { quota } = await checkQuota(POD, 0, defaultQuota);
|
|
117
|
+
assert.strictEqual(quota.limit, defaultQuota);
|
|
118
|
+
assert.strictEqual(quota.used, size, 'reconciled usage must not be reset to 0');
|
|
119
|
+
} finally { await cleanup(); }
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('saveQuota is atomic — concurrent read during save never sees empty file', async () => {
|
|
123
|
+
await saveQuota(POD, { limit: 1000, used: 100 });
|
|
124
|
+
const quotaPath = path.join(TEST_ROOT, POD, '.quota.json');
|
|
125
|
+
|
|
126
|
+
// Interleave 200 saves with 200 reads; with non-atomic writes, at least
|
|
127
|
+
// one read would land on a truncated file — the empty-file assertion
|
|
128
|
+
// below (or the JSON.parse) would then fail.
|
|
129
|
+
const ops = [];
|
|
130
|
+
for (let i = 0; i < 200; i++) {
|
|
131
|
+
ops.push(saveQuota(POD, { limit: 1000, used: i }));
|
|
132
|
+
ops.push(fs.readFile(quotaPath, 'utf-8').then((data) => {
|
|
133
|
+
assert.notStrictEqual(
|
|
134
|
+
data.length,
|
|
135
|
+
0,
|
|
136
|
+
'concurrent read saw an empty quota file'
|
|
137
|
+
);
|
|
138
|
+
JSON.parse(data);
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
await assert.doesNotReject(Promise.all(ops));
|
|
142
|
+
});
|
|
143
|
+
});
|