Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/prepare-restore-once.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sheetwrite/core": minor
---

Reuse decoded compressed restore blocks during synchronous transaction application and row projection, while revalidating payloads changed by host callbacks. Large-clear undo uses its already materialized block without decompressing it again. Rebase decodes each restore once across structural edits and serializes it once when complete. Document operations and change events retain their existing wire format.
59 changes: 54 additions & 5 deletions bench/src/core-paths-bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
import { createGridController, type GridControllerHandlers } from "@sheetwrite/core/adapter";
import { installCanvasTestStubs } from "@sheetwrite/core/testing";
import { initSync } from "@sheetwrite/wasm";
import { encodeRestoreBlock } from "../../packages/core/src/restore-block.js";

import { assertFiniteNonNegative, type BenchmarkMode, validateRawStat } from "./gate-protocol.js";
import { type ProtocolCaptureMeta, protocolCaptureMeta } from "./protocol-meta.js";
Expand Down Expand Up @@ -108,6 +109,7 @@ export const CORE_PATH_SCENARIOS = [
"validation-sparse",
"csv-import",
"undo-large-clear",
"restore-near-ceiling",
"paged-evicted-revisit",
"node-cold-init",
] as const;
Expand Down Expand Up @@ -153,11 +155,9 @@ const FULL_SCALE: CorePathScale = {
validationRules: 100,
csvRows: 200_000,
csvColumns: 10,
// Undo restores the cleared numbers as one transaction. 500,000 cells stay
// under the default 8 MiB payload limit; 1,000,000 cells are just above it,
// and the Grid then rejects that undo by design.
// A million-cell clear exceeds ordinary wire admission and must compact on undo.
clearRows: 1_000,
clearColumns: 500,
clearColumns: 1_000,
pagedRows: 200_000,
};

Expand Down Expand Up @@ -208,6 +208,7 @@ function samplePlan(id: CorePathScenarioId, mode: BenchmarkMode): SamplePlan {
case "csv-import":
return { warmup: 1, iters: smoke ? 3 : 2, gcBetween: true };
case "undo-large-clear":
case "restore-near-ceiling":
return { warmup: 1, iters: 3, gcBetween: true };
case "paged-evicted-revisit":
return { warmup: 1, iters: 3, gcBetween: false };
Expand Down Expand Up @@ -1049,6 +1050,38 @@ function importCsvText(scale: CorePathScale, plan: SamplePlan): CorePathScenario
);
}

// A repetitive 60 MiB decoded payload is valid under both restore ceilings.
// Encoding and initial sheet construction are deliberately outside the samples.
function restoreNearCeiling(scale: CorePathScale, plan: SamplePlan): CorePathScenarioResult {
const rows = scale === FULL_SCALE ? 4_000 : 100;
const columns = scale === FULL_SCALE ? 1_000 : 100;
const value = "restore-text";
const operation = encodeRestoreBlock(sheetRange(rows, columns), {
rowCount: rows,
colCount: columns,
values: new Array<CellScalar>(rows * columns).fill(value),
});
const mounted = mountGrid({ workbook: workbook(rows, numberColumns(columns)) });
const samplesMs = collect(() => {
assertApplied(mounted.grid.applyRemoteOperations([operation]).status, "restoreBlock");
}, plan);
if (resolvedCell(mounted.grid, rows - 1, columns - 1) !== value) {
fail("near-ceiling restore did not apply the final cell");
}
mounted.destroy();
return scenarioResult(
"restore-near-ceiling",
`one restoreBlock with ${operation.decodedBytes} decoded bytes`,
samplesMs,
{
restoredCells: rows * columns,
decodedBytes: operation.decodedBytes,
peakRssBytes: process.resourceUsage().maxRSS * 1024,
},
"a valid near-ceiling compressed restore applies its complete block",
);
}

// ── undo-large-clear ────────────────────────────────────────────────────────

