OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-vdrive commits / 8a91fd0

truncate/clear: parity with a stock drive, and two upstream constraints
The virtual drive behaves exactly like a stock one through clear() and truncate() — same bitfield transitions, same reads, same failures. Both findings below reproduce on stock, so neither is ours. clear() reclaims no disk. Clearing ~4MB of blocks left the stock store larger (6.81MB -> 6.90MB) even after compactRange; the column family's blobGarbageCollectionAgeCutOff of 0.25 is a plausible but unverified explanation. This argues for virtualising: on a virtual drive clear frees only pointer records, and the bytes are freed by the overlay's refcount immediately. clear-then-truncate is not a supported sequence. clear() deletes merkle tree nodes for fully-cleared spans (hypercore/lib/session-state.js:576), so a later truncate fails with "Expected tree node 71 from storage, got (nil)". Same-session happens to work off the tree cache; across sessions it does not. Adds test/truncate-clear.mjs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8a91fd08b9a25aaedf699e03bc6a05e0c0b524f9
by plankatron <fleeky@gmail.com> · 2026-08-24T14:21:35.000Z

Changed files (3) +175 -1

README.md +5 -1
……@@ -108,4 +108,8 @@ Known gaps, in the order they'd bite:
108108 equalling chunk bytes. Run unencrypted, or teach the adapter to re-derive.
1091094. **Unsupported seam.** `hypercore-storage` has no public pluggable-storage contract
110110 since `random-access-storage` was dropped. Pin versions and run the tests as a canary.
1115. Untested: truncate/clear, crash during append.
1115. **clear-then-truncate is unsupported upstream** — `clear()` prunes merkle tree nodes,
112 so a later `truncate()` fails with `Expected tree node N from storage, got (nil)`.
113 Same on a stock drive. And `clear()` reclaims no disk promptly: on a virtual drive
114 that does not matter, because the bytes are the overlay's to free.
1156. Untested: crash during append.
RESULTS.md +35 -0
……@@ -131,6 +131,41 @@ block-map block + tree nodes + pointer record. A 10,000-file tree costs ~14 MB.
131131Small files dilute the win (152× rather than the 657× of large artifacts) because
132132metadata, not bytes, becomes the cost.
133133
134## truncate / clear
135
136Three files (v1 and v2 of the same path, which share blocks under `dedup:true`, plus
137an unrelated file), then `drive.clear('/app.bin')`, then a truncate of the blobs core.
138Sizes are compacted.
139
140| | stock | virtual |
141|---|---|---|
142| after publish | 6.81 MB | 0.13 MB |
143| after `clear('/app.bin')` | 6.90 MB | 0.22 MB |
144| after attempted truncate | 6.98 MB | 0.30 MB |
145| `has()` on a cleared block | false | false |
146| read the cleared entry | REQUEST_TIMEOUT | REQUEST_TIMEOUT |
147| read the untouched entry | ok | ok |
148
149**Parity: the virtual drive behaves exactly like the stock one.** Two findings, both
150upstream and both reproduced on stock:
151
1521. **`clear()` reclaims no disk.** Clearing ~4 MB of blocks left the stock store
153 *larger* (6.81 → 6.90 MB) even after `compactRange`. The corestore column family
154 sets `blobGarbageCollectionAgeCutOff: 0.25`, so blob-file space is collected lazily
155 — a plausible explanation I did not verify. Either way, "clear to reclaim space" is
156 not a thing you can rely on promptly.
157
158 This is an argument *for* virtualising: on a virtual drive, clear frees only pointer
159 records, and the bytes are freed by the overlay's own refcount and `unlink` —
160 immediately, and verifiably.
161
1622. **clear-then-truncate is not a supported sequence.** After a clear, truncating the
163 blobs core fails with `INVALID_OPERATION: Expected tree node 71 from storage, got
164 (nil)` — on stock and virtual identically. Mechanism: `clear()` deletes merkle tree
165 nodes for fully-cleared spans (`hypercore/lib/session-state.js:576-577`), and
166 truncate then needs one of them. Same-session clear-then-truncate happens to work
167 (the node is still cached); across sessions it fails. Pick one operation, not both.
168
134169## GC / bitfield
135170
136171```
test/truncate-clear.mjs +135 -0
……@@ -0,0 +1,135 @@
1// truncate / clear — does deleting blocks free pointer records, and does the
2// virtual drive behave the same as a stock one?
3// node test/truncate-clear.mjs <workDir>
4import { createRequire } from 'module'
5const require = createRequire(import.meta.url)
6
7const fs = require('fs')
8const { execFileSync } = require('child_process')
9const crypto = require('hypercore-crypto')
10const Corestore = require('corestore')
11const Hyperdrive = require('hyperdrive')
12const CorestoreStorage = require('hypercore-storage')
13const { ContentStore, build, stats } = require('../lib/storage.cjs')
14const { chunk } = await import('../../hyper-overlay/lib/chunker.js')
15
16const ROOT = process.argv[2]
17const PRIMARY = Buffer.alloc(32, 7)
18const du = (p) => Number(execFileSync('du', ['-sb', p]).toString().split('\t')[0])
19
20async function compactedSize (dir) {
21 const s = new CorestoreStorage(dir)
22 await s.ready()
23 await s.compact()
24 await s.close()
25 return du(dir)
26}
27
28const srcDir = ROOT + '/src'
29fs.mkdirSync(srcDir, { recursive: true })
30
31// v1 and v2 of the same path share most blocks (dedup seeds from the previous
32// entry at that path) — that sharing is what makes clear() dangerous.
33const v1 = crypto.randomBytes(4 * 1024 * 1024)
34const v2 = Buffer.concat([v1.subarray(0, 1024 * 1024), crypto.randomBytes(256 * 1024), v1.subarray(1024 * 1024 + 256 * 1024)])
35const other = crypto.randomBytes(2 * 1024 * 1024)
36
37const FILES = [
38 { name: '/app.bin', bytes: v1, file: srcDir + '/v1' },
39 { name: '/app.bin', bytes: v2, file: srcDir + '/v2' },
40 { name: '/other.bin', bytes: other, file: srcDir + '/other' }
41]
42for (const f of FILES) fs.writeFileSync(f.file, f.bytes)
43
44const content = new ContentStore()
45for (const f of FILES) {
46 f.chunks = chunk(f.bytes)
47 for (const c of f.chunks) content.add(c.hash, f.file, c.offset, c.length)
48}
49
50function write (ws, buf) {
51 return new Promise((resolve) => {
52 if (ws.write(buf)) return resolve()
53 ws.once('drain', resolve)
54 })
55}
56
57async function open (dir, virtual) {
58 const Storage = virtual ? build(content) : null
59 const store = new Corestore(virtual ? new Storage(dir) : dir, { primaryKey: PRIMARY, unsafe: true })
60 const drive = new Hyperdrive(store)
61 await drive.ready()
62 await drive.getBlobs()
63 return { store, drive }
64}
65
66async function run (dir, virtual) {
67 const out = { virtual }
68
69 {
70 const { store, drive } = await open(dir, virtual)
71 for (const f of FILES) {
72 const ws = drive.createWriteStream(f.name, { dedup: true })
73 for (const c of f.chunks) await write(ws, c.data)
74 await new Promise((resolve, reject) => {
75 ws.once('error', reject)
76 ws.once('close', resolve)
77 ws.end()
78 })
79 }
80 out.blobsLength = drive.blobs.core.length
81 await drive.close()
82 await store.close()
83 }
84 out.sizeAfterPublish = await compactedSize(dir)
85
86 // clear the current /app.bin entry
87 {
88 const { store, drive } = await open(dir, virtual)
89 const entry = await drive.entry('/app.bin')
90 const map = await drive.blobs.getBlockMap(entry.value.blob)
91 const blocks = map.blocks.map((b) => b.index)
92 out.appBlocks = blocks.length
93
94 await drive.clear('/app.bin')
95
96 out.hasAfterClear = await drive.blobs.core.has(blocks[0])
97 const app = await drive.get('/app.bin', { timeout: 2000 }).catch((e) => e.code || 'throw')
98 out.readAppAfterClear = typeof app === 'string' ? app : app === null ? 'null' : 'bytes:' + app.length
99 const oth = await drive.get('/other.bin', { timeout: 2000 }).catch((e) => e.code || 'throw')
100 out.readOtherAfterClear =
101 typeof oth === 'string' ? oth : oth === null ? 'null' : oth.equals(other) ? 'ok' : 'CORRUPT'
102 await drive.close()
103 await store.close()
104 }
105 out.sizeAfterClear = await compactedSize(dir)
106
107 // truncate the blobs core outright
108 {
109 const { store, drive } = await open(dir, virtual)
110 const before = drive.blobs.core.length
111 const target = Math.floor(before / 2)
112 let truncateResult = 'ok'
113 try {
114 await drive.blobs.core.truncate(target)
115 } catch (err) {
116 truncateResult = (err.code || 'ERR') + ': ' + err.message
117 }
118 out.truncate = { before, target, result: truncateResult, after: drive.blobs.core.length }
119
120 const oth = await drive.get('/other.bin', { timeout: 2000 }).catch((e) => e.code || 'throw')
121 out.readOtherAfterTruncate =
122 typeof oth === 'string' ? oth : oth === null ? 'null' : oth.equals(other) ? 'ok' : 'CORRUPT'
123 await drive.close()
124 await store.close()
125 }
126 out.sizeAfterTruncate = await compactedSize(dir)
127
128 return out
129}
130
131const stock = await run(ROOT + '/stock', false)
132const virt = await run(ROOT + '/virtual', true)
133content.close()
134
135console.log(JSON.stringify({ stock, virtual: virt, stats }, null, 2))