Skip to content

fix(storage): derive storage stats from quota-status.json when available - #1990

Open
kriszyp wants to merge 6 commits into
mainfrom
fix/storage-stats-quota-aware
Open

fix(storage): derive storage stats from quota-status.json when available#1990
kriszyp wants to merge 6 commits into
mainfrom
fix/storage-stats-quota-aware

Conversation

@kriszyp

@kriszyp kriszyp commented Jul 29, 2026

Copy link
Copy Markdown
Member

What

Table.getStorageStats() (feeding the storage-volume analytics metric) and
blob.ts's storage-path weighting both derived available/free/size from raw
statfs. statfs describes the filesystem, which is wrong whenever the
data directory sits under a user/group/project quota, or is a subtree of a
much larger shared volume: analytics can report hundreds of GB "available"
while the instance has zero remaining quota and is failing every write.

Why

Fixes #1976. On an observed instance, Harper's own storage metrics never
indicated pressure before or during a quota-exhaustion outage — statfs
kept reporting the filesystem's real free space, oblivious to the quota.

How

Both call sites now go through a new getStorageSpaceStats(path) helper in
server/storageReclamation.ts, which prefers a fresh quota-status.json
(written by host-manager every ~90s) over statfs — the same
freshness-checked pattern storageReclamation.ts already used internally
for its own reclamation-trigger ratio, and that getDiskInfo() already
surfaces as metadata for system_information. The existing ratio
calculation is refactored to use the same helper, so there is a single
source of truth for the quota-vs-statfs choice.

  • resources/Table.tsgetStorageStats() becomes a thin async delegate
    to getStorageSpaceStats().
  • resources/analytics/write.tsstoreVolumeMetrics awaits it.
  • resources/blob.tscreateFrequencyTableForStoragePaths (which weights
    which storage path new blobs land on) uses it instead of raw statfs;
    directory creation for a not-yet-existing path is now explicit (it used to
    piggyback on a statfs ENOENT, which the quota branch never triggers).
  • The returned stats carry a basis: 'quota' | 'filesystem' tag, so the
    analytics record itself is now self-describing about which source
    produced it.

Test plan

  • npm run typecheck / npm run build
  • unitTests/server/storageReclamation.test.js — added coverage for
    getStorageSpaceStats (fresh quota, over-quota clamp, absent/stale
    quota → statfs fallback), all existing tests unaffected.
  • unitTests/resources/blob.test.js (63 tests) and
    unitTests/resources/analytics/write.test.js (36 tests) pass
    unchanged.
  • npm run format:write / npm run lint:required clean.
  • Not run: full integration suite — this is a behavior-preserving refactor
    for the statfs branch (same formula) plus a new quota branch already unit
    tested at the source; see Risks below for what a reviewer may want to spot
    check.

Risks / open questions

  • blob.ts's createFrequencyTableForStoragePaths (the storage-path
    weighting logic) has no dedicated unit test. It's internal/unexported and
    blob.ts has a module-scope _assignPackageExport('createBlob', ...)
    side effect that makes it unsafe to rewire() in isolation (pollutes
    global.createBlob for the rest of the test run — confirmed by a spurious
    failure in an unrelated caching.test.js suite while iterating on this).
    Coverage here relies on getStorageSpaceStats itself being fully tested
    plus the existing full blob.test.js suite staying green.
  • Independent (outside-model) pre-push review did not complete: codex/
    claude review runs were still actively working (large log output, no
    errors) when killed by the available time budget on this shared,
    concurrently-loaded host. independent-review: FAILED — reviewer CLI did not finish within the available window — flagging for the shepherd to
    pick up rather than claiming coverage that didn't happen.

Refs #1976

🤖 Generated with Claude Code

Table.getStorageStats() and blob.ts's storage-path weighting both used raw
statfs, which reports filesystem-level free space. Under a quota-limited
data directory this can be wrong by orders of magnitude: analytics can
report hundreds of GB "available" while the instance has zero remaining
quota and is failing every write.

Both now go through a new server/storageReclamation.ts helper,
getStorageSpaceStats(), that prefers a fresh quota-status.json (written by
host-manager, already used by storage reclamation) over statfs, refactoring
the existing reclamation ratio calculation to use it too so there's a
single source of truth for quota-vs-statfs selection.

Fixes #1976

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@kriszyp
kriszyp requested review from cb1kenobi and heskew July 29, 2026 05:52
@kriszyp

kriszyp commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Docs: no companion documentation PR — this is a correctness fix to an existing internal metric (storage-volume analytics) and an internal blob-placement heuristic, not a new public surface. The one additive field (basis: 'quota' | 'filesystem') is backward compatible and mirrors the existing free_space_basis annotation system_information already exposes.

