Tear out old code and turn on compaction

This commit is contained in:
2025-09-25 23:55:30 -07:00
parent c53f596d1d
commit b695437415
6 changed files with 63 additions and 124 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ function Layout(props: any) {
const navigate = useNavigate(); const navigate = useNavigate();
const [fileId, setFileId] = createSignal<string | null>(null); const [fileId, setFileId] = createSignal<string | null>(null);
const [numUpdates, setNumUpdates] = createSignal<number | null>(null); const [numUpdates, setNumUpdates] = createSignal<number | null>(null);
const docsMap = getDocsMap(defaultVault.doc); const docsMap = getDocsMap(defaultVault);
const title = () => { const title = () => {
const id = fileId(); const id = fileId();
+1 -1
View File
@@ -12,7 +12,7 @@ function Chooser() {
const { setFileId } = useNavbar(); const { setFileId } = useNavbar();
onMount(() => setFileId(null)); onMount(() => setFileId(null));
const docsMap = getDocsMap(defaultVault.doc); const docsMap = getDocsMap(defaultVault);
const computeDocsList = () => { const computeDocsList = () => {
const result: DocInfo[] = []; const result: DocInfo[] = [];
+8 -2
View File
@@ -3,11 +3,17 @@ import { createSignal, For } from 'solid-js';
const [getDebugLogs, setDebugLogs] = createSignal<string[]>([]); const [getDebugLogs, setDebugLogs] = createSignal<string[]>([]);
export function debugLog(message: string, ...args: any[]) { export function debugLog(message: string, ...args: any[]) {
console.log(message, ...args);
const components = [message, ...args.map((x) => JSON.stringify(x))]; const components = [message, ...args.map((x) => JSON.stringify(x))];
const line = components.join(' '); const line = components.join(' ');
setDebugLogs([...getDebugLogs(), line]);
const allLines = [...getDebugLogs(), line];
if (allLines.length > 1000) {
allLines.splice(0, allLines.length - 1000);
}
setDebugLogs(allLines);
} }
export default function DebugView() { export default function DebugView() {
return <For each={getDebugLogs()}>{(logLine) => <p>{logLine}</p>}</For>; return <For each={getDebugLogs()}>{(logLine) => <div>{logLine}</div>}</For>;
} }
+12 -9
View File
@@ -13,10 +13,10 @@ import {
onMount, onMount,
Show, Show,
} from 'solid-js'; } from 'solid-js';
import { LocalDocument } from '../sync';
import { useNavbar } from './App'; import { useNavbar } from './App';
import { useParams } from '@solidjs/router'; import { useParams } from '@solidjs/router';
import _ from 'lodash'; import _ from 'lodash';
import { closeDoc, getDocStats, openDoc } from '../persistence';
function Editor() { function Editor() {
const { id: fileId } = useParams(); const { id: fileId } = useParams();
@@ -27,25 +27,25 @@ function Editor() {
let editorDiv; let editorDiv;
let editorView: EditorView; let editorView: EditorView;
const [syncedDoc] = createResource(async () => LocalDocument.load(fileId)); const [syncedDoc] = createResource(async () => openDoc(fileId));
createEffect(() => { createEffect(() => {
const doc = syncedDoc(); const doc = syncedDoc();
if (!doc) { if (!doc) {
return; return;
} }
const ydoc = doc.doc;
const getNumRecords = () => getDocStats(doc).numRecords;
const refreshNumUpdates = _.debounce(() => { const refreshNumUpdates = _.debounce(() => {
setNumUpdates(doc.numUpdates); setNumUpdates(getNumRecords());
}, 100); }, 100);
setNumUpdates(doc.numUpdates); setNumUpdates(getNumRecords());
doc.doc.on('update', () => { doc.on('update', () => {
setTimeout(refreshNumUpdates, 1500); setTimeout(refreshNumUpdates, 1500);
}); });
const state = EditorState.create({ const state = EditorState.create({
doc: ydoc.getText().toString(), doc: doc.getText().toString(),
extensions: [ extensions: [
EditorView.lineWrapping, EditorView.lineWrapping,
EditorView.contentAttributes.of({ EditorView.contentAttributes.of({
@@ -56,7 +56,7 @@ function Editor() {
keymap.of([...defaultKeymap, ...historyKeymap]), keymap.of([...defaultKeymap, ...historyKeymap]),
scrollPastEnd(), scrollPastEnd(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }), syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
yCollab(ydoc.getText(), null), yCollab(doc.getText(), null),
], ],
}); });
editorView = new EditorView({ state, parent: editorDiv }); editorView = new EditorView({ state, parent: editorDiv });
@@ -65,7 +65,10 @@ function Editor() {
onCleanup(() => { onCleanup(() => {
setNumUpdates(null); setNumUpdates(null);
editorView?.destroy(); editorView?.destroy();
syncedDoc()?.finish(); const doc = syncedDoc();
if (doc) {
closeDoc(doc);
}
}); });
return ( return (
+37 -36
View File
@@ -2,71 +2,65 @@ import * as Y from 'yjs';
import { docsDb, LOCAL_DELTA_STORE } from './sync'; import { docsDb, LOCAL_DELTA_STORE } from './sync';
import { assert } from './util'; import { assert } from './util';
import _ from 'lodash'; import _ from 'lodash';
import { debugLog } from './components/Debug';
const COMPACTION_THRESHOLD = 200; const COMPACTION_THRESHOLD = 50;
export async function openDoc(id: string): Promise<Y.Doc> { export async function openDoc(id: string): Promise<Y.Doc> {
// Return existing doc if we still have it open // Return existing doc if we still have it open
const previousState = idToState.get(id); const previousState = idToState.get(id);
if (previousState) { if (previousState) {
previousState.refCount += 1; previousState.refCount += 1;
return previousState.ydoc; return previousState.savedDoc.ydoc;
} }
const state = await initialLoad(id); const state: State = {
refCount: 1,
savedDoc: new SavedDoc(id),
};
idToState.set(id, state); idToState.set(id, state);
docToState.set(state.ydoc, state); docToId.set(state.savedDoc.ydoc, id);
await state.savedDoc.connect();
debugLog(`Opened doc ${id}`);
return state.ydoc; return state.savedDoc.ydoc;
}
export function getDocStats(doc: Y.Doc) {
const id = docToId.get(doc);
assert(id, 'Document is not already open');
const state = idToState.get(id)!;
return state.savedDoc.getInfo();
} }
export function closeDoc(doc: Y.Doc) { export function closeDoc(doc: Y.Doc) {
const state = docToState.get(doc); const id = docToId.get(doc);
assert(state, 'Document is not already open'); assert(id, 'Document is not already open');
const state = idToState.get(id)!;
state.refCount -= 1; state.refCount -= 1;
if (state.refCount <= 0) { if (state.refCount <= 0) {
idToState.delete(state.id); idToState.delete(id);
docToState.delete(doc); docToId.delete(doc);
doc.destroy(); state.savedDoc.flush().then(() => doc.destroy());
} }
debugLog(`Closed doc ${id} (refcount ${state.refCount})`);
} }
const docToId = new Map<Y.Doc, string>();
const idToState = new Map<string, State>(); const idToState = new Map<string, State>();
const docToState = new Map<Y.Doc, State>();
type State = { type State = {
// TODO: Move more of this into class
id: string;
deltaBuffer: Uint8Array[];
numRecords: number;
refCount: number; refCount: number;
ydoc: Y.Doc; savedDoc: SavedDoc;
}; };
async function initialLoad(id: string): Promise<State> { class SavedDoc {
const ydoc = new Y.Doc(); readonly docId: string;
const records = await docsDb.getAll( readonly ydoc: Y.Doc;
LOCAL_DELTA_STORE,
IDBKeyRange.bound([id, -Infinity], [id, Infinity])
);
Y.applyUpdate(ydoc, Y.mergeUpdates(records));
return {
id,
deltaBuffer: [],
numRecords: records.length,
refCount: 1,
ydoc,
};
}
// @ts-ignore
class BufferedWriter {
private deltaBuffer: Uint8Array[]; private deltaBuffer: Uint8Array[];
private docId: string;
private numRecords: number; private numRecords: number;
private debouncedFlush: () => void; private debouncedFlush: () => void;
private ydoc: Y.Doc;
constructor(docId: string) { constructor(docId: string) {
this.deltaBuffer = []; this.deltaBuffer = [];
@@ -82,6 +76,10 @@ class BufferedWriter {
this.ydoc = new Y.Doc(); this.ydoc = new Y.Doc();
} }
public getInfo(): { numRecords: number } {
return { numRecords: this.numRecords };
}
public async connect(): Promise<Y.Doc> { public async connect(): Promise<Y.Doc> {
const records = await docsDb.getAll( const records = await docsDb.getAll(
LOCAL_DELTA_STORE, LOCAL_DELTA_STORE,
@@ -129,6 +127,9 @@ class BufferedWriter {
if (this.numRecords < COMPACTION_THRESHOLD) { if (this.numRecords < COMPACTION_THRESHOLD) {
return; return;
} }
debugLog(
`Running compaction on doc ${this.docId}, ${this.numRecords} records...`
);
const tx = docsDb.transaction(LOCAL_DELTA_STORE, 'readwrite'); const tx = docsDb.transaction(LOCAL_DELTA_STORE, 'readwrite');
// Make sure we're not missing anything by pulling in all changes. // Make sure we're not missing anything by pulling in all changes.
+4 -75
View File
@@ -3,7 +3,7 @@ import * as Y from 'yjs';
import { openDB, type DBSchema } from 'idb'; import { openDB, type DBSchema } from 'idb';
import { assert, generateId } from './util'; import { assert, generateId } from './util';
import { getDocsMap, getVaultMap, type DocMap } from './vault'; import { getDocsMap, getVaultMap, type DocMap } from './vault';
import { debugLog } from './components/Debug'; import { openDoc } from './persistence';
const DB_NAME = 'synced-docs'; const DB_NAME = 'synced-docs';
export const LOCAL_DELTA_STORE = 'local-updates'; export const LOCAL_DELTA_STORE = 'local-updates';
@@ -35,76 +35,6 @@ export const docsDb = await openDB<DocsDatabase>(DB_NAME, 3, {
}, },
}); });
export class LocalDocument {
readonly id: string;
readonly doc: Y.Doc;
private updates: Uint8Array[];
private totalUpdates: number;
private constructor(id: string, ydoc: Y.Doc) {
this.id = id;
this.doc = ydoc;
this.updates = [];
this.totalUpdates = 0;
const flush = _.debounce(() => this.flush(), 1000);
this.doc.on('update', (update: Uint8Array) => {
this.updates.push(update);
flush();
});
}
get numUpdates(): number {
return this.totalUpdates;
}
public static async load(id: string): Promise<LocalDocument> {
// Build Y.Doc from records on disk
const startMs = performance.now();
const updates: Uint8Array[] = await docsDb.getAll(
LOCAL_DELTA_STORE,
IDBKeyRange.bound([id, -Infinity], [id, Infinity])
);
const loadedMs = performance.now();
const mergedUpdate = Y.mergeUpdates(updates);
const mergedMs = performance.now();
const ydoc = new Y.Doc();
Y.applyUpdate(ydoc, mergedUpdate);
const appliedMs = performance.now();
// Build LocalDocument instance
const result = new LocalDocument(id, ydoc);
result.totalUpdates = updates.length;
// Emit debug logs
const timings = [loadedMs - startMs, mergedMs - loadedMs, appliedMs - mergedMs];
const sizes = _.sortBy(updates.map((x) => x.length));
const quartiles = _.range(5).map((x) => {
const i = Math.round((sizes.length - 1) * (x / 4));
return sizes[i];
});
debugLog(`Doc ${id}: load/merge/apply timings in ms`, timings);
debugLog(`${updates.length} updates, quartiles in bytes`, quartiles);
return result;
}
public async flush(): Promise<void> {
const batch = this.updates;
this.updates = [];
if (batch.length > 0) {
const update = Y.mergeUpdates(batch);
const key: LocalDeltaKey = [this.id, new Date().valueOf()];
await docsDb.add(LOCAL_DELTA_STORE, update, key);
this.totalUpdates += 1;
}
}
public async finish(): Promise<void> {
await this.flush();
this.doc.destroy();
}
}
async function getOrCreateVaultDoc() { async function getOrCreateVaultDoc() {
const records = await docsDb.getAll(VAULT_STORE); const records = await docsDb.getAll(VAULT_STORE);
if (records.length > 1) { if (records.length > 1) {
@@ -120,8 +50,8 @@ async function getOrCreateVaultDoc() {
if (!id) { if (!id) {
throw new Error("Vault record didn't have id"); throw new Error("Vault record didn't have id");
} }
const vault = await LocalDocument.load(id); const vault = await openDoc(id);
const vaultMap = getVaultMap(vault.doc); const vaultMap = getVaultMap(vault);
if (!vaultMap.has('id')) { if (!vaultMap.has('id')) {
vaultMap.set('id', id); vaultMap.set('id', id);
} }
@@ -141,9 +71,8 @@ async function getOrCreateVaultDoc() {
} }
export const defaultVault = await getOrCreateVaultDoc(); export const defaultVault = await getOrCreateVaultDoc();
// console.log(defaultVault.doc.getMap().toJSON())
const docsMap = getDocsMap(defaultVault.doc); const docsMap = getDocsMap(defaultVault);
if (docsMap.size === 0) { if (docsMap.size === 0) {
console.log('Running vault migration...'); console.log('Running vault migration...');
for (const oldKey of ['foo', 'bar', 'baz']) { for (const oldKey of ['foo', 'bar', 'baz']) {