Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions src/handlers/container.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as storage from '../storage/filesystem.js';
import { initializeQuota, checkQuota, updateQuotaUsage } from '../storage/quota.js';
import { initializeQuota, reserveQuota, updateQuotaUsage } from '../storage/quota.js';
import { getAllHeaders } from '../ldp/headers.js';
import { isContainer, getEffectiveUrlPath, getPodName } from '../utils/url.js';
import { generateProfile, generatePreferences, generateTypeIndex, serialize } from '../webid/profile.js';
Expand Down Expand Up @@ -159,18 +159,20 @@ export async function handlePost(request, reply) {

// Check storage quota before writing (skip in public mode - no pod structure)
const podName = request.config?.public ? null : getPodName(request);
// Atomically reserve before writing so concurrent creates can't both pass
// the check and overshoot the limit; release the reservation on failure.
if (podName) {
const { allowed, error } = await checkQuota(podName, content.length, request.defaultQuota || 0);
const { allowed, error } = await reserveQuota(podName, content.length, request.defaultQuota || 0);
if (!allowed) {
return reply.code(507).send({ error: 'Insufficient Storage', message: error });
}
}

success = await storage.write(newStoragePath, content);

// Update quota usage after successful write
if (success && podName) {
await updateQuotaUsage(podName, content.length);
// reserveQuota already recorded the usage; release it if the write failed.
if (!success && podName) {
await updateQuotaUsage(podName, -content.length);
}
}

Expand Down
22 changes: 19 additions & 3 deletions src/handlers/pay.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

import crypto from 'crypto';
import { getNostrPubkey, pubkeyToDidNostr } from '../auth/nostr.js';
import { readLedger, writeLedger, getBalance, credit, debit } from '../webledger.js';
import { readLedger, writeLedger, getBalance, credit, creditOnce, debit } from '../webledger.js';
import { verifyMrc20Deposit, verifyMrc20Anchor, jcs, btAddress } from '../mrc20.js';
import { loadTrail, transferToken, buildTransaction, broadcastTx, p2trScript, btDeriveChainedPrivkey } from '../token.js';
import { secp256k1 } from '@noble/curves/secp256k1';
Expand Down Expand Up @@ -319,6 +319,7 @@ export function createPayHandler(options = {}) {
const utxos = await loadUtxos();
const ledger = await readLedger();
let credited = 0;
let appended = false;

for (const chainId of payChains) {
const chain = CHAIN_REGISTRY[chainId];
Expand All @@ -341,14 +342,29 @@ export function createPayHandler(options = {}) {
}
} catch { /* best effort */ }
const currency = chain.unit;
credit(ledger, didUri, u.value, currency);
// Idempotent credit keyed on the outpoint: the balance and the
// "already counted" marker commit together in the ledger, so a
// crash before saveUtxos below can't double-credit on the next
// balance poll (the scanner re-runs on every GET /pay/.balance).
const depositKey = `${chainId}:${u.txid}:${u.vout}`;
const { credited: didCredit } = creditOnce(ledger, depositKey, didUri, u.value, currency);
utxos.push({ txid: u.txid, vout: u.vout, amount: u.value, scriptpubkey, chain: chainId, tweak: didUri, spent: false });
credited += u.value;
appended = true;
if (didCredit) credited += u.value;
Comment on lines 351 to +353
}
}

