Skip to content

Commit 49bdf00

Browse files
authored
Merge pull request #3185 from perspective-dev/opfs
Add `page_to_disk` option, disk-backed columns via OPFS, `mmap` or `node:fs`.
2 parents 305a0ac + f7fd382 commit 49bdf00

47 files changed

Lines changed: 1930 additions & 88 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,4 @@ docs/static/viewer
6868
docs/static/react
6969
rust/perspective-server/build
7070
target/
71+
dist-gh-pages

pnpm-lock.yaml

Lines changed: 41 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rust/perspective-client/perspective.proto

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,11 @@ message MakeTableReq {
334334
string make_index_table = 1;
335335
uint32 make_limit_table = 2;
336336
};
337+
338+
// Back this Table's canonical data with the on-disk storage backend
339+
// (memory-mapped file on native; OPFS on WASM) instead of memory.
340+
// Orthogonal to `make_table_type`, so it is a standalone field.
341+
optional bool page_to_disk = 3;
337342
}
338343
}
339344
message MakeTableResp {}

rust/perspective-client/src/rust/client.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,7 @@ impl Client {
618618
ClientResp::MakeJoinTableResp(_) => Ok(Table::new(entity_id, client, TableOptions {
619619
index: Some(on.to_owned()),
620620
limit: None,
621+
page_to_disk: None,
621622
})),
622623
resp => Err(resp.into()),
623624
}
@@ -662,6 +663,9 @@ impl Client {
662663
let options = TableOptions {
663664
index: info.index,
664665
limit: info.limit,
666+
// `page_to_disk` is a server-side property not surfaced in table
667+
// info; it does not affect client-side behavior.
668+
page_to_disk: None,
665669
};
666670

667671
let client = self.clone();

rust/perspective-client/src/rust/table.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,13 @@ pub struct TableInitOptions {
9292
#[serde(default)]
9393
#[ts(optional)]
9494
pub limit: Option<u32>,
95+
96+
/// Back this [`Table`]'s canonical data with the on-disk storage backend
97+
/// instead of memory. On native targets this is a memory-mapped file; on
98+
/// WASM it is OPFS (Worker only). Defaults to in-memory.
99+
#[serde(default)]
100+
#[ts(optional)]
101+
pub page_to_disk: Option<bool>,
95102
}
96103

97104
impl TableInitOptions {
@@ -104,11 +111,14 @@ impl TryFrom<TableOptions> for MakeTableOptions {
104111
type Error = ClientError;
105112

106113
fn try_from(value: TableOptions) -> Result<Self, Self::Error> {
114+
let page_to_disk = value.page_to_disk;
107115
Ok(MakeTableOptions {
116+
page_to_disk,
108117
make_table_type: match value {
109118
TableOptions {
110119
index: Some(_),
111120
limit: Some(_),
121+
..
112122
} => Err(ClientError::BadTableOptions)?,
113123
TableOptions {
114124
index: Some(index), ..
@@ -126,13 +136,15 @@ impl TryFrom<TableOptions> for MakeTableOptions {
126136
pub(crate) struct TableOptions {
127137
pub index: Option<String>,
128138
pub limit: Option<u32>,
139+
pub page_to_disk: Option<bool>,
129140
}
130141

131142
impl From<TableInitOptions> for TableOptions {
132143
fn from(value: TableInitOptions) -> Self {
133144
TableOptions {
134145
index: value.index,
135146
limit: value.limit,
147+
page_to_disk: value.page_to_disk,
136148
}
137149
}
138150
}

rust/perspective-js/src/ts/perspective.node.ts

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,16 @@ export type * from "./virtual_server.ts";
1616
import WebSocket, { WebSocketServer as HttpWebSocketServer } from "ws";
1717
import stoppable from "stoppable";
1818
import { promises as fs } from "node:fs";
19+
import {
20+
openSync,
21+
readSync,
22+
writeSync,
23+
closeSync,
24+
mkdirSync,
25+
unlinkSync,
26+
rmSync,
27+
} from "node:fs";
28+
import os from "node:os";
1929
import http from "node:http";
2030
import path from "node:path";
2131
import { webcrypto } from "node:crypto";
@@ -56,12 +66,123 @@ const uncompressed_client_wasm = await fs
5666
.then((buffer) => load_wasm_stage_0(buffer.buffer as ArrayBuffer));
5767

5868
await perspective_client.default({ module_or_path: uncompressed_client_wasm });
69+
70+
function make_node_disk_bridge({
71+
heap,
72+
toAddr,
73+
readCString,
74+
}: {
75+
heap: () => Uint8Array;
76+
toAddr: (p: number | bigint) => number;
77+
readCString: (p: number | bigint) => string;
78+
}) {
79+
const root = path.join(os.tmpdir(), `perspective-${process.pid}`);
80+
const resolve_path = (name: string) => {
81+
const p = path.join(root, name);
82+
if (!p.startsWith(root)) {
83+
throw new Error(`refusing disk path outside root: ${name}`);
84+
}
85+
return p;
86+
};
87+
88+
let cleaned = false;
89+
const cleanup = () => {
90+
if (cleaned) return;
91+
cleaned = true;
92+
try {
93+
rmSync(root, { recursive: true, force: true });
94+
} catch (e) {
95+
/* best effort */
96+
}
97+
};
98+
process.on("exit", cleanup);
99+
process.on("SIGINT", () => {
100+
cleanup();
101+
process.exit(130);
102+
});
103+
104+
return {
105+
// node:fs is synchronous — nothing to pre-open between safepoint phases.
106+
async ensureOpen(_name: string) {},
107+
store(
108+
namePtr: number | bigint,
109+
dataPtr: number | bigint,
110+
len: number,
111+
): number {
112+
try {
113+
const p = resolve_path(readCString(namePtr));
114+
mkdirSync(path.dirname(p), { recursive: true });
115+
const fd = openSync(p, "w");
116+
try {
117+
if (len > 0) {
118+
const addr = toAddr(dataPtr);
119+
const view = heap().subarray(addr, addr + len);
120+
let off = 0;
121+
while (off < len) {
122+
off += writeSync(fd, view, off, len - off, off);
123+
}
124+
}
125+
} finally {
126+
closeSync(fd);
127+
}
128+
return len;
129+
} catch (e) {
130+
console.error("node disk store failed", e);
131+
return -1;
132+
}
133+
},
134+
load(
135+
namePtr: number | bigint,
136+
dataPtr: number | bigint,
137+
len: number,
138+
): number {
139+
try {
140+
if (len <= 0) return 0;
141+
let fd: number;
142+
try {
143+
fd = openSync(resolve_path(readCString(namePtr)), "r");
144+
} catch (e) {
145+
return 0; // never-flushed file reads as zeros
146+
}
147+
try {
148+
const addr = toAddr(dataPtr);
149+
const view = heap().subarray(addr, addr + len);
150+
let off = 0;
151+
let n: number;
152+
while (
153+
off < len &&
154+
(n = readSync(fd, view, off, len - off, off)) > 0
155+
) {
156+
off += n;
157+
}
158+
return off;
159+
} finally {
160+
closeSync(fd);
161+
}
162+
} catch (e) {
163+
return 0;
164+
}
165+
},
166+
remove(namePtr: number | bigint) {
167+
try {
168+
unlinkSync(resolve_path(readCString(namePtr)));
169+
} catch (e) {
170+
/* already gone */
171+
}
172+
},
173+
};
174+
}
175+
59176
const SYNC_MODULE = await fs
60177
.readFile(
61178
resolve("@perspective-dev/server/dist/wasm/perspective-server.wasm"),
62179
)
63180
.then((buffer) => load_wasm_stage_0(buffer.buffer as ArrayBuffer))
64-
.then((buffer) => compile_perspective(buffer.buffer as ArrayBuffer));
181+
.then((buffer) =>
182+
compile_perspective(buffer.buffer as ArrayBuffer, {
183+
make_disk_bridge: make_node_disk_bridge,
184+
}),
185+
);
65186

66187
let SYNC_CLIENT: perspective_client.Client;
67188

rust/perspective-js/src/ts/wasm/emscripten_api.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,38 @@ import type * as perspective_server_t from "@perspective-dev/server/dist/wasm/pe
1717
export type PspPtr = BigInt | number;
1818
export type EmscriptenServer = bigint | number;
1919

20+
export interface DiskBridgeHelpers {
21+
heap: () => Uint8Array;
22+
toAddr: (p: number | bigint) => number;
23+
readCString: (p: number | bigint) => string;
24+
}
25+
26+
export interface CompileOptions {
27+
make_disk_bridge?: (helpers: DiskBridgeHelpers) => {
28+
store(
29+
namePtr: number | bigint,
30+
dataPtr: number | bigint,
31+
len: number,
32+
): number;
33+
load(
34+
namePtr: number | bigint,
35+
dataPtr: number | bigint,
36+
len: number,
37+
): number;
38+
remove(namePtr: number | bigint): void;
39+
ensureOpen(name: string): Promise<void>;
40+
};
41+
}
42+
2043
export async function compile_perspective(
2144
wasmBinary: ArrayBuffer,
45+
opts?: CompileOptions,
2246
): Promise<perspective_server_t.MainModule> {
2347
const module = await perspective_server.default({
2448
locateFile(x: any) {
2549
return x;
2650
},
51+
make_disk_bridge: opts?.make_disk_bridge,
2752
instantiateWasm: async (
2853
imports: any,
2954
receive: (_: WebAssembly.Instance) => void,

0 commit comments

Comments
 (0)