— KrAIs (Claude)

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a shared, quota-aware utility getStorageSpaceStats in storageReclamation.ts to retrieve available, free, and total storage space stats, falling back to statfs if a fresh quota status file is unavailable. This utility is integrated into table storage stats, blob storage path weighting, and analytics volume metrics. The review feedback highlights several robustness and performance improvements: handling potential NaN values if usedBytes is missing or if size is zero (causing division by zero), wrapping fire-and-forget promise operations in a try/catch block to prevent unhandled promise rejections, and parallelizing sequential await calls in the analytics metrics collection loop to avoid blocking.

Comment thread server/storageReclamation.ts
Comment thread resources/blob.ts Outdated
Comment thread resources/analytics/write.ts Outdated
Comment thread server/storageReclamation.ts Outdated
Comment thread resources/blob.ts Outdated
@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found. The new commit since the last review (48a3304, "resolve symlinks when scoping quota to a path") correctly hardens isPathWithinRoot by resolving real paths via realpath/walk-up-to-nearest-existing-ancestor before comparing to root, closing the lexical-symlink gap a prior independent review flagged. New test covers the symlinked-STORAGE_PATH case; existing quota/statfs fallback tests still pass.

kriszyp and others added 5 commits July 29, 2026 00:04
…ric, harden NaN edges

- Revert resources/blob.ts's storage-path weighting back to raw per-path
  statfs. quota-status.json is a single instance-wide figure; using it for
  createFrequencyTableForStoragePaths collapsed every disk in a multi-path
  STORAGE_BLOBPATHS config to the same "available" value, destroying the
  per-disk differentiation that function exists to compute. The metric fix
  (Table.getStorageStats(), the actual #1976 report) is unaffected — it's a
  single well-defined path under the quota-controlled data directory.
- Make the fire-and-forget createFrequencyTableForStoragePaths call handle
  its own rejection instead of leaving it unhandled.
- Guard getStorageSpaceStats/defaultGetAvailableSpaceRatio against NaN: a
  quota-status.json missing usedBytes, or a zero-size statfs result, no
  longer produces NaN (which silently failed the reclamation priority
  comparison for a full disk).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e metrics

Addresses the remaining draft review comments on #1990:

- getStorageSpaceStats now confirms the requested path is actually inside
  ROOTPATH before trusting quota-status.json, and validates usedBytes/
  quotaBytes/updatedAt before using them — a database on its own
  STORAGE_PATH (or an invalid/incomplete quota file) now correctly falls
  back to statfs instead of reporting the root's quota or fabricating
  zero usage.
- storeVolumeMetrics in analytics/write.ts now fetches per-database
  storage stats concurrently via Promise.all instead of sequentially
  awaiting each one.
- Table.getStorageStats() is now explicitly `static async` to document
  the promise-returning contract (authorized: only the in-tree analytics
  consumer awaits it already, and this was never part of the public docs).
- Extracted the available/size ratio guard into an exported
  computeAvailableRatio() so the zero-size regression test exercises real
  public behavior instead of a rewire __set__ injection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- isPathWithinRoot no longer rejects a real child path whose first
  segment happens to start with ".." (e.g. Kubernetes ConfigMap/Secret's
  "..data" symlink convention) — check the segment exactly, not a prefix.
- getStorageSpaceStats no longer treats a future/clock-skewed
  quota-status.json updatedAt as "fresh"; freshness now requires a
  non-negative age as well as being under the max age.

Found by an independent pre-push review pass on PR #1990.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
isPathWithinRoot compared lexically-normalized paths, so a path that
stays under ROOTPATH syntactically but is actually a symlink into
another volume (e.g. a STORAGE_PATH mounted via a symlinked directory)
was incorrectly treated as covered by the root's quota. Resolve real
paths instead, walking up to the nearest existing ancestor for a
not-yet-created target so a symlinked parent directory is still caught.

Found by independent pre-push review on PR #1990.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Dispatch codex/gpt-5.6-sol review (verdict CHANGES).

Comment thread server/storageReclamation.ts Outdated
Comment thread server/storageReclamation.ts Outdated
Comment thread resources/Table.ts Outdated
Comment thread unitTests/server/storageReclamation.test.js Outdated
@kriszyp
kriszyp marked this pull request as ready for review July 29, 2026 13:00
@claude

claude Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Solid change — the quota helper fails safe on every path I checked (missing/stale/malformed/unreadable quota-status.json all fall back to statfs), and the review-thread concerns are genuinely fixed in the head code. Two things before merge:

  • The Node 22 unit-test job failed on this head commit (v24/v26 green) — needs triage or a re-run.
  • Table.getStorageStats() is now async. The in-tree caller awaits it, but an out-of-tree sync caller silently gets a Promise (undefined fields, no error) — worth a release-note line.

Minor: STORAGE_VOLUME size will flap between quotaBytes and real fs size around the 5-min freshness window (the basis tag makes it diagnosable); the new .catch in blob.ts is untested; and the PR body still says blob.ts uses the new helper — the head code deliberately doesn't (see the in-code comment and DESIGN.md).

Review by @heskew (posted via Claude).


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

getStorageStats reports filesystem free space, which is wrong under a quota

1 participant