function undoLargeClear(scale: CorePathScale, plan: SamplePlan): CorePathScenarioResult {
Expand All @@ -1067,6 +1100,14 @@ function undoLargeClear(scale: CorePathScale, plan: SamplePlan): CorePathScenari
data,
});
const { grid } = mounted;
let compactRestores = 0;
grid.on("change", (event) => {
if (event.commitReason === "undo") {
compactRestores += event.transaction.patches.filter(
(operation) => operation.op === "restoreBlock",
).length;
}
});
const clearOperation: DocumentOp = {
op: "clearRange",
range: sheetRange(scale.clearRows, scale.clearColumns),
Expand Down Expand Up @@ -1105,13 +1146,20 @@ function undoLargeClear(scale: CorePathScale, plan: SamplePlan): CorePathScenari
// Every cell is compared against its original value after timing: a partial
// restore fails here even though each undo returned normally.
assertWindowedValues(grid, scale.clearRows, scale.clearColumns, 0, "undo restore");
if (scale === FULL_SCALE && compactRestores === 0) fail("large-clear undo did not compact");
mounted.destroy();

return scenarioResult(
"undo-large-clear",
`one undo of a ${cellCount.toLocaleString("en-US")}-cell clearRange`,
samplesMs,
{ clearedCells: cellCount, clears, restoredCells: cellCount },
{
clearedCells: cellCount,
clears,
restoredCells: cellCount,
compactRestores,
peakRssBytes: process.resourceUsage().maxRSS * 1024,
},
"the clear empties the block and the undos restore every original cell value",
);
}
Expand Down Expand Up @@ -1398,6 +1446,7 @@ export const CORE_PATH_SCENARIO_RUNNERS: Readonly<
"validation-sparse": writeSparseRangeWithRules,
"csv-import": importCsvText,
"undo-large-clear": undoLargeClear,
"restore-near-ceiling": restoreNearCeiling,
"paged-evicted-revisit": revisitEvictedBand,
"node-cold-init": (_scale, plan) => loadWasmInFreshNode(plan),
};
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/api/core/document-op-target.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ description: "Exhaustive stable target identity used by persistence/logging laye
Exhaustive stable target identity used by persistence/logging layers.

<dl class="api-metadata" data-pagefind-ignore>
<div><dt>Source</dt><dd><a href="https://github.com/chh-ay/sheetwrite/blob/main/packages/core/src/document-protocol.ts#L2955"><code>packages/core/src/document-protocol.ts#L2955</code></a></dd></div>
<div><dt>Source</dt><dd><a href="https://github.com/chh-ay/sheetwrite/blob/main/packages/core/src/document-protocol.ts#L2966"><code>packages/core/src/document-protocol.ts#L2966</code></a></dd></div>
</dl>

## Declaration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ conflicts instead of lossy guesses. This is the collaboration design gate;
no CRDT dependency is required for the supported cases.

<dl class="api-metadata" data-pagefind-ignore>
<div><dt>Source</dt><dd><a href="https://github.com/chh-ay/sheetwrite/blob/main/packages/core/src/rebase.ts#L48"><code>packages/core/src/rebase.ts#L48</code></a></dd></div>
<div><dt>Source</dt><dd><a href="https://github.com/chh-ay/sheetwrite/blob/main/packages/core/src/rebase.ts#L51"><code>packages/core/src/rebase.ts#L51</code></a></dd></div>
</dl>

## Declaration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ description: "Validate and canonically order a schema-1 snapshot without hydrati
Validate and canonically order a schema-1 snapshot without hydrating runtime state.

<dl class="api-metadata" data-pagefind-ignore>
<div><dt>Source</dt><dd><a href="https://github.com/chh-ay/sheetwrite/blob/main/packages/core/src/document-protocol.ts#L2917"><code>packages/core/src/document-protocol.ts#L2917</code></a></dd></div>
<div><dt>Source</dt><dd><a href="https://github.com/chh-ay/sheetwrite/blob/main/packages/core/src/document-protocol.ts#L2928"><code>packages/core/src/document-protocol.ts#L2928</code></a></dd></div>
</dl>