// Ledger first: a crash before saveUtxos leaves the credit recorded
// together with its idempotency key, so the rescan is a no-op rather
// than a double-credit. The reverse order would under-credit.
if (credited > 0) {
await writeLedger(ledger);
}
// Keyed on `appended`, not `credited`: a replayed outpoint credits
// nothing (didCredit === false) but must still land in the cache.
// Otherwise the crash window never heals — the scanner re-queries the
// explorer for that outpoint on every single balance poll.
if (appended) {
await saveUtxos(utxos);
}
} catch { /* scan failure is non-fatal */ }
Expand Down
14 changes: 10 additions & 4 deletions src/handlers/resource.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as storage from '../storage/filesystem.js';
import { checkQuota, updateQuotaUsage } from '../storage/quota.js';
import { reserveQuota, updateQuotaUsage } from '../storage/quota.js';
import { getAllHeaders, getNotFoundHeaders } from '../ldp/headers.js';
import { generateContainerJsonLd, serializeJsonLd } from '../ldp/container.js';
import { isContainer, getContentType, isRdfContentType, getEffectiveUrlPath, safeJsonParse, getPodName } from '../utils/url.js';
Expand Down Expand Up @@ -1045,20 +1045,26 @@ export async function handlePut(request, reply) {
const oldSize = stats?.size || 0;
const sizeDelta = content.length - oldSize;

// Atomically reserve the growth before writing so concurrent writers can't
// both pass the check and overshoot the limit. reserveQuota commits the
// reservation, so we must release it if the write then fails.
if (podName && sizeDelta > 0) {
const { allowed, error } = await checkQuota(podName, sizeDelta, request.defaultQuota || 0);
const { allowed, error } = await reserveQuota(podName, sizeDelta, request.defaultQuota || 0);
if (!allowed) {
return reply.code(507).send({ error: 'Insufficient Storage', message: error });
}
}

const success = await storage.write(storagePath, content);
if (!success) {
if (podName && sizeDelta > 0) {
await updateQuotaUsage(podName, -sizeDelta); // release the reservation
}
return reply.code(500).send({ error: 'Write failed' });
}

// Update quota usage after successful write
if (podName && sizeDelta !== 0) {
// Growth was already recorded by reserveQuota; only a shrink needs recording.
if (podName && sizeDelta < 0) {
await updateQuotaUsage(podName, sizeDelta);
}

Expand Down
99 changes: 92 additions & 7 deletions src/storage/quota.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,35 @@ const QUOTA_FILE = '.quota.json';
// between writeFile and rename) doesn't inflate pod usage.
const QUOTA_TMP_PREFIX = `${QUOTA_FILE}.tmp.`;

/**
* Per-pod serialization of read-modify-write on .quota.json.
*
* saveQuota is already atomic (temp file + rename), which stops a concurrent
* reader from seeing a truncated file (#309). But atomicity of a single write
* does not stop a lost update: two writers that both `loadQuota` the same
* `used` value, each add their bytes and `saveQuota`, race to clobber one
* another — and, worse, both can pass a `checkQuota` before either records its
* usage, letting concurrent writes overshoot the limit. Serializing the whole
* load→mutate→save critical section per pod closes both windows. This mirrors
* QuotaPolicy::reserve in the solid-pod-rs parity port, which holds a per-pod
* lock for atomic check-and-commit.
*/
const podLocks = new Map();

function withPodLock(podName, fn) {
const prev = podLocks.get(podName) || Promise.resolve();
// Chain onto the previous holder regardless of how it settled.
const run = prev.then(fn, fn);
// Store a tail that never rejects so the chain can't wedge on an error.
const tail = run.then(() => {}, () => {});
podLocks.set(podName, tail);
// Opportunistic cleanup so the map doesn't grow unbounded across many pods.
tail.then(() => {
if (podLocks.get(podName) === tail) podLocks.delete(podName);
});
return run;
}

/**
* Get quota file path for a pod
*/
Expand Down Expand Up @@ -168,19 +197,75 @@ export async function checkQuota(podName, additionalBytes, defaultQuota) {
}

/**
* Update quota usage after a write
* Atomically reserve quota for a pending write: check-and-commit under a
* per-pod lock so concurrent writers cannot both pass the check and then
* overshoot the limit. On success the reserved bytes are already recorded in
* `used`, so callers must NOT also call updateQuotaUsage for the same bytes —
* instead, release the reservation with `updateQuotaUsage(pod, -bytes)` if the
* write subsequently fails.
*
* This is the authoritative enforcement point and the only one the write path
* uses. checkQuota remains exported as a lock-free read-only probe for callers
* that want to inspect headroom without reserving it, but it performs no
* pre-check for PUT/POST any more — do not go looking for one. Mirrors
* QuotaPolicy::reserve in the solid-pod-rs parity port.
*
* @param {string} podName - The pod name
* @param {number} additionalBytes - Bytes to reserve (expected >= 0)
* @param {number} defaultQuota - Default quota limit
* @returns {Promise<{allowed: boolean, quota: object, error?: string}>}
*/
export async function reserveQuota(podName, additionalBytes, defaultQuota) {
return withPodLock(podName, async () => {
let quota = await loadQuota(podName);

// Initialize limit from the default on first use, preserving any usage
// reconciled from a recovered corrupt/empty file (see checkQuota).
if (quota.limit === 0 && defaultQuota > 0) {
quota = { limit: defaultQuota, used: quota.used };
}

// No enforcement (and no tracking) when no limit is in effect — matches
// updateQuotaUsage, which skips uninitialized quotas.
if (quota.limit === 0) {
return { allowed: true, quota };
}

const projectedUsage = quota.used + additionalBytes;

if (additionalBytes > 0 && projectedUsage > quota.limit) {
const usedMB = (quota.used / (1024 * 1024)).toFixed(2);
const limitMB = (quota.limit / (1024 * 1024)).toFixed(2);
return {
allowed: false,
quota,
error: `Storage quota exceeded. Used: ${usedMB}MB / ${limitMB}MB`
};
}

// Commit the reservation as part of the same locked critical section.
quota.used = Math.max(0, projectedUsage);
await saveQuota(podName, quota);
return { allowed: true, quota };
});
}

/**
* Update quota usage after a write (or to release a reservation).
* @param {string} podName - The pod name
* @param {number} bytesChange - Bytes added (positive) or removed (negative)
*/
export async function updateQuotaUsage(podName, bytesChange) {
const quota = await loadQuota(podName);
return withPodLock(podName, async () => {
const quota = await loadQuota(podName);

// Skip if no quota initialized
if (quota.limit === 0) return quota;
// Skip if no quota initialized
if (quota.limit === 0) return quota;

quota.used = Math.max(0, quota.used + bytesChange);
await saveQuota(podName, quota);
return quota;
quota.used = Math.max(0, quota.used + bytesChange);
await saveQuota(podName, quota);
return quota;
});
}

/**
Expand Down
39 changes: 38 additions & 1 deletion src/webledger.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ export function createLedger(options = {}) {
defaultCurrency: options.defaultCurrency ?? 'satoshi',
created: now,
updated: now,
entries: []
entries: [],
// Idempotency keys for deposits already absorbed into balances. Seeded
// here so a freshly created ledger has the same shape readLedger's
// migration produces for a legacy one.
credited: []
};
}

Expand All @@ -56,6 +60,11 @@ export async function readLedger(ledgerPath = DEFAULT_PATH) {
if (!ledger.defaultCurrency) ledger.defaultCurrency = 'satoshi';
if (!ledger.created) ledger.created = Math.floor(Date.now() / 1000);
if (!ledger.entries) ledger.entries = [];
// Idempotency keys for deposits already absorbed into balances. Kept in
// the ledger itself so crediting is crash-safe: the balance and the "this
// deposit was counted" marker commit together in one writeLedger, instead
// of the balance landing in one file and the spent-marker in another.
if (!ledger.credited) ledger.credited = [];
return ledger;
} catch {
return createLedger();
Expand Down Expand Up @@ -164,6 +173,34 @@ export function debit(ledger, uri, amount, currency) {
return { success: true, balance: newBalance };
}

/**
* Idempotently credit a keyed deposit exactly once.
*
* The dedup key (e.g. `${chain}:${txid}:${vout}` for an on-chain UTXO) is
* recorded in the ledger's own `credited` list, so the balance increment and
* the "already counted" marker are committed together by a single writeLedger.
* A crash after that single write can never re-credit the same deposit — which
* is exactly what happened when the credit and the UTXO-spent record lived in
* two separate files written one after another.
*
* @param {object} ledger - WebLedger object (must be persisted afterwards)
* @param {string} key - Stable idempotency key for this deposit
* @param {string} uri - Agent URI
* @param {number} amount - Amount to add
* @param {string} [currency] - Currency code
* @returns {{credited: boolean, balance: number}} `credited` is false when the
* key was already absorbed (no change made)
*/
export function creditOnce(ledger, key, uri, amount, currency) {
if (!ledger.credited) ledger.credited = [];
if (ledger.credited.includes(key)) {
return { credited: false, balance: getBalance(ledger, uri, currency) };
}
const balance = credit(ledger, uri, amount, currency);
ledger.credited.push(key);
return { credited: true, balance };
}

/**
* List all entries with non-zero balances
* @param {object} ledger - WebLedger object
Expand Down
85 changes: 85 additions & 0 deletions test/quota-reserve.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Regression tests for the quota check-and-commit race.
*
* saveQuota is atomic (temp + rename, #309), but check→write→record used to be
* three separate async steps: two concurrent writers could both pass the check
* and then overshoot the limit, and the read-modify-write in updateQuotaUsage
* could lose updates. reserveQuota + a per-pod lock make check-and-commit
* atomic — mirroring QuotaPolicy::reserve in the solid-pod-rs parity port.
*/

import { describe, it, before, after, beforeEach } from 'node:test';
import assert from 'node:assert';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import {
initializeQuota,
updateQuotaUsage,
reserveQuota,
loadQuota
} from '../src/storage/quota.js';

const POD = 'testpod';
let TEST_ROOT;
let originalDataRoot;

describe('quota — atomic reservation (check-and-commit race)', () => {
before(async () => {
originalDataRoot = process.env.DATA_ROOT;
TEST_ROOT = await fs.mkdtemp(path.join(os.tmpdir(), 'jss-quota-reserve-'));
process.env.DATA_ROOT = TEST_ROOT;
await fs.ensureDir(path.join(TEST_ROOT, POD));
});

after(async () => {
if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
else process.env.DATA_ROOT = originalDataRoot;
await fs.remove(TEST_ROOT);
});

beforeEach(async () => {
await initializeQuota(POD, 1000);
});

it('concurrent reservations never overshoot the limit', async () => {
// 40 writers each try to reserve 100 bytes against a 1000-byte limit.
// Only 10 can be admitted; a non-atomic check would let many more through.
const N = 40;
const results = await Promise.all(
Array.from({ length: N }, () => reserveQuota(POD, 100, 0))
);

const admitted = results.filter((r) => r.allowed).length;
assert.strictEqual(admitted, 10, 'exactly limit/size reservations may be admitted');

const final = await loadQuota(POD);
assert.ok(final.used <= final.limit, `used ${final.used} must not exceed limit ${final.limit}`);
assert.strictEqual(final.used, 1000, 'committed usage must equal the admitted reservations');
});

it('concurrent updateQuotaUsage does not lose updates', async () => {
await initializeQuota(POD, 10 * 1024 * 1024);
const N = 50;
const each = 100;
await Promise.all(Array.from({ length: N }, () => updateQuotaUsage(POD, each)));

const final = await loadQuota(POD);
assert.strictEqual(final.used, N * each, 'every increment must be recorded (no lost update)');
});

it('a released reservation frees the space for a later writer', async () => {
// Fill the quota, release one slot, then a new reservation must succeed.
const filled = await Promise.all(
Array.from({ length: 10 }, () => reserveQuota(POD, 100, 0))
);
assert.strictEqual(filled.filter((r) => r.allowed).length, 10);

const overflow = await reserveQuota(POD, 100, 0);
assert.strictEqual(overflow.allowed, false, 'quota is full');

await updateQuotaUsage(POD, -100); // simulate a failed write releasing its reservation
const retry = await reserveQuota(POD, 100, 0);
assert.strictEqual(retry.allowed, true, 'freed space is reusable');
});
});
Loading