How large is a save pass? The B4 measurement
Written 2026-11-10 for work package B4 of the entity and tick re-architecture plan, against commit 8988e0aa19. It decides whether ADR 0018 can be accepted. Every line reference and count in this note is from that commit on that day. Following AGENTS.md, the note reports keys, bytes and record counts. It reports no durations. The measuring test was thrown away, and the last section says how to rebuild it.
Verdict
Accept ADR 0018 with changes. The rule itself holds up. One Database.write takes a batch of any size, and a process crash never applies part of one, so a pass can commit as one batch. A saved player record is a deep copy. Every runtime write that changes a chunk's saved content already passes through a method of Chunk, so a hook that encodes before the first write has a place to sit, once three getters stop handing out mutable sub chunks and biomes.
What the ADR gets wrong is the size of the thing it commits. A pass today is dominated by freshly generated chunks, and those carry no gameplay state. The first autosave after the default start region holds 441 generated chunks, 4,861 keys and 9,846,382 encoded bytes. That is 2.4 times the database's 4 MiB write buffer. One block changed in a chunk that is already on disk costs 2 keys and 1,389 bytes. The ADR should cut only what gameplay changed, write generated chunks on their own, and say how writes outside a pass and the level's other dimensions join the commit. The commit still has to leave the server thread, because a level that holds the default area around one player can have about 10 MB of gameplay changes in the worst case, and Database.write copies that several times before the append.
The changes, worded for the ADR, are in Sentences of ADR 0018 to change. In short:
- The cut belongs to the level, not to one world. A level has one provider and one log for all its dimensions.
- A chunk generated since the last pass and untouched by gameplay is written outside the cut, as terrain plus its generation spawns, with no actor records or digest.
- Copy-on-write works per sub chunk, not per chunk. A forced encode is then one sub chunk (2,593 bytes on average) and the height and biome record (1,495 bytes), not a whole chunk (22,327 bytes).
- Writes made outside a pass today (a player's record on disconnect or on a dimension change, a chunk written on unload) join the next commit.
Database.writeneeds a per-call sync flag and an asynchronous commit path. The open questions in the ADR's last paragraph become requirements.
What one pass writes
Scenarios
All scenarios ran in one Vitest file in packages/prismarine/src/world/. It drove the real World.save, World.savePlayerData, LevelDB provider and @jsprismarine/leveldb Database over a scratch folder, and wrapped Database.prototype.write to record each batch.
| Scenario | What it is |
|---|---|
| S1 | 441 chunks, x and z from -10 to 10, generated by the real DataDrivenGenerator from the Java vanilla datapack with seed 1234, loaded into a world through testLevel over a fresh LevelDB, then world.save(). The default preload-radius of 10 gives this area (config/Config.ts:117). Every chunk is dirty after generation. |
| S0 | world.save() again with nothing changed. |
| S2 | One block set to glass at x 3, y 100, z 3 in K chunks with Chunk.setBlockRuntimeId, the call World.writeBlockRuntimeId makes (World.ts:2353), then world.save(). K is 1, 8 and 441. Each step is reverted and saved before the next. |
| S3 | 200 GenericEntity cows added with world.addEntity, spread over 200 chunks. Saved once, saved again idle, then every cow moved 0.25 blocks and saved. |
| S4 | One player record each through World.savePlayerData. Empty inventory. All 36 main and 27 ender chest slots holding 64 cobblestone. The same slots holding an enchanted, renamed and damaged diamond sword with one lore line. The same slots holding a shulker box with 27 such swords. The three filled cases also wear enchanted diamond armor and hold a shield. |
| One batch | Every operation of S1's 441 chunk batches put into one WriteBatch and written into a fresh Database with default options. |
| Torn | The same one batch written after a one-key batch, the log copied and cut in half, and the copy reopened. |
A smaller run of 81 chunks (x and z from -4 to 4) gave the same per-chunk figures, which is some evidence that the 441-chunk numbers are not specific to the area's size.
Chunk batches
Bytes are WriteBatch.encode output, which is what the log stores before framing.
| Scenario | Batches | Keys | Encoded bytes | Per batch |
|---|---|---|---|---|
| S1, 441 fresh chunks | 441 | 4,861 | 9,846,382 | 11 or 12 keys, min 18,020, median 21,475, p90 24,270, max 36,056, mean 22,327 bytes |
| S0, nothing changed | 0 | 0 | 0 | |
| S2, K = 1 | 1 | 2 | 1,389 | |
| S2, K = 8 | 8 | 16 | 11,112 | 1,389 each |
| S2, K = 441 | 441 | 882 | 946,917 | median 1,389, max 13,773 |
| S3, 200 cows, first save | 200 | 600 | 54,400 | 272: actor, digest, digest version |
| S3, idle | 0 | 0 | 0 | |
| S3, every cow moved | 200 | 200 | 47,200 | 236: the actor record only |
A fresh chunk's keys are its 8 sub chunks (SubChunkPrefix, 9,149,233 bytes over 3,528 keys, 2,593 bytes each on average), one Data3D height and biome record (1,495 bytes on average), Version and FinalizedState. Ten chunks also had a BlockEntity record. The terrain at this seed fills 8 sections of the Overworld's 24 everywhere in the area.
An edited chunk already on disk writes only what changed, because Chunk.getPersistenceChanges tracks a revision per sub chunk. The S2 edit sat at y 100, above the terrain, so its sub chunk was small (632 bytes) and the height changed, which rewrote Data3D. An edit inside a full section re-encodes that section, which is about 2.6 KB on this terrain.
The cows are GenericEntity, whose record is the body and little else (220 bytes with its key). A data-driven mob carries its component state in persistentNbt, so these records are a lower bound.
Records outside the chunks
| Record | Bytes |
|---|---|
| Player, empty inventory | 1,717 |
| Player, 63 stacks of cobblestone | 6,277 |
| Player, 63 enchanted named swords | 21,650 |
| Player, 63 shulker boxes of 27 swords each | 523,319 |
jsprismarine:world_data, written on every pass even when unchanged | 901 |
level.dat, written outside LevelDB | 233 |
One pass as one batch
The S1 chunks committed as one batch:
| Measure | Value |
|---|---|
| Operations | 4,861 |
WriteBatch.approximateSize | 9,880,863 bytes |
| Encoded batch | 9,841,102 bytes |
| Log bytes appended, framing included | 9,843,209 bytes, 301 log fragments of up to 32 KiB |
| Memtable after the write | 9,978,083 bytes |
writeBufferSize, the default | 4,194,304 bytes |
Database.write accepted it. The torn copy reopened with the earlier one-key batch present and none of the 4,861 keys of the large batch.
How many chunks an interval dirties
The pass size depends on how many chunks are dirty when it starts. These counts come from the code. A deterministic 2,400-tick simulation was not run (see What this note could not establish).
- The first autosave after a start region holds every preloaded chunk: 441 at the default radius, 9.85 MB.
- A player's retention area is a square of radius
max(SIMULATION_DISTANCE, viewDistance)chunks (World.ts:249,World.ts:336). Each chunk boundary a player crosses along an axis brings 21 new chunks into it at the default 10, which is about 469 KB of fresh chunks at the S1 mean. - Chunks that leave the area are written by the unload path, at most
CHUNK_UNLOAD_BATCH_SIZE(8) a tick (World.ts:193,World.ts:3077), and are not in the next pass. - So the chunks dirty at the cut are bounded by the chunks loaded around players: 441 per player at default settings, plus whatever spawn chunks stay loaded. The worst case for one player is S1's 9.85 MB.
- An area nobody touches costs nothing. S0 and the idle S3 pass wrote no chunk batch at all, because a chunk whose content and actors match what is on disk produces an empty batch.
1. The current save pass, claim by claim
| Claim in ADR 0018 | Finding |
|---|---|
| Autosave starts mid-tick on a microtask after its trigger | True. World.update calls beginAutoSave at World.ts:980, before block updates. enqueueSave takes the slot and then awaits the previous pass (World.ts:3738, World.ts:3741), so write() starts on a microtask. The tick reaches its first await at await this.runBlockUpdates() (World.ts:995), and the pass runs from there, interleaved with the rest of that tick's awaits. |
| Players are captured at one instant | True, for every provider. World.write maps savePlayerData over the players (World.ts:3756), and each call builds its record synchronously before its first await (World.ts:3882 to World.ts:3926). With LevelDB the write itself is synchronous too: Level.savePlayerData calls LevelDB.writePlayerData (Level.ts:1007), which encodes and calls Database.write with no await (LevelDB.ts:444 to LevelDB.ts:448). |
| Chunks are serialized eight at a time across later ticks | Mostly true. SAVE_BATCH_SIZE is 8 (World.ts:190), and the pass yields with setImmediate between batches (World.ts:3662). The yield is one event-loop turn, not one tick, so how many ticks a pass spans depends on when the tick timer fires. Inside a batch each await writeChunk also yields a microtask, so the tick that started the pass can run between chunks of the first batch. |
| A single chunk's write is atomic | True. LevelDB.writeChunk puts the sub chunks, Data3D, block entities, actor records and digest of one chunk into one WriteBatch and calls Database.write once (LevelDB.ts:524 to LevelDB.ts:579). |
| Entities are written with their chunk from a membership list taken at the start of the pass | True, and the record mixes moments. writeChunks calls dataEntities().persist() and builds the ChunkSaveContext once (World.ts:3638, World.ts:3639), and takes the list of chunks to write at the same time (World.ts:3641). writeEntities then serializes each entity when its chunk's turn comes (LevelDB.ts:882). A data-driven entity's component state is from the start of the pass, its position is from the moment its chunk is written, and an entity removed during the pass is still written. |
level.dat is written in place without an atomic replace | True. writeLevelDat copies the old file to level.dat_old and then calls fs.writeFileSync on level.dat (LevelDat.ts:84 to LevelDat.ts:92). Nothing renames over it or syncs it. Each world's pass calls Level.saveLevelData (World.ts:3758), so a level with three dimensions writes level.dat three times per autosave. |
| The LevelDB log is not synced by default | True. sync defaults to false (DatabaseOptions.ts:43), and the provider opens the database with default options (LevelDB.ts:224). The log is synced only when a memtable flush rotates it (Database.ts:565) and on close (Database.ts:236). |
One correction to the ADR's account of the failure. Within one pass an entity cannot be written twice or dropped, because its chunk is fixed by the membership list. It is written once, into the chunk it was in when the pass started. The duplicate or the loss comes from a crash between two chunk batches of a later pass. If the cow's new chunk is written first, both digests name it and it loads twice. If its old chunk is written first, neither does.
Four writes happen outside any pass and bypass the cut entirely. A player's record is written on disconnect (Player.ts:450) and when it leaves a dimension (PlayerSession.ts:1905). A chunk is written when it unloads (World.ts:3132) and when loading it discarded an item without a stack (World.ts:1479). The first three are gameplay state, and each can pair with an older or newer chunk write to duplicate or lose an item after a crash.
3. What Database.write does with one large batch
Size. There is no limit. WriteBatch.encode writes the whole batch into one buffer (WriteBatch.ts:53 to WriteBatch.ts:63). LogWriter.append splits the record into First, Middle and Last fragments across 32 KiB blocks (LogFormat.ts:36 to LogFormat.ts:67), and AppendFile.append writes it with one fs.writeSync (Storage.ts:62 to Storage.ts:67). The S1 batch became 301 fragments.
A batch larger than the write buffer. It goes into the memtable whole. write applies every operation (Database.ts:485) and only then checks memtable.size >= writeBufferSize and schedules a background flush (Database.ts:487 to Database.ts:489). Nothing splits the batch or stalls the writer. The flush builds one level 0 table from the whole memtable (Database.ts:571), larger than maxFileSize, and compaction splits it later. If a flush is already running when the memtable fills again, write only sets flushRequested, and the new memtable keeps growing until the running flush ends.
Copies. For the S1 batch, write allocates the encoded batch twice (ByteWriter sized to approximateSize + 12, 9,880,875 bytes, then finish() copies 9,841,102 bytes), grows the log writer's buffer by doubling from 32 KiB to 16 MiB, and copies the framed record once more (9,843,209 bytes). That is about 46 MB of buffers beyond the values the pass already holds, all on the server thread. The memtable keeps references to the batch's value buffers rather than copies. A later flush posts every memtable entry to the table worker, and postMessage copies them again.
Atomic across a crash. Yes for a process crash. Once fs.writeSync returns, the record is in the kernel's page cache and survives the process. On replay, readLogRecords stops at a record whose fragments run out and yields nothing for it (LogFormat.ts:115, LogFormat.ts:149). The torn scenario showed it, and the new test in Database.test.ts keeps it. An operating system crash or a power loss is different, because the log is not synced. All records since the last sync can be lost, which still leaves the database at some earlier batch boundary if the pages reach disk in order. If they do not, two failure modes follow from the reader as written. A lost page holding the start of a record leaves a Middle fragment with nothing to continue, and readLogRecords throws CorruptionError (LogFormat.ts:135), so the database does not open. A lost page that reads back as zeros is skipped as block padding (LogFormat.ts:108), so a record could be reassembled with a block missing. Neither was tested.
Syncing at each commit. sync is a database-wide option checked on every write (Database.ts:483). Turning it on would sync after every chunk batch of today's pass and every unload write too, 441 syncs for S1. Syncing once per pass needs a per-call flag, as upstream LevelDB's WriteOptions.sync is, and AppendFile.sync is fs.fsyncSync, which also blocks the server thread.
An off-thread path. The table worker exists (Database.ts:362 to Database.ts:418), but it builds and compacts tables only and takes one request at a time. The log append is the server thread's. Moving it off needs:
- An asynchronous commit that assigns the sequence numbers up front, appends and optionally syncs with the asynchronous
fs.writeandfs.fsync(which run on Node's thread pool) or in a worker the encoded record is transferred to, and resolves when the record is on disk. - A rule for what readers see while the commit is in flight. Applying the batch to the memtable at once and acknowledging durability later keeps today's read behavior.
- No other
writewhile a commit is in flight, or a queue that appends in sequence order. This holds without a queue if every write joins the commit, which change 4 above asks for. flushMemTableandclosewaiting for an in-flight append before they sync, close and rotate the log (Database.ts:563toDatabase.ts:575).- Encoding the batch and framing it can stay on the server thread or move with it. Framing the S1 batch copies about 46 MB, so it should move.
4. Is a saved player record a deep copy?
Yes. storedInventory builds new arrays of slots (persistence.ts:219), and storedItem builds each stack from primitives, copies the lore array, turns the enchantment map into a new object and recurses into shulker contents (persistence.ts:120 to persistence.ts:139). The rest of the record in World.savePlayerData is primitives or objects built in place (World.ts:3882 to World.ts:3926). The spawn point is the player's own StoredSpawnPoint object (Player.ts:1016), and setSpawnPoint replaces it rather than changing it in place (Player.ts:1025). In S4 the record built before the inventory was changed stayed equal to a clone taken at the same time. packages/prismarine/src/inventory/persistence.test.ts now asserts this.
With LevelDB the question is mostly moot today, because the record is encoded and written in the same synchronous run that builds it. It matters once the ADR holds records until a later commit.
5. Every way a chunk's saved content changes
writeChunk saves sub chunks, Data3D, block entities, actor records and the digest (LevelDB.ts:524 to LevelDB.ts:579). ADR 0012's logged setters do not exist yet, so the question is whether one place sees every write today.
| Content | Where it changes | Single setter? |
|---|---|---|
| Block, layer 0, at runtime | World.writeBlockRuntimeId calls Chunk.setBlockRuntimeId (World.ts:2353) | Yes at the chunk. No runtime caller writes layer 1. |
| Block, generation and loading | Chunk.setBlockRuntimeId, setBlock, fillColumn, and SubChunk.setBlock on a sub chunk from getOrCreateSubChunk | No. getSubChunk and getOrCreateSubChunk (Chunk.ts:542, Chunk.ts:562) hand out a live SubChunk with a public setBlock, so a caller can bypass Chunk. Only generators and the provider do today. |
| Block entity | writeBlockEntity calls Chunk.setBlockEntity (blockEntityWrite.ts:112). A block write removes the old one through Chunk.removeBlockEntity (Chunk.ts:860). | Yes. Block entity classes have no public mutators, and every change replaces the object. |
| Biomes | Chunk.setBiomes, and getBiomes() returning the live ChunkBiomes that generators fill (Chunk.ts:211) | No runtime writer exists. The live getter is a bypass. |
Height map in Data3D | Computed from the blocks when the chunk is encoded (LevelDB.ts:549) | Covered by the block setter. |
| Lighting | Not saved. writeChunk writes no light. | Nothing to hook. |
| Scheduled block and fluid ticks | Not saved. writeChunk writes no pending tick record. | Nothing to hook. This is a save gap of its own (see below). |
| Entity membership | markEntityChunkChanged (World.ts:3363) and removeEntity (World.ts:3043) | Yes, and ADR 0018 takes membership at the cut anyway. |
| Entity content | Any setter on Entity or the data runtime, read by EntitySerializer.toNBT | No chunk setter at all. The ADR encodes entity records at the cut, so no hook is needed. |
| Dirty flag only | Chunk.markChanged (World.ts:1540, World.ts:3650) | Not a content change. |
A hook in Chunk.setBlockRuntimeId, setBlock, fillColumn, setBlockEntity, removeBlockEntity and setBiomes sees every runtime write today. To be complete rather than true by convention, getSubChunk, getOrCreateSubChunk and getBiomes must stop handing out mutable state to code that runs after generation, or the hook must move into SubChunk.setBlock and ChunkBiomes. SubChunk.getRevision already exists, so the hook can work per sub chunk.
6. The options by counts
The reference pass is S1 plus S3's 200 cows and one player with 63 swords: 441 fresh chunks, 200 actor records and one player record, 9.92 MB encoded. "Gameplay-dirty" means changed by gameplay since the last pass. K is the number of gameplay-dirty chunks.
| Option | Encodes at the cut tick | Encodes on each later tick | Peak bytes held until commit |
|---|---|---|---|
| A. Encode everything at the cut | 642 records: 441 chunks, 200 actors, 1 player (9.92 MB) | 0 | 9.92 MB, for the rest of the cut tick |
| B. ADR 0018 as written: encode before the first later write, per chunk | 201 records (about 66 KB) | 8 chunks a tick at today's batch size, 56 ticks; a forced encode moves a chunk earlier and adds none | Grows to 9.92 MB by the 56th tick |
| C. Cut only gameplay changes; write fresh chunks outside the cut; copy-on-write per sub chunk | 201 records (about 66 KB) | The gameplay-dirty sub chunks, a bounded count a tick; a forced encode is one sub chunk plus Data3D, about 4.1 KB | For the reference pass, 66 KB, because no chunk was changed by gameplay. With K = 441 one-block edits, 1.01 MB (S2) |
Under C the 441 fresh chunks still cost 9.85 MB of writes, as 441 separate batches outside the cut, the way the current pass writes them. They are safe outside the cut because a chunk that gameplay has not touched has the same content at every tick boundary since it was generated. Two conditions make that true. The out-of-cut write must carry terrain and the generation spawns as pending entity records (the provider already reads jsprismarine:pending_entities back, LevelDB.ts:516), never actor records or a digest, because those are gameplay state. And a fresh chunk that gameplay touches before it is written moves into the cut whole.
C does not remove the worst case. If gameplay changes every section of every chunk around a player in one interval, K × 22 KB is again about 9.9 MB per player. That bound, not the typical case, is what the commit path has to handle, which is why the off-thread commit stays a requirement.
Sentences of ADR 0018 to change
The quoted sentences are from the ADR as it stands at 8988e0aa19. The proposed text follows each.
- "A save pass writes the authoritative state of one tick boundary, called the save cut, and commits it as one atomic write." Proposed: "A save pass writes the authoritative state of one tick boundary of a level, called the save cut, for every dimension the level holds, and commits it as one atomic write."
- "At the cut the world encodes the player and entity records and marks the chunks that changed since the last pass." Proposed: "At the cut the level encodes the player and entity records, takes each chunk's entity membership, and marks the chunks that gameplay changed since the last pass. A chunk generated since the last pass and not changed by gameplay is not part of the cut. It is written on its own, as terrain and generation spawns, with no actor records or digest."
- "A chunk in the marked set that is about to be written to is encoded first, which every write makes possible because every write goes through a logged setter (ADR 0012)." Proposed: "A sub chunk of a marked chunk that is about to be written to is encoded first, with the chunk's height and biome record. Every chunk write goes through a method of
Chunk, and no code outside generation and loading holds a mutable sub chunk or biome store, so the hook sees every write." - "The encoded bytes go to disk between ticks." Proposed: "Once the last marked chunk is encoded, the encoded bytes go to disk as one batch. The append and its sync run off the server thread, and the next pass waits for the commit."
- "The level metadata travels in the same atomic write, and
level.datis rewritten afterward as a copy through an atomic file replace." Proposed: add "once per pass of the level" after "rewritten afterward". - "Autosave starts mid-tick on the first microtask after its trigger. Players are captured then, and chunks are serialized eight at a time across later ticks, each with the entities it holds at that moment." Proposed: "Autosave starts mid-tick, on the first microtask after its trigger. Players are captured then. Chunks are serialized eight at a time with an event-loop turn between batches, each with the entities it held when the pass started and their state when the chunk is written."
- "A crash can therefore leave an item both in a saved inventory and in a chest saved several ticks later, or drop an entity that changed chunk during the pass." Proposed: "A crash can therefore leave an item both in a saved inventory and in a chest saved several ticks later. A crash between the writes of two chunks keeps an entity that moved between them in both or in neither."
- New sentence after 7: "A player's record on disconnect and on a dimension change, and a chunk on unload, are written outside any pass today. Under this rule they join the next commit."
- "
Database.writein@jsprismarine/leveldbappends a batch of any size to the log as one record, so a pass can commit as one batch." Proposed: add "A batch larger than the write buffer goes into the memtable whole and is flushed as one table. A crash applies none of a batch whose record is incomplete." - "The commit therefore runs on the server thread between two ticks, and its cost grows with the bytes in the pass. Moving the append off the thread and syncing the log at each commit are open before this is accepted." Proposed: "
Database.writegains a per-call sync flag and an asynchronous commit that appends and syncs off the server thread. No other write reaches the database while a commit is in flight." - "The count to watch is encoded bytes held per pass." Proposed: add "A changed chunk encodes to about 22 KB, so the pass for a level holding the default 441 chunks around one player holds at most about 10 MB."
Found on the way
- Scheduled block and fluid ticks are not saved.
writeChunkwrites noPendingTicksrecord, a chunk tag the provider's own key table names (LevelDBKeys.ts:29), so every scheduled update is dropped when a chunk is unloaded or the server restarts. This is outside B4. The package brief rules out opening issues, so it is recorded here for the program owner. level.datis written once per dimension per autosave (World.ts:3758), andjsprismarine:world_datais rewritten on every pass even when it is unchanged.- The log reader throws on a fragment whose start is missing (
LogFormat.ts:135). Upstream LevelDB'slog::Readerreports such a fragment as "missing start of fragmented record" and skips it. After a power loss with an unsynced log, this database could refuse to open.
What this note could not establish
- How many chunks gameplay dirties in a 2,400-tick interval. No deterministic driver for random ticks, fluids and mobs over real terrain was built. The counts above give the bounds (zero for an untouched area, 441 per player at default settings), not the typical value.
- The size of a data-driven mob's record. The measured cows are
GenericEntity, a lower bound. - What a power loss does to the unsynced log. Both failure modes above are read from the code and were not reproduced.
- Chunk sizes for terrain other than the Overworld around the origin at seed
1234. Terrain that fills more sections encodes to more bytes per chunk.
How to reproduce the counts
The measuring test was deleted. To rebuild it, write a Vitest file in packages/prismarine/src/world/, skipped when findJavaWorldgenSources() returns undefined, that:
- Wraps
Database.prototype.writeto record, for each batch,batch.count,batch.encode(0n).byteLength, the change inthis.stats.bytesWrittenand each operation's key and value, then calls the original. - Generates chunks x and z from -10 to 10 with
new DataDrivenGenerator({ registries: loadJavaWorldgenRegistries(sources), templates: findJavaTemplateSource(cacheRoot(), registries.processorLists) }).generateChunk(x, z, 1234n). - Builds a level with
testLevel({ provider: new LevelDB(folder, server), generator, spawn }), wheregeneratorhands back those chunks andserveris the stubPlayerPersistence.test.tsuses, then callsworld.getChunkfor each. - Runs S1, S0, S2, S3 and S4 as the scenario table describes, classifying each recorded batch by its keys with
decodeChunkKey,ACTOR_PREFIX,DIGEST_PREFIXandPLAYER_SERVER_PREFIXfromLevelDBKeys.ts. Players are built asPlayerPersistence.test.tsbuilds them and saved withworld.savePlayerData. - Concatenates S1's chunk operations into one
WriteBatch, writes it to a freshDatabase, and readsstats.memtableBytes. For the torn case, copies the database folder withoutLOCK, cuts the.logfile to half its length, and reopens the copy.
Run it from packages/prismarine with pnpm exec vitest run <file>. It needs the world generation cache that pnpm --filter @jsprismarine/bedrock-data run ensure:worldgen fills.
The two tests this work kept run with:
pnpm exec vitest run packages/leveldb/src/Database.test.ts -t "lost its tail"
pnpm exec vitest run packages/prismarine/src/inventory/persistence.test.ts