## Declaration
Expand Down
2 changes: 1 addition & 1 deletion docs/src/generated/docs-contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"entryPointCount": 20,
"exportCount": 484,
"symbolPageCount": 484,
"apiSha256": "568acf9fca3fd2f5833e532bd97ff236bc1e3412a7de362efa7722be132e6908",
"apiSha256": "3d8e4e61e382a25d648d082e3a7285b986cbf7257c1c66562c34c652408697cd",
"compatibilitySha256": "0e2d1e8db45a097ab437bdcb85591c8fddabd60cd6dcfcf2bc2098588e2966f6",
"formulaContractSha256": "daec72c46c939824c564fe3fe0758858f67c298a7c888f18fd4a060f467bd3b0"
}
6 changes: 3 additions & 3 deletions docs/src/generated/public-api.json
Original file line number Diff line number Diff line change
Expand Up @@ -1323,7 +1323,7 @@
"owners": [
"src/document-protocol.ts"
],
"source": "src/document-protocol.ts#L2955",
"source": "src/document-protocol.ts#L2966",
"jsDocTags": [],
"documentation": "Exhaustive stable target identity used by persistence/logging layers.",
"memberDocs": []
Expand Down Expand Up @@ -2767,7 +2767,7 @@
"owners": [
"src/rebase.ts"
],
"source": "src/rebase.ts#L48",
"source": "src/rebase.ts#L51",
"jsDocTags": [],
"documentation": "Conservative server-ordered rebase for pending offline work. Non-overlapping\nliteral edits are shifted across row/column insertion and deletion. Ambiguous\nformula, overlapping, sheet-lifecycle, and move cases become explicit\nconflicts instead of lossy guesses. This is the collaboration design gate;\nno CRDT dependency is required for the supported cases.",
"memberDocs": []
Expand Down Expand Up @@ -4871,7 +4871,7 @@
"owners": [
"src/document-protocol.ts"
],
"source": "src/document-protocol.ts#L2917",
"source": "src/document-protocol.ts#L2928",
"jsDocTags": [],
"documentation": "Validate and canonically order a schema-1 snapshot without hydrating runtime state.",
"memberDocs": []
Expand Down
14 changes: 11 additions & 3 deletions packages/core/src/document-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { cloneCellHyperlink } from "./hyperlink.js";
import { encodeRestoreBlock, estimateRestoreBlockBytes } from "./restore-block.js";
import type { SheetwriteStore } from "./store.js";
import type { GridTransactionAdmissionDecision } from "./transaction-admission.js";
import { TransactionPreparation } from "./transaction-preparation.js";
import type { CellValue, Column } from "./types/cell.js";
import type { CellAddress, MergeRange, Range, SheetId } from "./types/coordinates.js";
import type {
Expand Down Expand Up @@ -845,10 +846,12 @@ export class DocumentController {

this.applyingHistory = true;
let outcome: ApplyTransactionResult;
const preparation = new TransactionPreparation();
try {
const fitted = this.fitHistoryPatches(patches);
const fitted = this.fitHistoryPatches(patches, preparation);
outcome = this.commit(fitted.patches, reason, fitted.admitted);
} finally {
preparation.dispose();
this.applyingHistory = false;
}
if (outcome.status !== "applied") return outcome;
Expand All @@ -867,7 +870,10 @@ export class DocumentController {
* multi-version commit in sync. The returned measurement lets the commit
* skip a second walk of the payload.
*/
private fitHistoryPatches(patches: DocumentOp[]): {
private fitHistoryPatches(
patches: DocumentOp[],
preparation: TransactionPreparation,
): {
patches: DocumentOp[];
admitted?: AdmittedTransactionResources;
} {
Expand All @@ -881,7 +887,9 @@ export class DocumentController {
const compact = patches.map((patch) => {
if (patch.op !== "setBlock") return patch;
try {
return encodeRestoreBlock(patch.range, patch.block);
const operation = encodeRestoreBlock(patch.range, patch.block);
preparation.seed(operation, patch.block);
return operation;
} catch (error) {
// Above the restore block caps the block stays a candidate for splitting.
if (!(error instanceof RangeError)) throw error;
Expand Down
23 changes: 17 additions & 6 deletions packages/core/src/document-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
MAX_HYPERLINK_DISPLAY_LENGTH,
MAX_HYPERLINKS_PER_SHEET,
} from "./hyperlink.js";
import { decodeRestoreBlockPayload } from "./restore-block-codec.js";
import { preparedRestore } from "./transaction-preparation.js";
import type { MergeRange, Range } from "./types/coordinates.js";
import type {
DocumentOp,
Expand Down Expand Up @@ -1996,15 +1996,26 @@ export function validateDocumentOperationShape(
}
if (errors.length) break;
try {
const block = decodeRestoreBlockPayload(
const prepared = preparedRestore(
operation as unknown as Extract<DocumentOp, { op: "restoreBlock" }>,
validateDocumentOperationShape,
);
errors.push(
...validateDocumentOperationShape(
{ op: "setBlock", range: ownValue(operation, "range"), block },
path,
),
...prepared.errors.map((error) => ({
...error,
path: path + error.path.slice("operation".length),
})),
);
if (prepared.errors.length === 0) {
const range = operation.range as Range;
if (
prepared.block.rowCount !== Math.abs(range.end.row - range.start.row) + 1 ||
prepared.block.colCount !== Math.abs(range.end.col - range.start.col) + 1 ||
(prepared.block.styleIds !== undefined && prepared.block.styleTable === undefined)
) {
invalid(errors, path, "Invalid restore block");
}
}
} catch (error) {
invalid(errors, path, error instanceof Error ? error.message : "Invalid restore block");
}
Expand Down
Loading
Loading