feat(T-1.2): sequence counter + disk ring-buffer writer/reader
This commit is contained in:
parent
bd990a07ab
commit
17c32e7e93
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* Disk ring-buffer reader.
|
||||
*
|
||||
* Reads chunks from a session buffer file, optionally starting from a
|
||||
* given seq number. Used by the stream route for reconnect replay (T-1.5).
|
||||
*
|
||||
* File format (mirrors writer.ts):
|
||||
* Each record: [seq: 8 bytes BE uint64] [length: 4 bytes BE uint32] [data: N bytes]
|
||||
*
|
||||
* Owner: T-1.2
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export interface BufferChunk {
|
||||
seq: number;
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
export interface ReaderConfig {
|
||||
stateDir?: string;
|
||||
}
|
||||
|
||||
function stateDir(cfg?: ReaderConfig): string {
|
||||
return (
|
||||
cfg?.stateDir ?? path.join(os.homedir(), ".local", "share", "pi-remote")
|
||||
);
|
||||
}
|
||||
|
||||
function bufferPath(session: string, cfg?: ReaderConfig): string {
|
||||
return path.join(stateDir(cfg), "buffers", `${session}.buf`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all chunks from a session buffer, optionally starting after `afterSeq`.
|
||||
*
|
||||
* Returns chunks in seq order. If the file doesn't exist, returns [].
|
||||
* Stops at the first parse error (truncated file at end is tolerated).
|
||||
*/
|
||||
export function readChunks(
|
||||
session: string,
|
||||
opts: { afterSeq?: number; cfg?: ReaderConfig } = {},
|
||||
): BufferChunk[] {
|
||||
const { afterSeq = 0, cfg } = opts;
|
||||
const fp = bufferPath(session, cfg);
|
||||
|
||||
let buf: Buffer;
|
||||
try {
|
||||
buf = fs.readFileSync(fp);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const chunks: BufferChunk[] = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset + 12 <= buf.length) {
|
||||
const seqBig = buf.readBigUInt64BE(offset);
|
||||
const seq = Number(seqBig);
|
||||
const length = buf.readUInt32BE(offset + 8);
|
||||
offset += 12;
|
||||
|
||||
if (offset + length > buf.length) break; // truncated record at end
|
||||
|
||||
if (seq > afterSeq) {
|
||||
chunks.push({ seq, data: buf.slice(offset, offset + length) });
|
||||
}
|
||||
offset += length;
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read chunks as an async generator (streaming, for large buffers).
|
||||
* Yields one chunk at a time after `afterSeq`.
|
||||
*/
|
||||
export async function* streamChunks(
|
||||
session: string,
|
||||
opts: { afterSeq?: number; cfg?: ReaderConfig } = {},
|
||||
): AsyncGenerator<BufferChunk> {
|
||||
// Simple implementation: read all and yield. For large files T-1.5 can
|
||||
// switch to a streaming file read if needed.
|
||||
const { afterSeq = 0, cfg } = opts;
|
||||
const chunks = readChunks(session, { afterSeq, cfg });
|
||||
for (const chunk of chunks) {
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
/**
|
||||
* Disk ring-buffer writer.
|
||||
*
|
||||
* Appends chunks to a per-session file, enforcing:
|
||||
* - Per-session cap: 100 MB (configurable)
|
||||
* - Global cap: 1 GB across all sessions (configurable)
|
||||
* - Free-space watchdog: refuse writes if free disk < 1 GB
|
||||
* - Idle cleanup: sessions inactive for > 30 days are deleted
|
||||
*
|
||||
* File format (binary, append-only):
|
||||
* Each record: [seq: 8 bytes BE uint64] [length: 4 bytes BE uint32] [data: N bytes]
|
||||
*
|
||||
* Risk R1 mitigation: all writes serialised through a per-session async queue.
|
||||
* Global cap protected by a module-level mutex (simple flag since JS is single-threaded).
|
||||
*
|
||||
* Owner: T-1.2
|
||||
*/
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { SeqNum } from "../sequence.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config defaults (can be overridden; T-1.7 will wire these from config.toml)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BufferConfig {
|
||||
stateDir: string;
|
||||
perSessionMb: number; // default 100
|
||||
globalGb: number; // default 1
|
||||
freeMinGb: number; // default 1
|
||||
idleDays: number; // default 30
|
||||
}
|
||||
|
||||
function defaultConfig(): BufferConfig {
|
||||
return {
|
||||
stateDir: path.join(os.homedir(), ".local", "share", "pi-remote"),
|
||||
perSessionMb: 100,
|
||||
globalGb: 1,
|
||||
freeMinGb: 1,
|
||||
idleDays: 30,
|
||||
};
|
||||
}
|
||||
|
||||
let _config: BufferConfig = defaultConfig();
|
||||
|
||||
export function configureBuffer(cfg: Partial<BufferConfig>): void {
|
||||
_config = { ..._config, ...cfg };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global cap mutex (JS single-threaded so a flag suffices)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _globalBusy = false;
|
||||
let _globalBytes = 0; // tracked in-memory; recalculated on startup
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-session writer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class BufferWriter {
|
||||
readonly session: string;
|
||||
private filePath: string;
|
||||
private queue: Promise<void> = Promise.resolve();
|
||||
private sessionBytes = 0;
|
||||
private lastWriteAt = Date.now();
|
||||
|
||||
constructor(session: string) {
|
||||
this.session = session;
|
||||
this.filePath = path.join(_config.stateDir, "buffers", `${session}.buf`);
|
||||
}
|
||||
|
||||
async open(): Promise<void> {
|
||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||
// Load existing size for cap tracking
|
||||
try {
|
||||
const stat = await fs.stat(this.filePath);
|
||||
this.sessionBytes = stat.size;
|
||||
_globalBytes += stat.size;
|
||||
} catch {
|
||||
this.sessionBytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a chunk write. Writes are serialised per session.
|
||||
*/
|
||||
write(seq: SeqNum, data: Buffer): void {
|
||||
this.queue = this.queue.then(() => this._write(seq, data));
|
||||
}
|
||||
|
||||
private async _write(seq: SeqNum, data: Buffer): Promise<void> {
|
||||
const perSessionCap = _config.perSessionMb * 1024 * 1024;
|
||||
const globalCap = _config.globalGb * 1024 * 1024 * 1024;
|
||||
|
||||
// Free-space watchdog
|
||||
try {
|
||||
const { available } = await checkFreeSpace(path.dirname(this.filePath));
|
||||
const freeMin = _config.freeMinGb * 1024 * 1024 * 1024;
|
||||
if (available < freeMin) return; // silently drop; could emit a warning
|
||||
} catch {
|
||||
// If we can't check, don't block writes
|
||||
}
|
||||
|
||||
// Cap enforcement
|
||||
const recordSize = 8 + 4 + data.length;
|
||||
if (
|
||||
_globalBusy ||
|
||||
this.sessionBytes + recordSize > perSessionCap ||
|
||||
_globalBytes + recordSize > globalCap
|
||||
) {
|
||||
return; // drop oldest strategy: just don't write (ring via truncation not implemented yet)
|
||||
}
|
||||
|
||||
_globalBusy = true;
|
||||
try {
|
||||
const header = Buffer.allocUnsafe(12);
|
||||
header.writeBigUInt64BE(BigInt(seq), 0);
|
||||
header.writeUInt32BE(data.length, 8);
|
||||
|
||||
await fs.appendFile(this.filePath, Buffer.concat([header, data]));
|
||||
this.sessionBytes += recordSize;
|
||||
_globalBytes += recordSize;
|
||||
this.lastWriteAt = Date.now();
|
||||
} finally {
|
||||
_globalBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.queue; // drain
|
||||
}
|
||||
|
||||
/** Delete the buffer file and reclaim global tracking bytes. */
|
||||
async delete(): Promise<void> {
|
||||
await this.queue;
|
||||
try {
|
||||
await fs.unlink(this.filePath);
|
||||
_globalBytes = Math.max(0, _globalBytes - this.sessionBytes);
|
||||
this.sessionBytes = 0;
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
|
||||
get idleMs(): number {
|
||||
return Date.now() - this.lastWriteAt;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Idle cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Delete buffer files for sessions idle longer than idleDays.
|
||||
* Safe to call periodically (e.g. on startup or daily timer).
|
||||
*/
|
||||
export async function cleanupIdleBuffers(
|
||||
cfg: BufferConfig = _config,
|
||||
): Promise<string[]> {
|
||||
const dir = path.join(cfg.stateDir, "buffers");
|
||||
const maxIdleMs = cfg.idleDays * 24 * 60 * 60 * 1000;
|
||||
const deleted: string[] = [];
|
||||
|
||||
let entries: fs.Dirent[] = [];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return deleted;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.name.endsWith(".buf")) continue;
|
||||
const fp = path.join(dir, entry.name);
|
||||
try {
|
||||
const stat = await fs.stat(fp);
|
||||
if (Date.now() - stat.mtimeMs > maxIdleMs) {
|
||||
await fs.unlink(fp);
|
||||
deleted.push(entry.name.replace(/\.buf$/, ""));
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Approximate free disk space on the filesystem containing `dir`. */
|
||||
async function checkFreeSpace(dir: string): Promise<{ available: number }> {
|
||||
// Node doesn't expose statvfs directly; use df -k as a fallback.
|
||||
// If it fails, caller ignores the error.
|
||||
const { execFile } = await import("node:child_process");
|
||||
const { promisify } = await import("node:util");
|
||||
const exec = promisify(execFile);
|
||||
const { stdout } = await exec("df", ["-k", dir]);
|
||||
const lines = stdout.trim().split("\n");
|
||||
const last = lines[lines.length - 1];
|
||||
const parts = last.split(/\s+/);
|
||||
// df -k columns: Filesystem 1K-blocks Used Available Use% Mounted
|
||||
const availKb = parseInt(parts[3], 10);
|
||||
return { available: availKb * 1024 };
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* Monotonic sequence number generator — shared by stream + buffer.
|
||||
*
|
||||
* Each chunk of output gets a unique, monotonically increasing seq number.
|
||||
* This lets clients resume a stream from a known position (IC-1 `lastSeq`).
|
||||
*
|
||||
* Owner: T-1.2
|
||||
*/
|
||||
|
||||
export type SeqNum = number; // safe JS integer, starts at 1
|
||||
|
||||
/**
|
||||
* Per-session sequence counter.
|
||||
* Create one instance per session; share between the buffer writer and the
|
||||
* WebSocket broadcaster.
|
||||
*/
|
||||
export class SequenceCounter {
|
||||
private current: SeqNum = 0;
|
||||
|
||||
/** Increment and return the next seq number. */
|
||||
next(): SeqNum {
|
||||
this.current += 1;
|
||||
return this.current;
|
||||
}
|
||||
|
||||
/** Current value without incrementing. */
|
||||
peek(): SeqNum {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
/** Reset (e.g. after session restart). */
|
||||
reset(): void {
|
||||
this.current = 0;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue