decompress .json.zstd fetched by getSaveData
This commit is contained in:
@@ -38,7 +38,7 @@ SaveParserBackendService -> /api/proxy/saveParserBackend/* -> backend
|
||||
|
||||
- [src/services/SaveParserBackendService.ts](src/services/SaveParserBackendService.ts)
|
||||
is the only place that talks to the backend. It exposes `uploadSave`,
|
||||
`getSaveStatus`, `getParsedData` and `getSaveFile` — upload, poll until
|
||||
`getSaveStatus`, `getSaveData` and `getSaveFile` — upload, poll until
|
||||
parsing finishes, then fetch the result or the stored save back.
|
||||
- [src/app/api/proxy/saveParserBackend/[[...path]]/route.ts](src/app/api/proxy/saveParserBackend/[[...path]]/route.ts)
|
||||
forwards every method to the configured backend URL. It exists only to keep
|
||||
@@ -113,6 +113,13 @@ Three things there are easy to break:
|
||||
- **Compression runs in a Web Worker** because it would otherwise block the
|
||||
page for seconds on a large save.
|
||||
|
||||
`getSaveData` is the deliberate exception: the backend stores parsed save data
|
||||
as zstd compressed json, and the service decompresses that response on the main
|
||||
thread instead of in the worker. Decompression is cheap and its cost does not
|
||||
depend on the level — the whole 87 MB of an EU4 save comes back in 0.27 s at
|
||||
level 10, and the parsed json is a fraction of that, so it is a matter of
|
||||
milliseconds. A worker round trip would cost more than it saves.
|
||||
|
||||
[COMPRESSION.md](COMPRESSION.md) has every zstd level measured on a real save —
|
||||
size, time and the memory the encoder and decoder need. Consult it instead of
|
||||
measuring again when a device cannot cope with the current level.
|
||||
@@ -203,6 +210,12 @@ does not replace a full build.
|
||||
|
||||
Do not report a change as finished until both commands pass.
|
||||
|
||||
### Tests and benchmarks
|
||||
|
||||
**Never run a test suite or a benchmark without explicit permission.** Ask
|
||||
first and wait for a clear yes, however easy the command looks. Lint and build
|
||||
are the exception, and are expected after every change.
|
||||
|
||||
## Version control
|
||||
|
||||
This project is managed with **git**.
|
||||
|
||||
+27
-27
@@ -5,8 +5,8 @@ Reference for `saveUpload.zstdCompressionLevel` in
|
||||
out to be too slow or too memory hungry on some device, pick a different level
|
||||
from the table below rather than measuring again.
|
||||
|
||||
The level only affects the uploader. Decompression on the parser backend costs
|
||||
the same at every level.
|
||||
The level only affects the uploader. Decompression costs the same at every
|
||||
level, both on the parser backend and in the browser.
|
||||
|
||||
## What was measured
|
||||
|
||||
@@ -33,34 +33,34 @@ correct output rather than merely fast output.
|
||||
|
||||
## The table
|
||||
|
||||
| level | compressed | ratio | compress | decompress | encoder heap | decoder heap | window |
|
||||
| -----: | ------------: | --------: | ---------: | ---------: | -----------: | -----------: | -----: |
|
||||
| 1 | 6,698,281 | 1.72x | 0.54 s | 0.28 s | 8 MB | 8 MB | 1 MB |
|
||||
| 2 | 6,600,451 | 1.74x | 0.54 s | 0.29 s | 8 MB | 8 MB | 1 MB |
|
||||
| 3 | 6,094,116 | 1.89x | 0.57 s | 0.28 s | 8 MB | 8 MB | 2 MB |
|
||||
| 4 | 6,082,362 | 1.89x | 0.61 s | 0.28 s | 8 MB | 8 MB | 2 MB |
|
||||
| 5 | 5,280,090 | 2.18x | 0.92 s | 0.28 s | 8 MB | 8 MB | 2 MB |
|
||||
| 6 | 4,943,366 | 2.33x | 1.13 s | 0.27 s | 8 MB | 8 MB | 2 MB |
|
||||
| 7 | 4,738,515 | 2.43x | 1.27 s | 0.27 s | 9 MB | 8 MB | 2 MB |
|
||||
| 8 | 4,514,853 | 2.55x | 1.52 s | 0.28 s | 9 MB | 8 MB | 2 MB |
|
||||
| 9 | 4,445,368 | 2.59x | 1.51 s | 0.26 s | 16 MB | 8 MB | 4 MB |
|
||||
| 10 | 4,280,567 | 2.69x | 1.74 s | 0.27 s | 26 MB | 8 MB | 4 MB |
|
||||
| **11** | **4,157,684** | **2.77x** | **2.44 s** | **0.32 s** | **26 MB** | **8 MB** | 4 MB |
|
||||
| 12 | 4,155,668 | 2.77x | 2.39 s | 0.27 s | 46 MB | 8 MB | 4 MB |
|
||||
| 13 | 4,257,141 | 2.70x | 3.76 s | 0.27 s | 38 MB | 8 MB | 4 MB |
|
||||
| 14 | 4,108,558 | 2.80x | 5.39 s | 0.28 s | 54 MB | 8 MB | 4 MB |
|
||||
| 15 | 3,968,262 | 2.90x | 8.13 s | 0.33 s | 70 MB | 8 MB | 4 MB |
|
||||
| 16 | 3,862,217 | 2.98x | 11.63 s | 0.26 s | 38 MB | 8 MB | 4 MB |
|
||||
| 17 | 3,733,198 | 3.08x | 12.39 s | 0.26 s | 58 MB | 10 MB | 8 MB |
|
||||
| 18 | 3,705,141 | 3.10x | 15.11 s | 0.26 s | 59 MB | 10 MB | 8 MB |
|
||||
| 19 | 3,449,475 | 3.33x | 27.78 s | 0.28 s | 91 MB | 10 MB | 8 MB |
|
||||
| 20 | 3,344,642 | 3.44x | 28.24 s | 0.27 s | 195 MB | 34 MB | 32 MB |
|
||||
| 21 | 3,285,836 | 3.50x | 33.91 s | 0.30 s | 387 MB | 66 MB | 64 MB |
|
||||
| 22 | 3,245,980 | 3.54x | 49.02 s | 0.30 s | 835 MB | 130 MB | 128 MB |
|
||||
| level | compressed | ratio | compress | decompress | encoder heap | decoder heap |
|
||||
| -----: | ------------: | --------: | ---------: | ---------: | -----------: | -----------: |
|
||||
| 1 | 6,698,281 | 1.72x | 0.54 s | 0.28 s | 8 MB | 8 MB |
|
||||
| 2 | 6,600,451 | 1.74x | 0.54 s | 0.29 s | 8 MB | 8 MB |
|
||||
| 3 | 6,094,116 | 1.89x | 0.57 s | 0.28 s | 8 MB | 8 MB |
|
||||
| 4 | 6,082,362 | 1.89x | 0.61 s | 0.28 s | 8 MB | 8 MB |
|
||||
| 5 | 5,280,090 | 2.18x | 0.92 s | 0.28 s | 8 MB | 8 MB |
|
||||
| 6 | 4,943,366 | 2.33x | 1.13 s | 0.27 s | 8 MB | 8 MB |
|
||||
| 7 | 4,738,515 | 2.43x | 1.27 s | 0.27 s | 9 MB | 8 MB |
|
||||
| 8 | 4,514,853 | 2.55x | 1.52 s | 0.28 s | 9 MB | 8 MB |
|
||||
| 9 | 4,445,368 | 2.59x | 1.51 s | 0.26 s | 16 MB | 8 MB |
|
||||
| **10** | **4,280,567** | **2.69x** | **1.74 s** | **0.27 s** | **26 MB** | **8 MB** |
|
||||
| 11 | 4,157,684 | 2.77x | 2.44 s | 0.32 s | 26 MB | 8 MB |
|
||||
| 12 | 4,155,668 | 2.77x | 2.39 s | 0.27 s | 46 MB | 8 MB |
|
||||
| 13 | 4,257,141 | 2.70x | 3.76 s | 0.27 s | 38 MB | 8 MB |
|
||||
| 14 | 4,108,558 | 2.80x | 5.39 s | 0.28 s | 54 MB | 8 MB |
|
||||
| 15 | 3,968,262 | 2.90x | 8.13 s | 0.33 s | 70 MB | 8 MB |
|
||||
| 16 | 3,862,217 | 2.98x | 11.63 s | 0.26 s | 38 MB | 8 MB |
|
||||
| 17 | 3,733,198 | 3.08x | 12.39 s | 0.26 s | 58 MB | 10 MB |
|
||||
| 18 | 3,705,141 | 3.10x | 15.11 s | 0.26 s | 59 MB | 10 MB |
|
||||
| 19 | 3,449,475 | 3.33x | 27.78 s | 0.28 s | 91 MB | 10 MB |
|
||||
| 20 | 3,344,642 | 3.44x | 28.24 s | 0.27 s | 195 MB | 34 MB |
|
||||
| 21 | 3,285,836 | 3.50x | 33.91 s | 0.30 s | 387 MB | 66 MB |
|
||||
| 22 | 3,245,980 | 3.54x | 49.02 s | 0.30 s | 835 MB | 130 MB |
|
||||
|
||||
## Picking a level
|
||||
|
||||
**11 is the default**, because it is where the curve flattens. It gives good compression in reasonable time.
|
||||
**10 is the default**, because it is where the curve flattens. It gives good compression in reasonable time: 11 is only 3% smaller but takes 40% longer.
|
||||
|
||||
If there will be troubles with this level in the future, just change it in config.
|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@ export const defaultConfig = {
|
||||
// Size of a save if it is uploaded as uncompressed. The browser packs
|
||||
// saves as a stream, so these limits are about the backend's disk
|
||||
// rather than about memory.
|
||||
maxSizePlainTextBytes: 200 * 1024 * 1024,
|
||||
maxSizePlainTextMB: 200,
|
||||
// Archives produced by the game, recognized by the zip magic bytes.
|
||||
maxSizeCompressedBytes: 60 * 1024 * 1024,
|
||||
maxSizeCompressedMB: 60,
|
||||
// read COMPRESSION.md
|
||||
zstdCompressionLevel: 11,
|
||||
zstdCompressionLevel: 10,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const KILOBYTE = 1024
|
||||
const MEGABYTE = 1024 * KILOBYTE
|
||||
export const KILOBYTE = 1024
|
||||
export const MEGABYTE = 1024 * KILOBYTE
|
||||
|
||||
/**
|
||||
* Formats a byte count for error messages and status lines, e.g. "12.3 MB".
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import type { SaveUploadConfig } from '@/config/AppConfig'
|
||||
import { GAME_SAVE_ENTRY_NAMES, GameEnum } from '@/config/games'
|
||||
import { formatBytes } from '@/lib/formatBytes'
|
||||
import { formatBytes, MEGABYTE } from '@/lib/formatBytes'
|
||||
import { isZipFile } from './gameZip'
|
||||
import {
|
||||
OriginalFileType,
|
||||
@@ -68,9 +68,10 @@ export async function packSaveForUpload(
|
||||
onStage: StageCallback
|
||||
): Promise<PackedSave> {
|
||||
const isZip = await isZipFile(file)
|
||||
const sizeLimit = isZip
|
||||
? config.maxSizeCompressedBytes
|
||||
: config.maxSizePlainTextBytes
|
||||
// The limits are configured in megabytes, file sizes are in bytes.
|
||||
const sizeLimit =
|
||||
(isZip ? config.maxSizeCompressedMB : config.maxSizePlainTextMB) *
|
||||
MEGABYTE
|
||||
|
||||
if (file.size > sizeLimit) {
|
||||
const saveKind = isZip ? 'archived' : 'plain text'
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SaveUploadConfig } from '@/config/AppConfig'
|
||||
import { GameEnum } from '@/config/games'
|
||||
import { packSaveForUpload, StageCallback } from '@/lib/saveArchive/client'
|
||||
import { OriginalFileType } from '@/lib/saveArchive/messages'
|
||||
import { createZstdDecompressStream } from '@/lib/saveArchive/zstdStream'
|
||||
|
||||
export interface UploadSaveResponse {
|
||||
id: string
|
||||
@@ -86,7 +87,12 @@ export class SaveParserBackendService {
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
// Fetches the fully parsed data for a save once its parsing is done.
|
||||
/**
|
||||
* Fetches the fully parsed data for a save once its parsing is done.
|
||||
*
|
||||
* The backend stores and serves that data as zstd compressed json, so the
|
||||
* body is decompressed on the way in rather than read as text first.
|
||||
*/
|
||||
async getSaveData(id: string): Promise<Record<string, unknown>> {
|
||||
const url = `${this.API_BASE}/getSaveData?id=${encodeURIComponent(id)}`
|
||||
const response = await fetch(url)
|
||||
@@ -97,8 +103,16 @@ export class SaveParserBackendService {
|
||||
(await getBodyTextOrEmpty(response))
|
||||
)
|
||||
}
|
||||
// A body is only missing on a response that cannot carry one, which a
|
||||
// successful getSaveData never is.
|
||||
if (!response.body) {
|
||||
throw new Error('Failed to get parsed save: response has no body')
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
// Decompresses as the bytes arrive, so the compressed json is never
|
||||
// held whole; only the parsed object stays in memory.
|
||||
const json = response.body.pipeThrough(createZstdDecompressStream())
|
||||
return await new Response(json).json()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user