perf(session-persistence): prepare historical reads before publication
This commit is contained in:
1 parent
98d2baf2e5
commit
a3e5edbdbf
11 files changed
+1082
-557
No files matched your search
@@ -173,7 +173,7 @@ class SessionOpenBenchmarkSuite {
|
||||
this.legacySourcePath = this.facts.path
|
||||
// Produce one real post-upgrade directory outside every measured interval.
|
||||
const templateRoot = await this.createRoot('first-open', 'post-upgrade-template')
|
||||
requireReport(await runWorker(templateRoot, 'phase-migrate'), 'phase-migrate')
|
||||
requireReport(await runWorker(templateRoot, 'agent-resume'), 'agent-resume')
|
||||
this.currentSourcePath = join(
|
||||
templateRoot,
|
||||
SYNTHETIC_SESSION_DIRECTORY,
|
||||
|
||||
@@ -22,8 +22,11 @@ import { basename, dirname, join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { pipeline, Readable } from 'node:stream'
|
||||
import { scheduler } from 'node:timers/promises'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { constants, createZstdCompress } from 'node:zlib'
|
||||
import { Session } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { BlockAssembler, expandAssistantStream } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
SessionFormatArtifact,
|
||||
SessionFormatJsonValue,
|
||||
@@ -62,8 +65,8 @@ export interface JsonlGenerationFormatAdapter {
|
||||
isUnsupportedMigrationError?(error: unknown): error is Error
|
||||
}
|
||||
|
||||
/** Inputs for ensuring one already-resolved generation has a current successor. */
|
||||
export interface EnsureJsonlGenerationOptions {
|
||||
/** Inputs for preparing one historical generation and publishing its current successor later. */
|
||||
export interface PrepareJsonlMigrationOptions {
|
||||
/** Immutable generation selected by the backend resolver. */
|
||||
readonly sourcePath: string
|
||||
/** Version selected from the source filename and independently checked against its header. */
|
||||
@@ -101,36 +104,24 @@ export interface JsonlExpectedPrefix {
|
||||
readonly digest: string
|
||||
}
|
||||
|
||||
/** Result of current classification or exclusive publication. */
|
||||
export type EnsureJsonlGenerationResult =
|
||||
| {
|
||||
readonly status: 'current'
|
||||
readonly version: number
|
||||
readonly path: string
|
||||
readonly snapshot: JsonlPhysicalSnapshot
|
||||
}
|
||||
| {
|
||||
readonly status: 'migrated'
|
||||
readonly fromVersion: number
|
||||
readonly toVersion: number
|
||||
readonly path: string
|
||||
readonly sourcePath: string
|
||||
readonly snapshot: JsonlPhysicalSnapshot
|
||||
}
|
||||
/** A historical source changed after its single decode and migration pass. */
|
||||
export class JsonlGenerationSourceChangedError extends Error {
|
||||
override readonly name = 'JsonlGenerationSourceChangedError'
|
||||
|
||||
/** A future physical header was readable, but this writer cannot interpret it. */
|
||||
export class JsonlGenerationNewerVersionError extends Error {
|
||||
override readonly name = 'JsonlGenerationNewerVersionError'
|
||||
|
||||
constructor(
|
||||
readonly storedVersion: number,
|
||||
readonly currentVersion: number,
|
||||
readonly storedId: string,
|
||||
) {
|
||||
super(`session log format v${storedVersion} is newer than current v${currentVersion}`)
|
||||
/** @param path - historical generation whose revision changed. */
|
||||
constructor(readonly path: string) {
|
||||
super(`historical session generation changed during migration: "${path}"`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Current logical state prepared independently from durable publication. */
|
||||
export interface PreparedJsonlMigration {
|
||||
readonly sourceIdentity: JsonlPhysicalIdentity
|
||||
readonly artifact: SessionFormatArtifact
|
||||
/** Encode, verify, and exclusively publish once; every call shares the same success or failure. */
|
||||
publish(): Promise<JsonlPhysicalIdentity>
|
||||
}
|
||||
|
||||
/** A historical artifact is intact, but the format edge refuses its contents. */
|
||||
export class JsonlGenerationUnsupportedMigrationError extends Error {
|
||||
override readonly name = 'JsonlGenerationUnsupportedMigrationError'
|
||||
@@ -172,23 +163,12 @@ export interface JsonlPhysicalIdentity {
|
||||
readonly ctimeNs: bigint
|
||||
}
|
||||
|
||||
/** One revision-stable physical artifact returned to the immediate backend decoder. */
|
||||
export interface JsonlPhysicalSnapshot extends StablePhysicalFile {
|
||||
readonly headerValue: Record<string, unknown>
|
||||
readonly headerRecord: Buffer
|
||||
}
|
||||
|
||||
/** Exact bytes of one stable file revision together with the stat identity that proved it stable. */
|
||||
export interface StablePhysicalFile {
|
||||
readonly bytes: Buffer
|
||||
readonly identity: JsonlPhysicalIdentity
|
||||
}
|
||||
|
||||
interface JsonlPhysicalHeader {
|
||||
readonly value: Record<string, unknown>
|
||||
readonly record: Buffer
|
||||
}
|
||||
|
||||
interface GenerationFileSystem {
|
||||
open(path: string, flags: string, mode?: number): Promise<FileHandle>
|
||||
readFile(path: string, signal?: AbortSignal): Promise<Buffer>
|
||||
@@ -219,7 +199,7 @@ export type JsonlGenerationRuntimeOverrides = Partial<Omit<JsonlGenerationIntern
|
||||
/** Bound generation operations used by production defaults and deterministic tests. */
|
||||
export interface JsonlGenerationRuntime {
|
||||
readStable(path: string, signal?: AbortSignal): Promise<StablePhysicalFile>
|
||||
ensure(options: EnsureJsonlGenerationOptions): Promise<EnsureJsonlGenerationResult>
|
||||
prepare(options: PrepareJsonlMigrationOptions): Promise<PreparedJsonlMigration>
|
||||
verify(
|
||||
path: string,
|
||||
compression: JsonlCompression,
|
||||
@@ -260,10 +240,6 @@ function identity(value: JsonlPhysicalIdentity): string {
|
||||
return [value.dev, value.ino, value.size, value.mtimeNs, value.ctimeNs].join(':')
|
||||
}
|
||||
|
||||
function fingerprint(value: JsonlPhysicalIdentity, bytes: Buffer): string {
|
||||
return `${identity(value)}:${createHash('sha256').update(bytes).digest('hex')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one stable revision of a JSONL file with a single retry. If an append
|
||||
* overlaps both reads, return the second read's committed pre-read prefix
|
||||
@@ -313,10 +289,6 @@ function storedVersion(header: unknown): number {
|
||||
return version as number
|
||||
}
|
||||
|
||||
function storedId(header: unknown): string {
|
||||
return String((header as { id?: unknown }).id)
|
||||
}
|
||||
|
||||
function parseJson(text: string, subject: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
@@ -398,10 +370,15 @@ interface StartedMigrationStream {
|
||||
|
||||
async function startMigrationStream(
|
||||
headerRecord: Buffer,
|
||||
sourceVersion: number,
|
||||
format: JsonlGenerationFormatAdapter,
|
||||
validateHistoricalHeader?: EnsureJsonlGenerationOptions['validateHistoricalHeader'],
|
||||
validateHistoricalHeader?: PrepareJsonlMigrationOptions['validateHistoricalHeader'],
|
||||
): Promise<StartedMigrationStream> {
|
||||
const value = parseJson(headerRecord.subarray(0, -1).toString('utf8'), 'header line')
|
||||
const version = storedVersion(value)
|
||||
if (version !== sourceVersion) {
|
||||
throw new Error(`resolved JSONL source filename identifies v${sourceVersion}, but its header identifies v${version}`)
|
||||
}
|
||||
const header = value as Record<string, unknown>
|
||||
const validation = validateHistoricalHeader?.(header)
|
||||
if (validation !== undefined) await validation
|
||||
@@ -430,17 +407,18 @@ async function consumeMigrationBytes(
|
||||
async function decodeStreamingMigration(
|
||||
bytes: Buffer,
|
||||
compression: JsonlCompression,
|
||||
sourceVersion: number,
|
||||
format: JsonlGenerationFormatAdapter,
|
||||
validateHistoricalHeader: EnsureJsonlGenerationOptions['validateHistoricalHeader'],
|
||||
validateHistoricalHeader: PrepareJsonlMigrationOptions['validateHistoricalHeader'],
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionFormatArtifact> {
|
||||
signal?.throwIfAborted()
|
||||
if (compression === 'none') {
|
||||
const headerEnd = bytes.indexOf(0x0A)
|
||||
/* v8 ignore next -- ensureCurrent's physical-header preflight already requires this newline. */
|
||||
if (headerEnd === -1) throw new Error('empty or header-less session log')
|
||||
const stream = await startMigrationStream(
|
||||
bytes.subarray(0, headerEnd + 1),
|
||||
sourceVersion,
|
||||
format,
|
||||
validateHistoricalHeader,
|
||||
)
|
||||
@@ -457,7 +435,6 @@ async function decodeStreamingMigration(
|
||||
}
|
||||
|
||||
const { frames, tornStart } = scanZstdFrames(bytes)
|
||||
/* v8 ignore next -- ensureCurrent's physical-header preflight already requires a complete header frame. */
|
||||
if (frames.length === 0) throw new Error('empty or header-less Zstandard session log')
|
||||
const decoder = createZstdFrameDecoder()
|
||||
try {
|
||||
@@ -468,6 +445,7 @@ async function decodeStreamingMigration(
|
||||
assertIndependentHeaderFrame(first.value)
|
||||
const stream = await startMigrationStream(
|
||||
first.value,
|
||||
sourceVersion,
|
||||
format,
|
||||
validateHistoricalHeader,
|
||||
)
|
||||
@@ -557,7 +535,9 @@ async function verifyCurrentGeneration(
|
||||
generation.events,
|
||||
generation.meta,
|
||||
generation.inheritedEventCount,
|
||||
'detached',
|
||||
)
|
||||
assertCurrentAssistantStreams(generation.events)
|
||||
return {
|
||||
identity: snapshot.identity,
|
||||
bytes: snapshot.bytes.length,
|
||||
@@ -565,6 +545,32 @@ async function verifyCurrentGeneration(
|
||||
}
|
||||
}
|
||||
|
||||
/** Fully replay embedded streams only inside isolated current-generation verification. */
|
||||
function assertCurrentAssistantStreams(events: readonly SessionEvent[]): void {
|
||||
for (const [index, event] of events.entries()) {
|
||||
if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt') continue
|
||||
const assembler = new BlockAssembler()
|
||||
let timed: ReturnType<typeof expandAssistantStream>
|
||||
try {
|
||||
timed = expandAssistantStream(event.data.stream)
|
||||
for (const member of timed) assembler.push(member.chunk)
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`seed ${event.type} at index ${index} has an invalid embedded stream`, { cause: error })
|
||||
}
|
||||
if (event.type === 'assistant/attempt' || timed.length === 0) continue
|
||||
const content = event.data.interrupted === true ? assembler.interruptedBlocks() : assembler.blocks()
|
||||
if (!isDeepStrictEqual(event.data.message.content, content)) {
|
||||
throw new Error(`seed assistant/message at index ${index} content disagrees with its embedded stream`)
|
||||
}
|
||||
if (!isDeepStrictEqual(event.data.usage, assembler.usage)) {
|
||||
throw new Error(`seed assistant/message at index ${index} usage disagrees with its embedded stream`)
|
||||
}
|
||||
if (!isDeepStrictEqual(event.data.message.source.replayState, assembler.replayState)) {
|
||||
throw new Error(`seed assistant/message at index ${index} replay state disagrees with its embedded stream`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function decodeCurrentGeneration(
|
||||
bytes: Buffer,
|
||||
compression: JsonlCompression,
|
||||
@@ -620,45 +626,6 @@ function assertIndependentHeaderFrame(plaintext: Buffer): void {
|
||||
}
|
||||
}
|
||||
|
||||
function readRawHeader(bytes: Buffer): JsonlPhysicalHeader {
|
||||
const newline = bytes.indexOf(0x0A)
|
||||
if (newline === -1) throw new Error('empty or header-less session log')
|
||||
const record = bytes.subarray(0, newline + 1)
|
||||
const value = parseJson(record.subarray(0, -1).toString('utf8'), 'header line')
|
||||
storedVersion(value)
|
||||
return { value: value as Record<string, unknown>, record }
|
||||
}
|
||||
|
||||
function readZstdHeader(bytes: Buffer, signal?: AbortSignal): JsonlPhysicalHeader {
|
||||
signal?.throwIfAborted()
|
||||
const first = scanZstdFrames(bytes, 1).frames[0]
|
||||
if (first === undefined) throw new Error('empty or header-less Zstandard session log')
|
||||
const decoder = createZstdFrameDecoder()
|
||||
const decodedFrames = decoder.decode(bytes, [first])
|
||||
try {
|
||||
const decoded = decodedFrames.next()
|
||||
/* v8 ignore next -- one complete frame yields once or the decoder throws. */
|
||||
if (decoded.done) throw new Error('empty or header-less Zstandard session log')
|
||||
signal?.throwIfAborted()
|
||||
assertIndependentHeaderFrame(decoded.value)
|
||||
const record = Buffer.from(decoded.value)
|
||||
const value = parseJson(record.subarray(0, -1).toString('utf8'), 'header line')
|
||||
storedVersion(value)
|
||||
return { value: value as Record<string, unknown>, record }
|
||||
} finally {
|
||||
decodedFrames.return()
|
||||
decoder.close()
|
||||
}
|
||||
}
|
||||
|
||||
function readPhysicalHeader(
|
||||
bytes: Buffer,
|
||||
compression: JsonlCompression,
|
||||
signal: AbortSignal | undefined,
|
||||
): JsonlPhysicalHeader {
|
||||
return compression === 'zstd' ? readZstdHeader(bytes, signal) : readRawHeader(bytes)
|
||||
}
|
||||
|
||||
function assertGenerationPaths(
|
||||
sourcePath: string,
|
||||
sourceVersion: number,
|
||||
@@ -823,7 +790,6 @@ async function removeTemporary(
|
||||
try {
|
||||
await internals.fs.rm(path)
|
||||
} catch (cleanupFailure: unknown) {
|
||||
if (primaryFailure === undefined) throw cleanupFailure
|
||||
throw new AggregateError(
|
||||
[primaryFailure, cleanupFailure],
|
||||
`failed to clean migration temporary "${path}" after an earlier failure`,
|
||||
@@ -879,19 +845,16 @@ function asError(error: unknown): Error {
|
||||
|
||||
async function inspectExpectedCurrent<T>(
|
||||
currentPath: string,
|
||||
checkCanonicalTargetName: boolean,
|
||||
internals: JsonlGenerationInternals,
|
||||
inspect: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
if (checkCanonicalTargetName) {
|
||||
const expectedName = basename(currentPath)
|
||||
const names = await internals.fs.readdir(dirname(currentPath))
|
||||
if (!names.includes(expectedName)) {
|
||||
const noncanonical = names.find(name => name.toLowerCase() === expectedName.toLowerCase())
|
||||
if (noncanonical !== undefined) {
|
||||
throw new Error(`target resolves to noncanonical directory entry "${noncanonical}"`)
|
||||
}
|
||||
const expectedName = basename(currentPath)
|
||||
const names = await internals.fs.readdir(dirname(currentPath))
|
||||
if (!names.includes(expectedName)) {
|
||||
const noncanonical = names.find(name => name.toLowerCase() === expectedName.toLowerCase())
|
||||
if (noncanonical !== undefined) {
|
||||
throw new Error(`target resolves to noncanonical directory entry "${noncanonical}"`)
|
||||
}
|
||||
}
|
||||
const info = await internals.fs.lstat(currentPath)
|
||||
@@ -913,39 +876,71 @@ function withOverrides(overrides: JsonlGenerationRuntimeOverrides): JsonlGenerat
|
||||
}
|
||||
}
|
||||
|
||||
async function reopenExpectedCurrent(
|
||||
currentPath: string,
|
||||
staged: StreamedMigrationStage,
|
||||
compression: JsonlCompression,
|
||||
expectedId: string,
|
||||
expectedEventCount: number,
|
||||
verifyCurrentFile: EnsureJsonlGenerationOptions['verifyCurrentFile'],
|
||||
signal: AbortSignal | undefined,
|
||||
checkCanonicalTargetName: boolean,
|
||||
async function publishPreparedMigration(
|
||||
options: PrepareJsonlMigrationOptions,
|
||||
suffix: string,
|
||||
artifact: SessionFormatArtifact,
|
||||
sourceIdentity: JsonlPhysicalIdentity,
|
||||
internals: JsonlGenerationInternals,
|
||||
): Promise<JsonlPhysicalSnapshot> {
|
||||
return inspectExpectedCurrent(currentPath, checkCanonicalTargetName, internals, async () => {
|
||||
const verified = await verifyCurrentFile(
|
||||
currentPath,
|
||||
): Promise<JsonlPhysicalIdentity> {
|
||||
await scheduler.yield()
|
||||
const { sourcePath, currentPath, compression, verifyCurrentFile } = options
|
||||
const eventCount = artifact.events.length
|
||||
let staged = await writeSyncedTemp(currentPath, suffix, compression, artifact, options.format, undefined, internals)
|
||||
try {
|
||||
const verifiedStage = await verifyCurrentFile(
|
||||
staged.path,
|
||||
compression,
|
||||
expectedId,
|
||||
expectedEventCount,
|
||||
staged,
|
||||
signal,
|
||||
artifact.header.id,
|
||||
eventCount,
|
||||
)
|
||||
if (verified.bytes !== staged.bytes || verified.digest !== staged.digest) {
|
||||
throw new Error('target bytes differ from the migrated generation')
|
||||
if (verifiedStage.bytes !== staged.bytes || verifiedStage.digest !== staged.digest) {
|
||||
throw new Error('staged session generation changed during verification')
|
||||
}
|
||||
const snapshot = await readStableSnapshot(currentPath, signal, internals.fs)
|
||||
const header = readPhysicalHeader(snapshot.bytes, compression, signal)
|
||||
return { ...snapshot, headerValue: header.value, headerRecord: header.record }
|
||||
})
|
||||
await internals.barrier('before-source-check', 1)
|
||||
const beforePublish = await internals.fs.stat(sourcePath)
|
||||
if (identity(beforePublish) !== identity(sourceIdentity)) {
|
||||
throw new JsonlGenerationSourceChangedError(sourcePath)
|
||||
}
|
||||
const published = await publishCurrentExclusive(staged.path, currentPath, internals)
|
||||
if (published && internals.platform === 'win32') staged = { ...staged, path: '' }
|
||||
await internals.barrier('after-publication', 1)
|
||||
let currentIdentity: JsonlPhysicalIdentity
|
||||
if (published) {
|
||||
if (staged.path !== '') {
|
||||
await removeCommittedTemporary(staged.path, internals)
|
||||
staged = { ...staged, path: '' }
|
||||
}
|
||||
currentIdentity = await internals.fs.stat(currentPath)
|
||||
} else {
|
||||
const winner = await inspectExpectedCurrent(currentPath, internals, async () => {
|
||||
const candidate = await verifyCurrentFile(
|
||||
currentPath,
|
||||
compression,
|
||||
artifact.header.id,
|
||||
eventCount,
|
||||
staged,
|
||||
)
|
||||
if (candidate.bytes !== staged.bytes || candidate.digest !== staged.digest) {
|
||||
throw new Error('target bytes differ from the migrated generation')
|
||||
}
|
||||
return candidate
|
||||
})
|
||||
currentIdentity = winner.identity
|
||||
await removeCommittedTemporary(staged.path, internals)
|
||||
staged = { ...staged, path: '' }
|
||||
}
|
||||
return currentIdentity
|
||||
} catch (error: unknown) {
|
||||
if (staged.path !== '') await removeTemporary(staged.path, error, internals)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCurrent(
|
||||
options: EnsureJsonlGenerationOptions,
|
||||
async function prepareMigration(
|
||||
options: PrepareJsonlMigrationOptions,
|
||||
internals: JsonlGenerationInternals,
|
||||
): Promise<EnsureJsonlGenerationResult> {
|
||||
): Promise<PreparedJsonlMigration> {
|
||||
const { sourcePath, sourceVersion, currentPath, compression, format, signal } = options
|
||||
const suffix = assertGenerationPaths(
|
||||
sourcePath,
|
||||
@@ -954,124 +949,58 @@ async function ensureCurrent(
|
||||
format.currentVersion,
|
||||
compression,
|
||||
)
|
||||
let attempt = 0
|
||||
for (;;) {
|
||||
attempt += 1
|
||||
signal?.throwIfAborted()
|
||||
const source = await readStableSnapshot(sourcePath, signal, internals.fs)
|
||||
const quickHeader = readPhysicalHeader(source.bytes, compression, signal)
|
||||
const quickVersion = storedVersion(quickHeader.value)
|
||||
if (quickVersion !== sourceVersion) {
|
||||
throw new Error(
|
||||
`resolved JSONL source filename identifies v${sourceVersion}, but its header identifies v${quickVersion}: `
|
||||
+ sourcePath,
|
||||
)
|
||||
if (sourceVersion >= format.currentVersion) {
|
||||
throw new Error(`migration preparation requires a historical source, got v${sourceVersion}`)
|
||||
}
|
||||
const source = await readStableSnapshot(sourcePath, signal, internals.fs)
|
||||
let artifact: SessionFormatArtifact
|
||||
try {
|
||||
artifact = await decodeStreamingMigration(
|
||||
source.bytes,
|
||||
compression,
|
||||
sourceVersion,
|
||||
format,
|
||||
options.validateHistoricalHeader,
|
||||
signal,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
if (format.isUnsupportedMigrationError?.(error) === true) {
|
||||
throw new JsonlGenerationUnsupportedMigrationError(sourceVersion, error)
|
||||
}
|
||||
if (sourceVersion > format.currentVersion) {
|
||||
throw new JsonlGenerationNewerVersionError(
|
||||
sourceVersion,
|
||||
format.currentVersion,
|
||||
storedId(quickHeader.value),
|
||||
)
|
||||
}
|
||||
if (sourceVersion === format.currentVersion) {
|
||||
return {
|
||||
status: 'current',
|
||||
version: quickVersion,
|
||||
path: sourcePath,
|
||||
snapshot: {
|
||||
...source,
|
||||
headerValue: quickHeader.value,
|
||||
headerRecord: quickHeader.record,
|
||||
},
|
||||
throw error
|
||||
}
|
||||
if (artifact.header.version !== format.currentVersion) {
|
||||
throw new Error(`format migration returned v${artifact.header.version}, expected v${format.currentVersion}`)
|
||||
}
|
||||
const sourceIdentity = source.identity
|
||||
let publication: Promise<JsonlPhysicalIdentity> | undefined
|
||||
return {
|
||||
sourceIdentity,
|
||||
artifact,
|
||||
publish() {
|
||||
if (publication === undefined) {
|
||||
publication = publishPreparedMigration(
|
||||
options,
|
||||
suffix,
|
||||
artifact,
|
||||
sourceIdentity,
|
||||
internals,
|
||||
)
|
||||
}
|
||||
}
|
||||
let artifact: SessionFormatArtifact
|
||||
try {
|
||||
artifact = await decodeStreamingMigration(
|
||||
source.bytes,
|
||||
compression,
|
||||
format,
|
||||
options.validateHistoricalHeader,
|
||||
signal,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
if (format.isUnsupportedMigrationError?.(error) === true) {
|
||||
throw new JsonlGenerationUnsupportedMigrationError(sourceVersion, error)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (artifact.header.version !== format.currentVersion) {
|
||||
throw new Error(`format migration returned v${artifact.header.version}, expected v${format.currentVersion}`)
|
||||
}
|
||||
|
||||
await scheduler.yield()
|
||||
signal?.throwIfAborted()
|
||||
const sourceFingerprint = fingerprint(source.identity, source.bytes)
|
||||
const eventCount = artifact.events.length
|
||||
let staged = await writeSyncedTemp(currentPath, suffix, compression, artifact, format, signal, internals)
|
||||
let failure: unknown
|
||||
try {
|
||||
const verifiedStage = await options.verifyCurrentFile(
|
||||
staged.path,
|
||||
compression,
|
||||
artifact.header.id,
|
||||
eventCount,
|
||||
undefined,
|
||||
signal,
|
||||
)
|
||||
if (verifiedStage.bytes !== staged.bytes || verifiedStage.digest !== staged.digest) {
|
||||
throw new Error('staged session generation changed during verification')
|
||||
}
|
||||
await internals.barrier('before-source-check', attempt)
|
||||
const beforePublish = await readStableSnapshot(sourcePath, signal, internals.fs)
|
||||
if (fingerprint(beforePublish.identity, beforePublish.bytes) !== sourceFingerprint) continue
|
||||
|
||||
const published = await publishCurrentExclusive(staged.path, currentPath, internals)
|
||||
if (published && internals.platform === 'win32') staged = { ...staged, path: '' }
|
||||
await internals.barrier('after-publication', attempt)
|
||||
signal?.throwIfAborted()
|
||||
const committed = await reopenExpectedCurrent(
|
||||
currentPath,
|
||||
staged,
|
||||
compression,
|
||||
artifact.header.id,
|
||||
eventCount,
|
||||
options.verifyCurrentFile,
|
||||
signal,
|
||||
!published,
|
||||
internals,
|
||||
)
|
||||
if (staged.path !== '') {
|
||||
await removeCommittedTemporary(staged.path, internals)
|
||||
staged = { ...staged, path: '' }
|
||||
}
|
||||
return {
|
||||
status: 'migrated',
|
||||
fromVersion: sourceVersion,
|
||||
toVersion: format.currentVersion,
|
||||
path: currentPath,
|
||||
sourcePath,
|
||||
snapshot: committed,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
failure = error
|
||||
throw error
|
||||
} finally {
|
||||
if (staged.path !== '') await removeTemporary(staged.path, failure, internals)
|
||||
}
|
||||
return publication
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure one resolved generation has a current-format successor before returning.
|
||||
* @param options - resolved source, current target, format adapter, verification, and cancellation.
|
||||
* @returns the current source or the verified and reopened migrated successor.
|
||||
* Decode and migrate one historical generation without writing its successor.
|
||||
* @param options - resolved source, current target, format adapter, and load cancellation.
|
||||
* @returns the current artifact and an idempotent explicit publication operation.
|
||||
*/
|
||||
export function ensureJsonlGenerationCurrent(
|
||||
options: EnsureJsonlGenerationOptions,
|
||||
): Promise<EnsureJsonlGenerationResult> {
|
||||
return defaultGenerationRuntime.ensure(options)
|
||||
export function prepareJsonlMigration(
|
||||
options: PrepareJsonlMigrationOptions,
|
||||
): Promise<PreparedJsonlMigration> {
|
||||
return defaultGenerationRuntime.prepare(options)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1085,7 +1014,7 @@ export function createJsonlGenerationRuntime(
|
||||
const internals = withOverrides(overrides)
|
||||
return {
|
||||
readStable: (path, signal) => readStableSnapshot(path, signal, internals.fs),
|
||||
ensure: options => ensureCurrent(options, internals),
|
||||
prepare: options => prepareMigration(options, internals),
|
||||
verify: (path, compression, expectedId, expectedEventCount, expectedPrefix) => verifyCurrentGeneration(
|
||||
path, compression, expectedId, expectedEventCount, internals.fs, expectedPrefix,
|
||||
),
|
||||
|
||||
@@ -24,12 +24,13 @@ import {
|
||||
SessionAlreadyExistsError, SessionPersistenceNotFoundError,
|
||||
assertStoredId, materializeCreateHeader, sessionFormatVersionRefusal, validateStoredEvents,
|
||||
type SessionAccess, type SessionHandle,
|
||||
type SessionHandleReadResult,
|
||||
type SessionLocation, type SessionPersistenceCreateOptions,
|
||||
type SessionPersistenceListOptions, type SessionPersistenceOpenOptions,
|
||||
type SessionPersistenceSnapshot, type SessionPersistenceStatOptions,
|
||||
type SessionPersistenceRevision as PersistenceRevision,
|
||||
} from '@deepseek-ai/dsh-session-persistence'
|
||||
import { JsonlBackendTracker, JsonlSessionHandle } from './storage.ts'
|
||||
import { JsonlBackendTracker, JsonlSessionHandle, type StorageHandleState } from './storage.ts'
|
||||
import { SessionWriteLease } from './lease.ts'
|
||||
import { SESSION_FORMAT_VERSION, SessionId as makeSessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionId, SessionHeader, SessionLogOffset as SessionLogOffsetType } from '@deepseek-ai/dsh-session'
|
||||
@@ -44,13 +45,13 @@ import {
|
||||
import { ensureDurableDirectoryWin32, publishNewFileWin32 } from './win32.ts'
|
||||
import { verifyCurrentGenerationInWorker } from './migration-verifier.ts'
|
||||
import {
|
||||
ensureJsonlGenerationCurrent,
|
||||
JsonlGenerationNewerVersionError,
|
||||
JsonlGenerationSourceChangedError,
|
||||
JsonlGenerationUnsupportedMigrationError,
|
||||
prepareJsonlMigration,
|
||||
readStableJsonlFile,
|
||||
type EnsureJsonlGenerationResult,
|
||||
type JsonlGenerationFormatAdapter,
|
||||
type JsonlPhysicalIdentity,
|
||||
type PreparedJsonlMigration,
|
||||
} from './generation.ts'
|
||||
|
||||
export type { JsonlCompression } from './format.ts'
|
||||
@@ -97,11 +98,14 @@ export interface Config {
|
||||
compression?: JsonlCompression
|
||||
}
|
||||
|
||||
/** A parsed, validated stored log: header, logical events, and any torn-tail repair state. */
|
||||
interface StoredLog {
|
||||
/** One stored event graph whose producer has established immutable sharing. */
|
||||
interface FrozenStoredEvents extends SessionHandleReadResult {
|
||||
readonly eventState: 'shared-frozen'
|
||||
}
|
||||
|
||||
/** State shared by prepared historical and published current logs. */
|
||||
interface StoredLogBase extends FrozenStoredEvents {
|
||||
readonly meta: SessionHeader
|
||||
/** The logical log, including any events recovered from a torn final frame. */
|
||||
readonly events: SessionEvent[]
|
||||
readonly tornTruncateTo: number | undefined
|
||||
/** Complete events recovered from the torn final frame; the write path rewrites them durably. */
|
||||
readonly recoveredTail: SessionEvent[]
|
||||
@@ -110,6 +114,45 @@ interface StoredLog {
|
||||
readonly revision: PersistenceRevision
|
||||
}
|
||||
|
||||
/** A decoded current generation that is already durable. */
|
||||
interface CurrentStoredLog extends StoredLogBase {
|
||||
readonly status: 'current'
|
||||
}
|
||||
|
||||
/** A migrated historical generation retained until an explicit write open publishes it. */
|
||||
interface PreparedStoredLog extends StoredLogBase {
|
||||
readonly status: 'prepared'
|
||||
readonly publication: {
|
||||
readonly source: ResolvedJsonlGeneration
|
||||
readonly value: PreparedJsonlMigration
|
||||
}
|
||||
}
|
||||
|
||||
/** A validated logical log, either durable current state or prepared historical state. */
|
||||
type StoredLog = CurrentStoredLog | PreparedStoredLog
|
||||
|
||||
/** Deep-freeze one acyclic stored JSON event without recursive calls. */
|
||||
function freezeStoredEvent(event: SessionEvent): void {
|
||||
const pending: object[] = [event]
|
||||
while (pending.length > 0) {
|
||||
// The non-empty check proves an object remains to visit.
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
const current = pending.pop()!
|
||||
Object.freeze(current)
|
||||
for (const key in current) {
|
||||
const child = (current as Record<string, unknown>)[key]
|
||||
if (child !== null && typeof child === 'object') pending.push(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Establish immutable sharing for one decoded event graph and report that state. */
|
||||
function freezeStoredEvents(events: SessionEvent[]): FrozenStoredEvents {
|
||||
for (const event of events) freezeStoredEvent(event)
|
||||
Object.freeze(events)
|
||||
return { eventState: 'shared-frozen', events }
|
||||
}
|
||||
|
||||
/** One authoritative immutable generation selected from a Session directory. */
|
||||
interface ResolvedJsonlGeneration {
|
||||
readonly sourcePath: string
|
||||
@@ -117,6 +160,16 @@ interface ResolvedJsonlGeneration {
|
||||
readonly currentPath: string
|
||||
}
|
||||
|
||||
/** One backend-owned historical preparation shared by its current callers. */
|
||||
interface MigrationPreparation {
|
||||
readonly sourcePath: string
|
||||
readonly sourceRevision: PersistenceRevision
|
||||
readonly controller: AbortController
|
||||
readonly promise: Promise<PreparedStoredLog>
|
||||
settled: boolean
|
||||
waiters: number
|
||||
}
|
||||
|
||||
/** Build the stat-derived best-effort change token shared by full and lightweight reads. */
|
||||
function fileRevision(identity: JsonlPhysicalIdentity): PersistenceRevision {
|
||||
return SessionPersistenceRevision([
|
||||
@@ -138,6 +191,41 @@ function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
||||
return typeof (error as NodeJS.ErrnoException | null)?.code === 'string'
|
||||
}
|
||||
|
||||
/** Preserve an Error abort reason and normalize hostile non-Error reasons. */
|
||||
function abortError(signal: AbortSignal): Error {
|
||||
return signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new Error('session migration preparation aborted', { cause: signal.reason })
|
||||
}
|
||||
|
||||
/** Let one caller stop waiting without transferring cancellation ownership to shared work. */
|
||||
function waitWithAbort<T>(operation: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
if (signal === undefined) return operation
|
||||
/* v8 ignore next -- requireStoredLog synchronously rechecks the signal immediately before waiting. */
|
||||
if (signal.aborted) return Promise.reject(abortError(signal))
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const stopWaiting = (): void => {
|
||||
reject(abortError(signal))
|
||||
}
|
||||
signal.addEventListener('abort', stopWaiting, { once: true })
|
||||
void operation.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', stopWaiting)
|
||||
resolve(value)
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener('abort', stopWaiting)
|
||||
/* v8 ignore else -- the preparation owner normalizes every rejection before this waiter sees it. */
|
||||
if (error instanceof Error) {
|
||||
reject(error)
|
||||
} else {
|
||||
reject(new Error('session migration preparation failed', { cause: error }))
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The JSONL persistence backend. Load as a plugin; it registers as
|
||||
* `ctx.sessionPersistence`. Sessions materialize lazily: a created session is
|
||||
@@ -166,6 +254,8 @@ class JsonlSessionPersistence extends SessionPersistence {
|
||||
* revision guard.
|
||||
*/
|
||||
private readonly coldLogMemo = new Map<SessionId, StoredLog>()
|
||||
/** One joinable decode/migration operation per selected historical Session file revision. */
|
||||
private readonly migrationPreparations = new Map<SessionId, MigrationPreparation>()
|
||||
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
super(ctx)
|
||||
@@ -253,11 +343,22 @@ class JsonlSessionPersistence extends SessionPersistence {
|
||||
return this.tracker.adopt(new JsonlSessionHandle(this, id, pending.header, 'read', { cursor: 0, materialized: false, inheritedEventCount: pending.inheritedEventCount }))
|
||||
}
|
||||
const stored = await this.requireStoredLog(id, options?.signal)
|
||||
return this.tracker.adopt(new JsonlSessionHandle(this, id, stored.meta, 'read', {
|
||||
cursor: 0,
|
||||
materialized: true,
|
||||
inheritedEventCount: stored.inheritedEventCount,
|
||||
}))
|
||||
let state: StorageHandleState
|
||||
if (stored.status === 'prepared') {
|
||||
state = {
|
||||
cursor: 0,
|
||||
materialized: true,
|
||||
inheritedEventCount: stored.inheritedEventCount,
|
||||
primed: stored,
|
||||
}
|
||||
} else {
|
||||
state = {
|
||||
cursor: 0,
|
||||
materialized: true,
|
||||
inheritedEventCount: stored.inheritedEventCount,
|
||||
}
|
||||
}
|
||||
return this.tracker.adopt(new JsonlSessionHandle(this, id, stored.meta, 'read', state))
|
||||
}
|
||||
// A pending entry always belongs to an ACTIVE creator handle (close erases
|
||||
// it), so the claim below rejects that case as already owned.
|
||||
@@ -267,14 +368,22 @@ class JsonlSessionPersistence extends SessionPersistence {
|
||||
const resolved = await this.findLog(id, options?.signal)
|
||||
if (resolved === undefined) throw new SessionPersistenceNotFoundError(id)
|
||||
lease = await this.acquireLease(id, undefined, dirname(resolved.currentPath))
|
||||
const stored = await this.requireStoredLog(id, options?.signal)
|
||||
const prepared = await this.requireStoredLog(id, options?.signal)
|
||||
options?.signal?.throwIfAborted()
|
||||
let stored: CurrentStoredLog
|
||||
if (prepared.status === 'prepared') {
|
||||
stored = await this.publishStoredMigration(id, prepared)
|
||||
} else {
|
||||
stored = prepared
|
||||
}
|
||||
options?.signal?.throwIfAborted()
|
||||
return this.tracker.adopt(new JsonlSessionHandle(this, id, stored.meta, 'write', {
|
||||
cursor: stored.events.length,
|
||||
materialized: true,
|
||||
tornTruncateTo: stored.tornTruncateTo,
|
||||
recoveredTail: stored.recoveredTail,
|
||||
inheritedEventCount: stored.inheritedEventCount,
|
||||
primed: stored.events,
|
||||
primed: stored,
|
||||
}, lease))
|
||||
} catch (error) {
|
||||
// Free the in-process claim no matter how the kernel-lock release
|
||||
@@ -386,34 +495,115 @@ class JsonlSessionPersistence extends SessionPersistence {
|
||||
private async requireStoredLog(id: SessionId, signal?: AbortSignal): Promise<StoredLog> {
|
||||
const selected = await this.findLog(id, signal)
|
||||
if (selected === undefined) throw new SessionPersistenceNotFoundError(id)
|
||||
if (selected.sourceVersion === SESSION_FORMAT_VERSION) {
|
||||
const probe = fileRevision(await stat(selected.sourcePath, { bigint: true }))
|
||||
const memoized = this.coldLogMemo.get(id)
|
||||
if (memoized !== undefined && memoized.revision === probe) {
|
||||
this.coldLogMemo.delete(id)
|
||||
this.coldLogMemo.set(id, memoized)
|
||||
return memoized
|
||||
if (selected.sourceVersion < SESSION_FORMAT_VERSION) {
|
||||
const sourceRevision = fileRevision(await stat(selected.sourcePath, { bigint: true }))
|
||||
signal?.throwIfAborted()
|
||||
let preparation = this.migrationPreparations.get(id)
|
||||
if (preparation === undefined
|
||||
|| preparation.sourcePath !== selected.sourcePath
|
||||
|| preparation.sourceRevision !== sourceRevision) {
|
||||
const controller = new AbortController()
|
||||
const promise = this.loadStoredMigration(id, selected, sourceRevision, controller.signal)
|
||||
preparation = {
|
||||
sourcePath: selected.sourcePath,
|
||||
sourceRevision,
|
||||
controller,
|
||||
promise,
|
||||
settled: false,
|
||||
waiters: 0,
|
||||
}
|
||||
this.migrationPreparations.set(id, preparation)
|
||||
const created = preparation
|
||||
const release = (): void => {
|
||||
created.settled = true
|
||||
if (this.migrationPreparations.get(id) === created) {
|
||||
this.migrationPreparations.delete(id)
|
||||
}
|
||||
}
|
||||
void promise.then(release, release)
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
return this.waitForPreparation(id, preparation, signal)
|
||||
}
|
||||
const current = await this.ensureCurrentLog(id, signal, selected)
|
||||
if (selected.sourceVersion > SESSION_FORMAT_VERSION) {
|
||||
const header = await this.readGenerationHeader(selected, id, signal)
|
||||
/* v8 ignore else -- a readable future header is rejected inside readGenerationHeader. */
|
||||
if (header === undefined) {
|
||||
throw new SessionPersistenceCorruptionError(
|
||||
`session "${id}": stored log has a malformed header (raw log: ${selected.sourcePath})`,
|
||||
{ cause: new Error('malformed Session header') },
|
||||
)
|
||||
}
|
||||
/* v8 ignore next -- readGenerationHeader rejects every future version. */
|
||||
throw new SessionFormatUnsupportedError(
|
||||
`${sessionFormatVersionRefusal(id, selected.sourceVersion)} (raw log: ${selected.sourcePath})`,
|
||||
{ kind: 'jsonl', path: selected.sourcePath },
|
||||
)
|
||||
}
|
||||
const probe = fileRevision(await stat(selected.sourcePath, { bigint: true }))
|
||||
const memoized = this.coldLogMemo.get(id)
|
||||
if (memoized?.status === 'current' && memoized.revision === probe) {
|
||||
this.coldLogMemo.delete(id)
|
||||
this.coldLogMemo.set(id, memoized)
|
||||
return memoized
|
||||
}
|
||||
const current = await readStableJsonlFile(selected.sourcePath, signal)
|
||||
return this.decodeStoredLog(
|
||||
current.path,
|
||||
selected.sourcePath,
|
||||
id,
|
||||
current.snapshot.bytes,
|
||||
fileRevision(current.snapshot.identity),
|
||||
current.bytes,
|
||||
fileRevision(current.identity),
|
||||
signal,
|
||||
)
|
||||
}
|
||||
|
||||
/** Select and, when required, publish one immutable current generation. */
|
||||
private async ensureCurrentLog(
|
||||
/** Probe the memo and otherwise decode one historical generation under backend cancellation. */
|
||||
private async loadStoredMigration(
|
||||
id: SessionId,
|
||||
signal: AbortSignal | undefined,
|
||||
selected: ResolvedJsonlGeneration,
|
||||
): Promise<EnsureJsonlGenerationResult> {
|
||||
signal?.throwIfAborted()
|
||||
sourceRevision: PersistenceRevision,
|
||||
signal: AbortSignal,
|
||||
): Promise<PreparedStoredLog> {
|
||||
signal.throwIfAborted()
|
||||
const memoized = this.coldLogMemo.get(id)
|
||||
if (memoized?.status === 'prepared' && memoized.revision === sourceRevision) {
|
||||
this.coldLogMemo.delete(id)
|
||||
this.coldLogMemo.set(id, memoized)
|
||||
return memoized
|
||||
}
|
||||
return this.prepareStoredMigration(id, selected, signal)
|
||||
}
|
||||
|
||||
/** Await shared preparation for one caller and abort it only after its last waiter leaves. */
|
||||
private async waitForPreparation(
|
||||
id: SessionId,
|
||||
preparation: MigrationPreparation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<PreparedStoredLog> {
|
||||
preparation.waiters += 1
|
||||
try {
|
||||
return await ensureJsonlGenerationCurrent({
|
||||
return await waitWithAbort(preparation.promise, signal)
|
||||
} finally {
|
||||
preparation.waiters -= 1
|
||||
if (preparation.waiters === 0 && !preparation.settled) {
|
||||
/* v8 ignore else -- a newer selected source may already own this id's preparation slot. */
|
||||
if (this.migrationPreparations.get(id) === preparation) {
|
||||
this.migrationPreparations.delete(id)
|
||||
}
|
||||
preparation.controller.abort()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode one historical generation without publishing a successor. */
|
||||
private async prepareStoredMigration(
|
||||
id: SessionId,
|
||||
selected: ResolvedJsonlGeneration,
|
||||
signal: AbortSignal,
|
||||
): Promise<PreparedStoredLog> {
|
||||
let prepared: Awaited<ReturnType<typeof prepareJsonlMigration>>
|
||||
try {
|
||||
prepared = await prepareJsonlMigration({
|
||||
sourcePath: selected.sourcePath,
|
||||
sourceVersion: selected.sourceVersion,
|
||||
currentPath: selected.currentPath,
|
||||
@@ -426,32 +616,75 @@ class JsonlSessionPersistence extends SessionPersistence {
|
||||
id,
|
||||
signal,
|
||||
),
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
signal,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
signal?.throwIfAborted()
|
||||
if (error instanceof JsonlGenerationNewerVersionError) {
|
||||
const reason = sessionFormatVersionRefusal(error.storedId, error.storedVersion)
|
||||
throw new SessionFormatUnsupportedError(
|
||||
`${reason} (raw log: ${selected.sourcePath})`,
|
||||
{ kind: 'jsonl', path: selected.sourcePath },
|
||||
)
|
||||
}
|
||||
if (error instanceof JsonlGenerationUnsupportedMigrationError) {
|
||||
throw new SessionFormatUnsupportedError(
|
||||
`${error.message}; source v${error.fromVersion} artifact remains unchanged (raw log: ${selected.sourcePath})`,
|
||||
{ kind: 'jsonl', path: selected.sourcePath },
|
||||
)
|
||||
}
|
||||
if (error instanceof SessionFormatUnsupportedError
|
||||
|| error instanceof SessionPersistenceCorruptionError
|
||||
|| isErrnoException(error)
|
||||
|| error instanceof DOMException && error.name === 'AbortError') throw error
|
||||
throw new SessionPersistenceCorruptionError(
|
||||
`session "${id}": stored log is corrupt: ${String(error)} (raw log: ${selected.sourcePath})`,
|
||||
{ cause: error },
|
||||
throw this.generationFailure(id, selected, error)
|
||||
}
|
||||
const meta = this.currentHeader(prepared.artifact.header)
|
||||
assertStoredId(id, meta)
|
||||
const events = prepared.artifact.events as SessionEvent[]
|
||||
validateStoredEvents(meta, events, { kind: 'jsonl', path: selected.sourcePath })
|
||||
const stored: PreparedStoredLog = {
|
||||
status: 'prepared',
|
||||
meta,
|
||||
...freezeStoredEvents(events),
|
||||
tornTruncateTo: undefined,
|
||||
recoveredTail: [],
|
||||
inheritedEventCount: SessionLogOffset(prepared.artifact.inheritedEventCount),
|
||||
revision: fileRevision(prepared.sourceIdentity),
|
||||
publication: { source: selected, value: prepared },
|
||||
}
|
||||
this.memoizeStoredLog(id, stored)
|
||||
return stored
|
||||
}
|
||||
|
||||
/** Publish a prepared historical log before granting write access. */
|
||||
private async publishStoredMigration(id: SessionId, stored: PreparedStoredLog): Promise<CurrentStoredLog> {
|
||||
const migration = stored.publication
|
||||
let identity: JsonlPhysicalIdentity
|
||||
try {
|
||||
identity = await migration.value.publish()
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore else -- a newer preparation may have replaced this stale cache entry. */
|
||||
if (this.coldLogMemo.get(id) === stored) this.coldLogMemo.delete(id)
|
||||
throw this.generationFailure(id, migration.source, error)
|
||||
}
|
||||
const published: CurrentStoredLog = {
|
||||
status: 'current',
|
||||
meta: stored.meta,
|
||||
eventState: stored.eventState,
|
||||
events: stored.events,
|
||||
tornTruncateTo: stored.tornTruncateTo,
|
||||
recoveredTail: stored.recoveredTail,
|
||||
inheritedEventCount: stored.inheritedEventCount,
|
||||
revision: fileRevision(identity),
|
||||
}
|
||||
this.memoizeStoredLog(id, published)
|
||||
return published
|
||||
}
|
||||
|
||||
/** Translate generation-layer failures into the persistence seam's error vocabulary. */
|
||||
private generationFailure(
|
||||
id: SessionId,
|
||||
selected: ResolvedJsonlGeneration,
|
||||
error: unknown,
|
||||
): Error {
|
||||
if (error instanceof JsonlGenerationUnsupportedMigrationError) {
|
||||
return new SessionFormatUnsupportedError(
|
||||
`${error.message}; source v${error.fromVersion} artifact remains unchanged (raw log: ${selected.sourcePath})`,
|
||||
{ kind: 'jsonl', path: selected.sourcePath },
|
||||
)
|
||||
}
|
||||
if (error instanceof JsonlGenerationSourceChangedError) return error
|
||||
if (error instanceof SessionFormatUnsupportedError
|
||||
|| error instanceof SessionPersistenceCorruptionError
|
||||
|| isErrnoException(error)
|
||||
|| error instanceof DOMException && error.name === 'AbortError') return error
|
||||
return new SessionPersistenceCorruptionError(
|
||||
`session "${id}": stored log is corrupt: ${String(error)} (raw log: ${selected.sourcePath})`,
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -461,11 +694,11 @@ class JsonlSessionPersistence extends SessionPersistence {
|
||||
* @param signal - optional cancellation for the stat/read/decode work.
|
||||
* @returns the validated stored log with any torn-tail truncation point.
|
||||
*/
|
||||
async readStoredLog(path: string, expectedId: SessionId, signal?: AbortSignal): Promise<StoredLog> {
|
||||
async readStoredLog(path: string, expectedId: SessionId, signal?: AbortSignal): Promise<CurrentStoredLog> {
|
||||
signal?.throwIfAborted()
|
||||
const probe = fileRevision(await stat(path, { bigint: true }))
|
||||
const memoized = this.coldLogMemo.get(expectedId)
|
||||
if (memoized !== undefined && memoized.revision === probe) {
|
||||
if (memoized?.status === 'current' && memoized.revision === probe) {
|
||||
this.coldLogMemo.delete(expectedId)
|
||||
this.coldLogMemo.set(expectedId, memoized)
|
||||
return memoized
|
||||
@@ -481,7 +714,7 @@ class JsonlSessionPersistence extends SessionPersistence {
|
||||
buffer: Buffer,
|
||||
revision: PersistenceRevision,
|
||||
signal?: AbortSignal,
|
||||
): Promise<StoredLog> {
|
||||
): Promise<CurrentStoredLog> {
|
||||
let parsed: {
|
||||
meta: SessionHeader
|
||||
inheritedEventCount: SessionLogOffsetType
|
||||
@@ -523,30 +756,45 @@ class JsonlSessionPersistence extends SessionPersistence {
|
||||
assertStoredId(expectedId, parsed.meta)
|
||||
const location = this.locate(parsed.meta)
|
||||
validateStoredEvents(parsed.meta, parsed.events, location)
|
||||
const stored: StoredLog = { ...parsed, revision }
|
||||
this.coldLogMemo.delete(expectedId)
|
||||
this.coldLogMemo.set(expectedId, stored)
|
||||
const { events, ...rest } = parsed
|
||||
const stored: CurrentStoredLog = {
|
||||
status: 'current',
|
||||
...rest,
|
||||
...freezeStoredEvents(events),
|
||||
revision,
|
||||
}
|
||||
this.memoizeStoredLog(expectedId, stored)
|
||||
return stored
|
||||
}
|
||||
|
||||
/** Insert one parsed log into the bounded handoff cache. */
|
||||
private memoizeStoredLog(id: SessionId, stored: StoredLog): void {
|
||||
this.coldLogMemo.delete(id)
|
||||
this.coldLogMemo.set(id, stored)
|
||||
for (const oldest of this.coldLogMemo.keys()) {
|
||||
if (this.coldLogMemo.size <= COLD_LOG_MEMO_MAX_ENTRIES) break
|
||||
this.coldLogMemo.delete(oldest)
|
||||
}
|
||||
return stored
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a session's unique log path.
|
||||
* Resolve a session's current-generation log path.
|
||||
* @param id - the stored session to locate.
|
||||
* @param signal - optional cancellation for the directory scans.
|
||||
* @returns the artifact path, or `undefined` when absent.
|
||||
* @returns the current artifact path, or `undefined` while only a historical generation exists.
|
||||
*/
|
||||
async resolveLog(id: SessionId, signal?: AbortSignal): Promise<string | undefined> {
|
||||
async resolveCurrentLog(id: SessionId, signal?: AbortSignal): Promise<string | undefined> {
|
||||
await this.ensureRootEncoding()
|
||||
signal?.throwIfAborted()
|
||||
const selected = await this.findLog(id, signal)
|
||||
if (selected === undefined) return undefined
|
||||
if (selected.sourceVersion === SESSION_FORMAT_VERSION) return selected.sourcePath
|
||||
const current = await this.ensureCurrentLog(id, signal, selected)
|
||||
return current.path
|
||||
if (selected.sourceVersion < SESSION_FORMAT_VERSION) return undefined
|
||||
const reason = sessionFormatVersionRefusal(id, selected.sourceVersion)
|
||||
throw new SessionFormatUnsupportedError(
|
||||
`${reason} (raw log: ${selected.sourcePath})`,
|
||||
{ kind: 'jsonl', path: selected.sourcePath },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
SessionHandleAppendOptions,
|
||||
SessionHandleFlushOptions,
|
||||
SessionHandleReadOptions,
|
||||
SessionHandleReadResult,
|
||||
} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { SessionWriteLease } from './lease.ts'
|
||||
|
||||
@@ -47,10 +48,10 @@ export interface JsonlHandleStorage {
|
||||
persistHeader(header: SessionHeader, inheritedEventCount: SessionLogOffset): Promise<void>
|
||||
/** Truncate a torn physical tail before the first new append lands. */
|
||||
truncateTornTail(header: SessionHeader, truncateTo: number): Promise<void>
|
||||
/** Resolve the session's artifact path, or `undefined` before materialization. */
|
||||
resolveLog(id: SessionId, signal?: AbortSignal): Promise<string | undefined>
|
||||
/** Read and validate the stored log at `path`. */
|
||||
readStoredLog(path: string, expectedId: SessionId, signal?: AbortSignal): Promise<{ events: SessionEvent[] }>
|
||||
/** Resolve the current-generation artifact path, or `undefined` when absent. */
|
||||
resolveCurrentLog(id: SessionId, signal?: AbortSignal): Promise<string | undefined>
|
||||
/** Read and validate the stored log at `path`, including its established event aliasing state. */
|
||||
readStoredLog(path: string, expectedId: SessionId, signal?: AbortSignal): Promise<SessionHandleReadResult>
|
||||
/** Whether the id is still a created-but-unmaterialized session here. */
|
||||
hasPendingSession(id: SessionId): boolean
|
||||
/** Acquire the session's cross-process write lock in its artifact directory. */
|
||||
@@ -72,7 +73,7 @@ export interface StorageHandleState {
|
||||
/** Exact fork-inherited prefix length stored with the log; `0` when unseeded. */
|
||||
inheritedEventCount: SessionLogOffset
|
||||
/** The validated stored prefix from a write open, served to reads until the first append. */
|
||||
primed?: SessionEvent[] | undefined
|
||||
primed?: SessionHandleReadResult | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,9 +113,9 @@ export class JsonlSessionHandle implements SessionHandle {
|
||||
* @param offset - first logical seq to include (default 0).
|
||||
* @param length - maximum events returned (default: the rest).
|
||||
* @param options - optional cancellation.
|
||||
* @returns the requested slice.
|
||||
* @returns a slice carrying the aliasing state established by its producer.
|
||||
*/
|
||||
async read(offset = 0, length = Number.MAX_SAFE_INTEGER, options?: SessionHandleReadOptions): Promise<readonly SessionEvent[]> {
|
||||
async read(offset = 0, length = Number.MAX_SAFE_INTEGER, options?: SessionHandleReadOptions): Promise<SessionHandleReadResult> {
|
||||
// Closed-handle refusal precedes argument validation: a closed handle
|
||||
// rejects SessionHandleClosedError regardless of the arguments.
|
||||
this.assertOpen('read')
|
||||
@@ -125,24 +126,57 @@ export class JsonlSessionHandle implements SessionHandle {
|
||||
throw new TypeError(`read length must be a non-negative safe integer, got ${String(length)}`)
|
||||
}
|
||||
options?.signal?.throwIfAborted()
|
||||
if (this.state.primed !== undefined) {
|
||||
this.observedLength = Math.max(this.observedLength, this.state.primed.length)
|
||||
return this.state.primed.slice(offset, offset + length)
|
||||
let result: SessionHandleReadResult
|
||||
const primed = this.state.primed
|
||||
if (primed !== undefined) {
|
||||
if (this.access === 'write') {
|
||||
result = this.readPrimed(primed, offset, length)
|
||||
} else {
|
||||
const currentPath = await this.storage.resolveCurrentLog(this.id, options?.signal)
|
||||
if (currentPath === undefined) {
|
||||
result = this.readPrimed(primed, offset, length)
|
||||
} else {
|
||||
this.state.primed = undefined
|
||||
result = await this.readCurrent(currentPath, offset, length, options?.signal)
|
||||
}
|
||||
}
|
||||
} else if (this.access === 'write' && !this.state.materialized) {
|
||||
result = { eventState: 'detached', events: [] }
|
||||
} else {
|
||||
const currentPath = await this.storage.resolveCurrentLog(this.id, options?.signal)
|
||||
if (currentPath !== undefined) {
|
||||
result = await this.readCurrent(currentPath, offset, length, options?.signal)
|
||||
} else if (this.storage.hasPendingSession(this.id)) {
|
||||
result = { eventState: 'detached', events: [] }
|
||||
} else {
|
||||
throw new SessionPersistenceNotFoundError(this.id)
|
||||
}
|
||||
}
|
||||
// A write handle knows its own materialization; a read handle asks the
|
||||
// backend so a writer's later materialization becomes visible here.
|
||||
if (this.access === 'write' && !this.state.materialized) return []
|
||||
const path = await this.storage.resolveLog(this.id, options?.signal)
|
||||
if (path === undefined) {
|
||||
if (this.storage.hasPendingSession(this.id)) return []
|
||||
throw new SessionPersistenceNotFoundError(this.id)
|
||||
return result
|
||||
}
|
||||
|
||||
/** Read one slice from the prepared historical prefix retained by this handle. */
|
||||
private readPrimed(source: SessionHandleReadResult, offset: number, length: number): SessionHandleReadResult {
|
||||
this.observedLength = Math.max(this.observedLength, source.events.length)
|
||||
return { eventState: source.eventState, events: source.events.slice(offset, offset + length) }
|
||||
}
|
||||
|
||||
/** Read one current physical generation and enforce this handle's monotonic view. */
|
||||
private async readCurrent(
|
||||
path: string,
|
||||
offset: number,
|
||||
length: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionHandleReadResult> {
|
||||
const source = await this.storage.readStoredLog(path, this.id, signal)
|
||||
if (source.events.length < this.observedLength) {
|
||||
throw new Error(`session "${this.id}": stored log shrank below a previously observed prefix (${source.events.length} < ${this.observedLength})`)
|
||||
}
|
||||
const { events } = await this.storage.readStoredLog(path, this.id, options?.signal)
|
||||
if (events.length < this.observedLength) {
|
||||
throw new Error(`session "${this.id}": stored log shrank below a previously observed prefix (${events.length} < ${this.observedLength})`)
|
||||
this.observedLength = source.events.length
|
||||
return {
|
||||
eventState: source.eventState,
|
||||
events: source.events.slice(offset, offset + length),
|
||||
}
|
||||
this.observedLength = events.length
|
||||
return events.slice(offset, offset + length)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,7 +27,7 @@ describe.skipIf(!built)('built migration verifier (plain node)', () => {
|
||||
type: 'session', version: 0, id, createdAt: 1, delegationDepth: 0,
|
||||
}) + '\\n')
|
||||
await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
|
||||
const handle = await ctx.sessionPersistence.open(id, 'read')
|
||||
const handle = await ctx.sessionPersistence.open(id, 'write')
|
||||
await handle.close()
|
||||
await ctx.sessionPersistence.flush()
|
||||
const header = JSON.parse((await readFile(join(directory, 'session.v2.jsonl'), 'utf8')).trim())
|
||||
|
||||
@@ -17,19 +17,24 @@ import {
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { scheduler } from 'node:timers/promises'
|
||||
import {
|
||||
ensureJsonlGenerationCurrent as ensureJsonlGenerationCurrentProduction,
|
||||
JsonlGenerationSourceChangedError,
|
||||
JsonlGenerationTargetConflictError,
|
||||
JsonlGenerationUnsupportedMigrationError,
|
||||
prepareJsonlMigration,
|
||||
verifyJsonlCurrentGeneration,
|
||||
type EnsureJsonlGenerationOptions,
|
||||
type JsonlGenerationFormatAdapter,
|
||||
type PrepareJsonlMigrationOptions,
|
||||
} from '../src/generation.ts'
|
||||
import { createJsonlGenerationTestRuntime } from '../src/testing/generation.ts'
|
||||
import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from '../src/zstd.ts'
|
||||
import type { JsonlCompression } from '../src/format.ts'
|
||||
import type { SessionFormatArtifact, SessionFormatRestore } from '@deepseek-ai/dsh-session-format'
|
||||
import type {
|
||||
SessionFormatArtifact,
|
||||
SessionFormatEvent,
|
||||
SessionFormatJsonValue,
|
||||
SessionFormatRestore,
|
||||
} from '@deepseek-ai/dsh-session-format'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
@@ -78,6 +83,61 @@ function header(version: number, id = 'generation-test'): Record<string, unknown
|
||||
|
||||
const event0 = { type: 'turn/start', seq: 0, time: 2, data: { turn: 1 } }
|
||||
const event1 = { type: 'turn/end', seq: 1, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }
|
||||
const assistantUsage = { inputTokens: 3, outputTokens: 2 }
|
||||
const assistantReplayState = { response: { id: 'response' } }
|
||||
|
||||
function assistantData(
|
||||
overrides: {
|
||||
readonly content?: readonly SessionFormatJsonValue[]
|
||||
readonly stream?: SessionFormatJsonValue
|
||||
readonly usage?: SessionFormatJsonValue
|
||||
readonly replayState?: SessionFormatJsonValue
|
||||
readonly interrupted?: true
|
||||
} = {},
|
||||
): SessionFormatJsonValue {
|
||||
const replayState = overrides.replayState === undefined
|
||||
? assistantReplayState
|
||||
: overrides.replayState
|
||||
return {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: {
|
||||
id: 'assistant',
|
||||
role: 'assistant',
|
||||
content: overrides.content ?? [{ type: 'text', text: 'hello' }],
|
||||
source: {
|
||||
kind: 'model', provider: 'mock', model: 'mock',
|
||||
...(replayState === null ? {} : { replayState }),
|
||||
},
|
||||
},
|
||||
stream: overrides.stream ?? [
|
||||
{ type: 'text-chunks', time0: 3, index: 0, dt: [], texts: ['hello'] },
|
||||
{ type: 'chunk', time: 4, chunk: { type: 'usage', usage: assistantUsage } },
|
||||
{ type: 'chunk', time: 5, chunk: { type: 'finish', reason: { kind: 'stop' }, replayState: assistantReplayState } },
|
||||
],
|
||||
...(overrides.usage === null ? {} : { usage: overrides.usage ?? assistantUsage }),
|
||||
...(overrides.interrupted === undefined ? {} : { interrupted: overrides.interrupted }),
|
||||
}
|
||||
}
|
||||
|
||||
function assistantLifecycle(
|
||||
type: 'assistant/message' | 'assistant/attempt',
|
||||
data: SessionFormatJsonValue,
|
||||
): SessionFormatEvent[] {
|
||||
return [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
|
||||
{
|
||||
type,
|
||||
seq: 2,
|
||||
time: 5,
|
||||
data,
|
||||
...(type === 'assistant/message' ? { surfaceOp: 'append' as const } : {}),
|
||||
},
|
||||
{ type: 'step/end', seq: 3, time: 6, data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 4, time: 7, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
}
|
||||
|
||||
interface TestGenerationFormatAdapter extends JsonlGenerationFormatAdapter {
|
||||
createRestore(header: Record<string, unknown>): SessionFormatRestore
|
||||
@@ -120,12 +180,12 @@ function streamingAdapter(): JsonlGenerationFormatAdapter & {
|
||||
return adapter()
|
||||
}
|
||||
|
||||
function verifier(): EnsureJsonlGenerationOptions['verifyCurrentFile'] {
|
||||
function verifier(): PrepareJsonlMigrationOptions['verifyCurrentFile'] {
|
||||
return (path, compression, expectedId, expectedEventCount, expectedPrefix) =>
|
||||
verifyJsonlCurrentGeneration(path, compression, expectedId, expectedEventCount, expectedPrefix)
|
||||
}
|
||||
|
||||
const byteVerifier: EnsureJsonlGenerationOptions['verifyCurrentFile'] = async (path) => {
|
||||
const byteVerifier: PrepareJsonlMigrationOptions['verifyCurrentFile'] = async (path) => {
|
||||
const [bytes, identity] = await Promise.all([readFile(path), stat(path, { bigint: true })])
|
||||
return {
|
||||
identity,
|
||||
@@ -144,7 +204,7 @@ function options(
|
||||
compression: JsonlCompression = 'none',
|
||||
format: JsonlGenerationFormatAdapter = adapter(),
|
||||
sourceVersion = 0,
|
||||
): Omit<EnsureJsonlGenerationOptions, 'verifyCurrentFile'> {
|
||||
): Omit<PrepareJsonlMigrationOptions, 'verifyCurrentFile'> {
|
||||
return {
|
||||
sourcePath: generationPath(root, sourceVersion, compression),
|
||||
sourceVersion,
|
||||
@@ -156,7 +216,7 @@ function options(
|
||||
|
||||
type TestMigrationOptions = ReturnType<typeof options> & {
|
||||
readonly signal?: AbortSignal
|
||||
readonly verifyCurrentFile?: EnsureJsonlGenerationOptions['verifyCurrentFile']
|
||||
readonly verifyCurrentFile?: PrepareJsonlMigrationOptions['verifyCurrentFile']
|
||||
}
|
||||
type TestGenerationOverrides = Parameters<typeof createJsonlGenerationTestRuntime>[0]
|
||||
|
||||
@@ -175,17 +235,24 @@ async function ensureWithOverrides(
|
||||
expectedPrefix,
|
||||
)
|
||||
)
|
||||
return runtime.ensure({
|
||||
const prepared = await runtime.prepare({
|
||||
...request,
|
||||
verifyCurrentFile,
|
||||
})
|
||||
const identity = await prepared.publish()
|
||||
const bytes = await readFile(request.currentPath)
|
||||
return {
|
||||
status: 'migrated' as const,
|
||||
fromVersion: request.sourceVersion,
|
||||
toVersion: request.format.currentVersion,
|
||||
path: request.currentPath,
|
||||
sourcePath: request.sourcePath,
|
||||
snapshot: { identity, bytes },
|
||||
}
|
||||
}
|
||||
|
||||
function ensureJsonlGenerationCurrent(request: TestMigrationOptions) {
|
||||
return ensureJsonlGenerationCurrentProduction({
|
||||
...request,
|
||||
verifyCurrentFile: request.verifyCurrentFile ?? verifier(),
|
||||
})
|
||||
return ensureWithOverrides(request, {})
|
||||
}
|
||||
|
||||
async function encodeZstd(version: number, rows: readonly unknown[]): Promise<Buffer> {
|
||||
@@ -207,7 +274,7 @@ async function decodeZstdJsonl(path: string): Promise<string> {
|
||||
}
|
||||
|
||||
describe('JSONL immutable generation publication', () => {
|
||||
it('does not return until verification and publication complete', async () => {
|
||||
it('returns migrated events while publication is still waiting for verification', async () => {
|
||||
const root = await tempRoot()
|
||||
const request = options(root, 'none', streamingAdapter())
|
||||
const boundaryBase = { ...event0, data: { turn: 1, text: '' } }
|
||||
@@ -223,7 +290,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
|
||||
const migration = ensureJsonlGenerationCurrent({
|
||||
const prepared = await prepareJsonlMigration({
|
||||
...request,
|
||||
verifyCurrentFile: async (path, compression, expectedId, expectedEventCount) => {
|
||||
entered.resolve(undefined)
|
||||
@@ -231,11 +298,14 @@ describe('JSONL immutable generation publication', () => {
|
||||
return verifyJsonlCurrentGeneration(path, compression, expectedId, expectedEventCount)
|
||||
},
|
||||
})
|
||||
expect(prepared.artifact.events).toEqual([boundaryEvent, largeEvent, finalEvent])
|
||||
const publication = prepared.publish()
|
||||
expect(prepared.publish()).toBe(publication)
|
||||
await entered.promise
|
||||
await expect(readFile(request.currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
|
||||
release.resolve(undefined)
|
||||
await migration
|
||||
await publication
|
||||
const [writtenHeader, ...writtenEvents] = (await readFile(request.currentPath, 'utf8')).trimEnd().split('\n')
|
||||
expect(JSON.parse(writtenHeader as string)).toEqual({ ...header(2), isSeeded: false })
|
||||
expect(writtenEvents.map(row => JSON.parse(row) as unknown)).toEqual([boundaryEvent, largeEvent, finalEvent])
|
||||
@@ -252,15 +322,16 @@ describe('JSONL immutable generation publication', () => {
|
||||
const events = widths.map((_, seq) => ({ ...event0, seq }))
|
||||
await writeFile(request.sourcePath, line(header(0)) + events.map(line).join(''))
|
||||
|
||||
await ensureJsonlGenerationCurrent({
|
||||
const prepared = await prepareJsonlMigration({
|
||||
...request,
|
||||
verifyCurrentFile: byteVerifier,
|
||||
})
|
||||
await prepared.publish()
|
||||
|
||||
expect((await stat(request.currentPath)).size).toBeGreaterThan(8 * mib)
|
||||
})
|
||||
|
||||
it('retries migration when the source changes before publication', async () => {
|
||||
it('fails publication without rerunning migration when the source changes', async () => {
|
||||
const root = await tempRoot()
|
||||
const base = streamingAdapter()
|
||||
const sourceStreams = vi.fn()
|
||||
@@ -274,21 +345,18 @@ describe('JSONL immutable generation publication', () => {
|
||||
const source = line(header(0)) + line(event0)
|
||||
await writeFile(request.sourcePath, source)
|
||||
|
||||
let verifications = 0
|
||||
await ensureJsonlGenerationCurrent({
|
||||
const prepared = await prepareJsonlMigration({
|
||||
...request,
|
||||
verifyCurrentFile: async (path, compression, expectedId, expectedEventCount) => {
|
||||
const verified = await verifyJsonlCurrentGeneration(path, compression, expectedId, expectedEventCount)
|
||||
if (++verifications === 1) await writeFile(request.sourcePath, source + line(event1))
|
||||
await writeFile(request.sourcePath, source + line(event1))
|
||||
return verified
|
||||
},
|
||||
})
|
||||
|
||||
expect(sourceStreams).toHaveBeenCalledTimes(2)
|
||||
expect(verifications).toBe(3)
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(
|
||||
line(header(2)) + line(event0) + line(event1),
|
||||
)
|
||||
await expect(prepared.publish()).rejects.toBeInstanceOf(JsonlGenerationSourceChangedError)
|
||||
expect(sourceStreams).toHaveBeenCalledOnce()
|
||||
await expect(readFile(request.currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('refuses malformed streaming inputs before publication', async () => {
|
||||
@@ -296,23 +364,24 @@ describe('JSONL immutable generation publication', () => {
|
||||
const request = options(root, 'none', streamingAdapter())
|
||||
|
||||
await writeFile(request.sourcePath, '')
|
||||
await expect(ensureJsonlGenerationCurrent({ ...request, verifyCurrentFile: vi.fn() }))
|
||||
await expect(prepareJsonlMigration({ ...request, verifyCurrentFile: vi.fn() }))
|
||||
.rejects.toThrow('empty or header-less')
|
||||
|
||||
await writeFile(request.sourcePath, line(header(1)))
|
||||
await expect(ensureJsonlGenerationCurrent({ ...request, verifyCurrentFile: vi.fn() }))
|
||||
await expect(prepareJsonlMigration({ ...request, verifyCurrentFile: vi.fn() }))
|
||||
.rejects.toThrow(/filename identifies v0.*header identifies v1/)
|
||||
|
||||
await writeFile(request.sourcePath, line(header(0)) + '{bad json}\n' + line(event1))
|
||||
await expect(ensureJsonlGenerationCurrent({ ...request, verifyCurrentFile: vi.fn() }))
|
||||
await expect(prepareJsonlMigration({ ...request, verifyCurrentFile: vi.fn() }))
|
||||
.rejects.toThrow('row 1 is not valid JSON')
|
||||
|
||||
await writeFile(request.sourcePath, line(header(0)) + '{bad json}\n' + line(event0))
|
||||
await ensureJsonlGenerationCurrent({
|
||||
const dropped = await prepareJsonlMigration({
|
||||
...request,
|
||||
verifyCurrentFile: verifier(),
|
||||
})
|
||||
expect((await readFile(request.currentPath, 'utf8')).trimEnd().split('\n')).toHaveLength(1)
|
||||
expect(dropped.artifact.events).toEqual([])
|
||||
await dropped.publish()
|
||||
|
||||
})
|
||||
|
||||
@@ -346,11 +415,52 @@ describe('JSONL immutable generation publication', () => {
|
||||
.rejects.toThrow('torn physical tail')
|
||||
})
|
||||
|
||||
it('keeps complete Assistant stream checks in current-generation verification', async () => {
|
||||
const root = await tempRoot()
|
||||
const path = generationPath(root, 2, 'none')
|
||||
const verify = async (events: readonly SessionFormatEvent[]) => {
|
||||
await writeFile(path, line(header(2)) + events.map(line).join(''))
|
||||
return verifyJsonlCurrentGeneration(path, 'none', 'generation-test', events.length)
|
||||
}
|
||||
|
||||
const valid = [
|
||||
assistantLifecycle('assistant/message', assistantData()),
|
||||
assistantLifecycle('assistant/message', assistantData({
|
||||
interrupted: true,
|
||||
stream: [{ type: 'text-chunks', time0: 3, index: 0, dt: [], texts: ['hello'] }],
|
||||
usage: null,
|
||||
replayState: null,
|
||||
})),
|
||||
assistantLifecycle('assistant/message', assistantData({
|
||||
content: [], stream: [], usage: null, replayState: null,
|
||||
})),
|
||||
assistantLifecycle('assistant/attempt', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
stream: [{ type: 'text-chunks', time0: 3, index: 0, dt: [1], texts: ['a', 'b'] }],
|
||||
}),
|
||||
]
|
||||
for (const events of valid) expect((await verify(events)).bytes).toBeGreaterThan(0)
|
||||
|
||||
await expect(verify(assistantLifecycle('assistant/attempt', {
|
||||
turn: 1, step: 1, stream: [{ type: 'future' }],
|
||||
}))).rejects.toThrow(/invalid embedded stream/)
|
||||
await expect(verify(assistantLifecycle('assistant/message', assistantData({
|
||||
content: [{ type: 'text', text: 'different' }],
|
||||
})))).rejects.toThrow(/content disagrees/)
|
||||
await expect(verify(assistantLifecycle('assistant/message', assistantData({
|
||||
usage: { inputTokens: 9, outputTokens: 2 },
|
||||
})))).rejects.toThrow(/usage disagrees/)
|
||||
await expect(verify(assistantLifecycle('assistant/message', assistantData({
|
||||
replayState: { response: { id: 'different' } },
|
||||
})))).rejects.toThrow(/replay state disagrees/)
|
||||
})
|
||||
|
||||
it('accepts an identical publication winner and rejects different bytes', async () => {
|
||||
const identicalRoot = await tempRoot()
|
||||
const identical = options(identicalRoot, 'none', streamingAdapter())
|
||||
await writeFile(identical.sourcePath, line(header(0)) + line(event0))
|
||||
await ensureJsonlGenerationCurrent({
|
||||
const prepared = await prepareJsonlMigration({
|
||||
...identical,
|
||||
verifyCurrentFile: async (path, compression, expectedId, expectedEventCount) => {
|
||||
const verified = await verifyJsonlCurrentGeneration(path, compression, expectedId, expectedEventCount)
|
||||
@@ -358,16 +468,17 @@ describe('JSONL immutable generation publication', () => {
|
||||
return verified
|
||||
},
|
||||
})
|
||||
expect((await stat(identical.currentPath, { bigint: true })).size).toBeGreaterThan(0n)
|
||||
expect((await prepared.publish()).size).toBeGreaterThan(0n)
|
||||
|
||||
const differentRoot = await tempRoot()
|
||||
const different = options(differentRoot, 'none', streamingAdapter())
|
||||
await writeFile(different.sourcePath, line(header(0)) + line(event0))
|
||||
await writeFile(different.currentPath, line({ ...header(2), isSeeded: false }) + line({ ...event0, time: 99 }))
|
||||
await expect(ensureJsonlGenerationCurrent({
|
||||
const conflicted = await prepareJsonlMigration({
|
||||
...different,
|
||||
verifyCurrentFile: verifier(),
|
||||
})).rejects.toBeInstanceOf(JsonlGenerationTargetConflictError)
|
||||
})
|
||||
await expect(conflicted.publish()).rejects.toBeInstanceOf(JsonlGenerationTargetConflictError)
|
||||
|
||||
const uncheckedRoot = await tempRoot()
|
||||
const unchecked = options(uncheckedRoot, 'none', streamingAdapter())
|
||||
@@ -376,17 +487,19 @@ describe('JSONL immutable generation publication', () => {
|
||||
unchecked.currentPath,
|
||||
line({ ...header(2), isSeeded: false }) + line({ ...event0, time: 99 }),
|
||||
)
|
||||
await expect(ensureJsonlGenerationCurrent({
|
||||
const uncheckedPublication = await prepareJsonlMigration({
|
||||
...unchecked,
|
||||
verifyCurrentFile: byteVerifier,
|
||||
})).rejects.toThrow(/target bytes differ from the migrated generation/)
|
||||
})
|
||||
await expect(uncheckedPublication.publish())
|
||||
.rejects.toThrow(/target bytes differ from the migrated generation/)
|
||||
})
|
||||
|
||||
it('handles empty, incomplete-record, and torn Zstandard migration sources', async () => {
|
||||
const emptyRoot = await tempRoot()
|
||||
const empty = options(emptyRoot, 'zstd', streamingAdapter())
|
||||
await writeFile(empty.sourcePath, Buffer.alloc(0))
|
||||
await expect(ensureJsonlGenerationCurrent({ ...empty, verifyCurrentFile: vi.fn() }))
|
||||
await expect(prepareJsonlMigration({ ...empty, verifyCurrentFile: vi.fn() }))
|
||||
.rejects.toThrow('empty or header-less Zstandard')
|
||||
|
||||
const incompleteRoot = await tempRoot()
|
||||
@@ -395,7 +508,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
await compressZstdFrame(line(header(0))),
|
||||
await compressZstdFrame(JSON.stringify(event0)),
|
||||
]))
|
||||
await expect(ensureJsonlGenerationCurrent({ ...incomplete, verifyCurrentFile: vi.fn() }))
|
||||
await expect(prepareJsonlMigration({ ...incomplete, verifyCurrentFile: vi.fn() }))
|
||||
.rejects.toThrow('complete frame contains a torn JSONL record')
|
||||
|
||||
const tornRoot = await tempRoot()
|
||||
@@ -405,11 +518,12 @@ describe('JSONL immutable generation publication', () => {
|
||||
await compressZstdFrame(line(header(0))),
|
||||
tornBody.subarray(0, -3),
|
||||
]))
|
||||
await ensureJsonlGenerationCurrent({
|
||||
const recovered = await prepareJsonlMigration({
|
||||
...torn,
|
||||
verifyCurrentFile: verifier(),
|
||||
})
|
||||
expect((await decodeZstdJsonl(torn.currentPath)).trimEnd().split('\n')).toHaveLength(3)
|
||||
expect(recovered.artifact.events).toEqual([event0, event1])
|
||||
await recovered.publish()
|
||||
|
||||
const emptyTailRoot = await tempRoot()
|
||||
const emptyTail = options(emptyTailRoot, 'zstd', streamingAdapter())
|
||||
@@ -417,11 +531,12 @@ describe('JSONL immutable generation publication', () => {
|
||||
await compressZstdFrame(line(header(0))),
|
||||
tornBody.subarray(0, 8),
|
||||
]))
|
||||
await ensureJsonlGenerationCurrent({
|
||||
const withoutTail = await prepareJsonlMigration({
|
||||
...emptyTail,
|
||||
verifyCurrentFile: verifier(),
|
||||
})
|
||||
expect((await decodeZstdJsonl(emptyTail.currentPath)).trimEnd().split('\n')).toHaveLength(1)
|
||||
expect(withoutTail.artifact.events).toEqual([])
|
||||
await withoutTail.publish()
|
||||
})
|
||||
|
||||
it('checks migration and verification identities exactly', async () => {
|
||||
@@ -441,13 +556,18 @@ describe('JSONL immutable generation publication', () => {
|
||||
const mismatchRoot = await tempRoot()
|
||||
const mismatch = options(mismatchRoot, 'none', streamingAdapter())
|
||||
await writeFile(mismatch.sourcePath, line(header(0)))
|
||||
await expect(ensureJsonlGenerationCurrent({
|
||||
const mismatched = await prepareJsonlMigration({
|
||||
...mismatch,
|
||||
verifyCurrentFile: async (path, compression, expectedId, expectedEventCount) => ({
|
||||
...await verifyJsonlCurrentGeneration(path, compression, expectedId, expectedEventCount),
|
||||
digest: 'different',
|
||||
}),
|
||||
})).rejects.toThrow('changed during verification')
|
||||
})
|
||||
await expect(mismatched.publish()).rejects.toThrow('changed during verification')
|
||||
|
||||
const current = options(await tempRoot(), 'none', streamingAdapter(), 2)
|
||||
await expect(prepareJsonlMigration({ ...current, verifyCurrentFile: vi.fn() }))
|
||||
.rejects.toThrow('requires a historical source')
|
||||
|
||||
const wrongRoot = await tempRoot()
|
||||
const wrongFormat = streamingAdapter()
|
||||
@@ -464,7 +584,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
}),
|
||||
})
|
||||
await writeFile(wrong.sourcePath, line(header(0)))
|
||||
await expect(ensureJsonlGenerationCurrent({ ...wrong, verifyCurrentFile: vi.fn() }))
|
||||
await expect(prepareJsonlMigration({ ...wrong, verifyCurrentFile: vi.fn() }))
|
||||
.rejects.toThrow('migration returned v0')
|
||||
})
|
||||
|
||||
@@ -478,12 +598,13 @@ describe('JSONL immutable generation publication', () => {
|
||||
sync: async () => {},
|
||||
close: async () => { throw new Error('close failed') },
|
||||
} as unknown as FileHandle
|
||||
await expect(createJsonlGenerationTestRuntime({
|
||||
const failedPreparation = await createJsonlGenerationTestRuntime({
|
||||
fs: { open: async () => failedHandle },
|
||||
}).ensure({
|
||||
}).prepare({
|
||||
...failed,
|
||||
verifyCurrentFile: vi.fn(),
|
||||
})).rejects.toBeInstanceOf(AggregateError)
|
||||
})
|
||||
await expect(failedPreparation.publish()).rejects.toBeInstanceOf(AggregateError)
|
||||
})
|
||||
|
||||
it('propagates a streamed encoder failure through the Zstandard pipeline', async () => {
|
||||
@@ -494,77 +615,26 @@ describe('JSONL immutable generation publication', () => {
|
||||
}))
|
||||
await writeFile(request.sourcePath, await encodeZstd(0, [event0]))
|
||||
|
||||
await expect(ensureJsonlGenerationCurrent({
|
||||
const prepared = await prepareJsonlMigration({
|
||||
...request,
|
||||
verifyCurrentFile: vi.fn(),
|
||||
})).rejects.toBe(failure)
|
||||
})
|
||||
await expect(prepared.publish()).rejects.toBe(failure)
|
||||
expect(await readdir(root)).toEqual(['session.jsonl.zstd'])
|
||||
})
|
||||
|
||||
it('observes cancellation at the existing encode yield boundary', async () => {
|
||||
const root = await tempRoot()
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancelled during encoding')
|
||||
const request = { ...options(root), signal: controller.signal }
|
||||
const payload = 'x'.repeat(600 * 1024)
|
||||
await writeFile(request.sourcePath, line(header(0)) + line({
|
||||
...event0, data: { turn: 1, payload },
|
||||
}) + line({
|
||||
...event1, data: { turn: 1, reason: { kind: 'completed' }, payload },
|
||||
}))
|
||||
vi.spyOn(performance, 'now').mockReturnValue(0)
|
||||
let yields = 0
|
||||
vi.spyOn(scheduler, 'yield').mockImplementation(async () => {
|
||||
yields += 1
|
||||
if (yields === 2) controller.abort(reason)
|
||||
})
|
||||
|
||||
await expect(ensureJsonlGenerationCurrent({
|
||||
...request,
|
||||
verifyCurrentFile: vi.fn(),
|
||||
})).rejects.toBe(reason)
|
||||
expect(yields).toBe(2)
|
||||
expect(await readdir(root)).toEqual(['session.jsonl'])
|
||||
})
|
||||
|
||||
it('forwards cancellation to staged verification', async () => {
|
||||
const root = await tempRoot()
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancelled during verification')
|
||||
const request = { ...options(root), signal: controller.signal }
|
||||
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
||||
const verifyCurrentFile: EnsureJsonlGenerationOptions['verifyCurrentFile'] = async (
|
||||
_path,
|
||||
_compression,
|
||||
_expectedId,
|
||||
_expectedEventCount,
|
||||
_expectedPrefix,
|
||||
signal,
|
||||
) => {
|
||||
expect(signal).toBe(controller.signal)
|
||||
controller.abort(reason)
|
||||
signal?.throwIfAborted()
|
||||
throw new Error('unreachable')
|
||||
}
|
||||
|
||||
await expect(ensureJsonlGenerationCurrent({
|
||||
...request,
|
||||
verifyCurrentFile,
|
||||
})).rejects.toBe(reason)
|
||||
expect(await readdir(root)).toEqual(['session.jsonl'])
|
||||
})
|
||||
|
||||
it('publishes through the Windows no-overwrite path', async () => {
|
||||
it('publishes a prepared stage through the Windows no-overwrite path', async () => {
|
||||
const winRoot = await tempRoot()
|
||||
const win = options(winRoot, 'none', streamingAdapter())
|
||||
await writeFile(win.sourcePath, line(header(0)))
|
||||
await createJsonlGenerationTestRuntime({
|
||||
const winPrepared = await createJsonlGenerationTestRuntime({
|
||||
platform: 'win32',
|
||||
publishNewWin32: rename,
|
||||
}).ensure({
|
||||
}).prepare({
|
||||
...win,
|
||||
verifyCurrentFile: verifier(),
|
||||
})
|
||||
await winPrepared.publish()
|
||||
expect(await readFile(win.currentPath, 'utf8')).toContain('"version":2')
|
||||
})
|
||||
|
||||
@@ -588,56 +658,6 @@ describe('JSONL immutable generation publication', () => {
|
||||
expect((await readdir(root)).sort()).toEqual(['session.jsonl', 'session.v2.jsonl'])
|
||||
})
|
||||
|
||||
it('takes the current fast path with one read and no format callback', async () => {
|
||||
const root = await tempRoot()
|
||||
const base = adapter()
|
||||
const createRestore = vi.fn((value: Record<string, unknown>) => base.createRestore(value))
|
||||
const encodeHeader = vi.fn((value: SessionFormatArtifact['header'], cut: number) =>
|
||||
base.encodeHeader(value, cut))
|
||||
const encodeEvent = vi.fn((value: SessionFormatArtifact['events'][number]) => base.encodeEvent(value))
|
||||
const validateHistoricalHeader = vi.fn()
|
||||
const request = {
|
||||
...options(root, 'none', { ...base, createRestore, encodeHeader, encodeEvent }, 2),
|
||||
validateHistoricalHeader,
|
||||
}
|
||||
const contents = line({ ...header(2), isSeeded: false }) + line(event0)
|
||||
await writeFile(request.sourcePath, contents)
|
||||
const readStableFile = vi.fn(async (path: string, signal?: AbortSignal) =>
|
||||
readFile(path, signal === undefined ? undefined : { signal }))
|
||||
|
||||
const result = await ensureWithOverrides(request, { fs: { readFile: readStableFile } })
|
||||
|
||||
expect(result).toMatchObject({ status: 'current', version: 2, path: request.sourcePath })
|
||||
expect(readStableFile).toHaveBeenCalledOnce()
|
||||
expect(createRestore).not.toHaveBeenCalled()
|
||||
expect(encodeHeader).not.toHaveBeenCalled()
|
||||
expect(encodeEvent).not.toHaveBeenCalled()
|
||||
expect(validateHistoricalHeader).not.toHaveBeenCalled()
|
||||
expect(await readFile(request.sourcePath, 'utf8')).toBe(contents)
|
||||
})
|
||||
|
||||
it('bounds current snapshot retries under continuous revision churn', async () => {
|
||||
const root = await tempRoot()
|
||||
const request = options(root, 'none', adapter(), 2)
|
||||
const contents = line(header(2)) + line(event0)
|
||||
await writeFile(request.sourcePath, contents)
|
||||
let revision = 0n
|
||||
const statFile = vi.fn(async (path: string) => {
|
||||
const value = await stat(path, { bigint: true })
|
||||
revision += 1n
|
||||
return { ...value, mtimeNs: value.mtimeNs + revision }
|
||||
})
|
||||
const readChangingFile = vi.fn(async () => Buffer.from(contents + line(event1)))
|
||||
|
||||
const result = await ensureWithOverrides(request, {
|
||||
fs: { stat: statFile, readFile: readChangingFile },
|
||||
})
|
||||
|
||||
expect(result.snapshot.bytes.toString('utf8')).toBe(contents)
|
||||
expect(readChangingFile).toHaveBeenCalledTimes(2)
|
||||
expect(statFile).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it.each(['none', 'zstd'] as const)(
|
||||
'validates the selected %s historical header before invoking migration',
|
||||
async (compression) => {
|
||||
@@ -700,24 +720,15 @@ describe('JSONL immutable generation publication', () => {
|
||||
expect(await readdir(root)).toEqual(['session.jsonl'])
|
||||
})
|
||||
|
||||
it('rejects malformed and future version discriminators before migration', async () => {
|
||||
it('rejects a malformed version discriminator before migration', async () => {
|
||||
const root = await tempRoot()
|
||||
const malformed = options(join(root, 'malformed'))
|
||||
const future = options(join(root, 'future'), 'none', adapter(), 3)
|
||||
await mkdir(join(root, 'malformed'))
|
||||
await mkdir(join(root, 'future'))
|
||||
await writeFile(malformed.sourcePath, line(header(-1)))
|
||||
await writeFile(future.sourcePath, line(header(3, 'future-id')))
|
||||
|
||||
await expect(ensureJsonlGenerationCurrent(malformed)).rejects.toThrow(
|
||||
'header version is not a non-negative safe integer',
|
||||
)
|
||||
await expect(ensureJsonlGenerationCurrent(future)).rejects.toMatchObject({
|
||||
name: 'JsonlGenerationNewerVersionError',
|
||||
storedVersion: 3,
|
||||
currentVersion: 2,
|
||||
storedId: 'future-id',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -976,7 +987,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('retries a bracketed physical read and a source changed before publication', async () => {
|
||||
it('bounds a bracketed physical read and does not rerun migration after a publication race', async () => {
|
||||
const root = await tempRoot()
|
||||
const request = options(root)
|
||||
const first = Buffer.from(line(header(0)) + line(event0))
|
||||
@@ -995,17 +1006,15 @@ describe('JSONL immutable generation publication', () => {
|
||||
if (phase === 'before-source-check' && attempt === 1) await writeFile(request.sourcePath, second)
|
||||
})
|
||||
|
||||
await ensureWithOverrides(
|
||||
await expect(ensureWithOverrides(
|
||||
{ ...request, format: { ...base, createRestore } },
|
||||
{ fs: { stat: statFile }, barrier },
|
||||
)
|
||||
)).rejects.toBeInstanceOf(JsonlGenerationSourceChangedError)
|
||||
|
||||
expect(stats).toBeGreaterThan(2)
|
||||
expect(createRestore).toHaveBeenCalledTimes(2)
|
||||
expect(createRestore).toHaveBeenCalledOnce()
|
||||
expect(await readFile(request.sourcePath)).toEqual(second)
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(
|
||||
line(header(2)) + line(event0) + line(event1),
|
||||
)
|
||||
await expect(readFile(request.currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect((await readdir(root)).every(name => !name.includes('.tmp'))).toBe(true)
|
||||
})
|
||||
|
||||
@@ -1020,7 +1029,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
if (phase === 'before-source-check' && attempt === 1) await writeFile(request.sourcePath, second)
|
||||
}
|
||||
|
||||
await expect(ensureWithOverrides(request, {
|
||||
const failure = await ensureWithOverrides(request, {
|
||||
barrier,
|
||||
fs: {
|
||||
rm: async (path: string) => {
|
||||
@@ -1028,7 +1037,10 @@ describe('JSONL immutable generation publication', () => {
|
||||
await rm(path, { force: true })
|
||||
},
|
||||
},
|
||||
})).rejects.toBe(cleanup)
|
||||
}).then(() => undefined, (error: unknown) => error)
|
||||
if (!(failure instanceof AggregateError)) throw new Error('expected source and cleanup failures')
|
||||
expect(failure.errors[0]).toBeInstanceOf(JsonlGenerationSourceChangedError)
|
||||
expect(failure.errors[1]).toBe(cleanup)
|
||||
expect(await readFile(request.sourcePath)).toEqual(second)
|
||||
await expect(readFile(request.currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
@@ -1133,7 +1145,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
const format = adapter()
|
||||
const request = {
|
||||
...options(root, 'none', format),
|
||||
verifyCurrentFile: async (...args: Parameters<EnsureJsonlGenerationOptions['verifyCurrentFile']>) => {
|
||||
verifyCurrentFile: async (...args: Parameters<PrepareJsonlMigrationOptions['verifyCurrentFile']>) => {
|
||||
validations += 1
|
||||
if (validations === 2) throw 'non-error rejection'
|
||||
return verifier()(...args)
|
||||
@@ -1175,7 +1187,26 @@ describe('JSONL immutable generation publication', () => {
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(2)) + line(event0))
|
||||
})
|
||||
|
||||
it('reports cancellation during committed reopen and leaves the target', async () => {
|
||||
it('retains a committed generation when its post-publication stat fails', async () => {
|
||||
const root = await tempRoot()
|
||||
const request = options(root)
|
||||
const statFailure = new Error('published target stat failed')
|
||||
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
||||
let targetStats = 0
|
||||
|
||||
await expect(ensureWithOverrides(request, {
|
||||
fs: {
|
||||
stat: async (path) => {
|
||||
if (path === request.currentPath && ++targetStats === 1) throw statFailure
|
||||
return stat(path, { bigint: true })
|
||||
},
|
||||
},
|
||||
})).rejects.toBe(statFailure)
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(2)) + line(event0))
|
||||
expect((await readdir(root)).every(name => !name.includes('.tmp'))).toBe(true)
|
||||
})
|
||||
|
||||
it('finishes a committed publication despite later caller cancellation', async () => {
|
||||
const root = await tempRoot()
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('stop after publication')
|
||||
@@ -1186,7 +1217,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
barrier: (phase) => {
|
||||
if (phase === 'after-publication') controller.abort(reason)
|
||||
},
|
||||
})).rejects.toBe(reason)
|
||||
})).resolves.toMatchObject({ status: 'migrated', path: request.currentPath })
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(2)) + line(event0))
|
||||
})
|
||||
|
||||
@@ -1219,7 +1250,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
})).rejects.toMatchObject({ code: 'ENOENT', path: request.currentPath })
|
||||
})
|
||||
|
||||
it('reopens a target after exclusive publication', async () => {
|
||||
it('does not reopen a target after exclusive publication', async () => {
|
||||
const root = await tempRoot()
|
||||
const request = options(root)
|
||||
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
||||
@@ -1233,7 +1264,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
},
|
||||
},
|
||||
})).resolves.toMatchObject({ status: 'migrated', path: request.currentPath })
|
||||
expect(reads).toContain(request.currentPath)
|
||||
expect(reads).not.toContain(request.currentPath)
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(2)) + line(event0))
|
||||
})
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat, symlink } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, relative, resolve } from 'node:path'
|
||||
import { scheduler } from 'node:timers/promises'
|
||||
import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
} from '../../session-persistence/tests/contract.ts'
|
||||
import { runLiveWritePathContract } from '../../session-persistence/tests/live-write-contract.ts'
|
||||
import { LIVE_WRITE_BATCH_MAX_DELAY_MS, type JsonlSessionHandle } from '../src/storage.ts'
|
||||
import { JsonlGenerationSourceChangedError } from '../src/generation.ts'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
|
||||
const statRace = vi.hoisted(() => ({
|
||||
@@ -43,6 +45,21 @@ const readTally = vi.hoisted(() => ({
|
||||
enabled: false,
|
||||
}))
|
||||
|
||||
const readFailure = vi.hoisted(() => ({
|
||||
path: undefined as string | undefined,
|
||||
error: undefined as Error | undefined,
|
||||
}))
|
||||
|
||||
const pausedRead = vi.hoisted(() => ({
|
||||
path: undefined as string | undefined,
|
||||
active: false,
|
||||
entered: undefined as (() => void) | undefined,
|
||||
resume: undefined as Promise<void> | undefined,
|
||||
release: undefined as (() => void) | undefined,
|
||||
done: undefined as Promise<void> | undefined,
|
||||
finished: undefined as (() => void) | undefined,
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
@@ -57,10 +74,25 @@ vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
return { ...identity, mtimeNs: identity.mtimeNs + 1n }
|
||||
}) as typeof actual.stat,
|
||||
readFile: (async (...args: Parameters<typeof actual.readFile>) => {
|
||||
if (readTally.enabled && typeof args[0] === 'string') {
|
||||
readTally.bySuffix.set(args[0], (readTally.bySuffix.get(args[0]) ?? 0) + 1)
|
||||
const path = typeof args[0] === 'string' ? args[0] : undefined
|
||||
if (path === readFailure.path && readFailure.error !== undefined) throw readFailure.error
|
||||
if (readTally.enabled && path !== undefined) {
|
||||
readTally.bySuffix.set(path, (readTally.bySuffix.get(path) ?? 0) + 1)
|
||||
}
|
||||
if (path !== pausedRead.path || pausedRead.resume === undefined) {
|
||||
return actual.readFile(...args)
|
||||
}
|
||||
const resume = pausedRead.resume
|
||||
const finished = pausedRead.finished
|
||||
pausedRead.active = true
|
||||
pausedRead.entered?.()
|
||||
await resume
|
||||
try {
|
||||
return await actual.readFile(...args)
|
||||
} finally {
|
||||
pausedRead.active = false
|
||||
finished?.()
|
||||
}
|
||||
return actual.readFile(...args)
|
||||
}) as typeof actual.readFile,
|
||||
readdir: (async (...args: Parameters<typeof actual.readdir>) => {
|
||||
if (String(args[0]) === readdirFailure.path && readdirFailure.error !== undefined) {
|
||||
@@ -107,6 +139,27 @@ async function freshRoot(): Promise<string> {
|
||||
return dir
|
||||
}
|
||||
|
||||
function pausePhysicalRead(path: string): {
|
||||
readonly entered: Promise<void>
|
||||
readonly finished: Promise<void>
|
||||
release(): void
|
||||
} {
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const resume = Promise.withResolvers<undefined>()
|
||||
const finished = Promise.withResolvers<undefined>()
|
||||
pausedRead.path = path
|
||||
pausedRead.entered = () => { entered.resolve(undefined) }
|
||||
pausedRead.resume = resume.promise
|
||||
pausedRead.release = () => { resume.resolve(undefined) }
|
||||
pausedRead.done = finished.promise
|
||||
pausedRead.finished = () => { finished.resolve(undefined) }
|
||||
return {
|
||||
entered: entered.promise,
|
||||
finished: finished.promise,
|
||||
release: () => { resume.resolve(undefined) },
|
||||
}
|
||||
}
|
||||
|
||||
function rawLogPath(root: string, cwd: string | undefined, id: SessionId): string {
|
||||
return logPath(root, cwd, id, 'none')
|
||||
}
|
||||
@@ -178,7 +231,7 @@ async function writeLog(persistence: SessionPersistence, m: SessionHeader, event
|
||||
async function readAll(persistence: SessionPersistence, id: SessionId): Promise<{ meta: SessionHeader; events: readonly SessionEvent[] }> {
|
||||
const handle = await persistence.open(id, 'read')
|
||||
try {
|
||||
return { meta: handle.header, events: await handle.read() }
|
||||
return { meta: handle.header, events: (await handle.read()).events }
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
@@ -200,6 +253,18 @@ afterEach(async () => {
|
||||
statRace.mode = 'settle'
|
||||
readTally.bySuffix.clear()
|
||||
readTally.enabled = false
|
||||
const pausedReadDone = pausedRead.active ? pausedRead.done : undefined
|
||||
readFailure.path = undefined
|
||||
readFailure.error = undefined
|
||||
pausedRead.release?.()
|
||||
await pausedReadDone
|
||||
pausedRead.path = undefined
|
||||
pausedRead.active = false
|
||||
pausedRead.entered = undefined
|
||||
pausedRead.resume = undefined
|
||||
pausedRead.release = undefined
|
||||
pausedRead.done = undefined
|
||||
pausedRead.finished = undefined
|
||||
statFailure.path = undefined
|
||||
statFailure.error = undefined
|
||||
readdirFailure.path = undefined
|
||||
@@ -451,6 +516,17 @@ describe('JsonlSessionPersistence: stored-format refusals', () => {
|
||||
expect(await ctx.sessionPersistence.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('classifies an unparsable future generation header as corruption', async () => {
|
||||
const id = SessionId('future-malformed')
|
||||
const path = generationLogPath(root, '/work', id, 42, 'none')
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, '{not-json}\n')
|
||||
|
||||
await expect(ctx.sessionPersistence.open(id, 'read')).rejects.toMatchObject({
|
||||
name: 'SessionPersistenceCorruptionError',
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a well-shaped newer-version header at read open with the upgrade direction', async () => {
|
||||
// A header that satisfies the current shape but carries a future version:
|
||||
// stat can parse it, and the open still refuses before handing out a
|
||||
@@ -524,7 +600,14 @@ describe('JsonlSessionPersistence: stored-format refusals', () => {
|
||||
const handle = await ctx.sessionPersistence.open(m.id, 'read', { signal: new AbortController().signal })
|
||||
try {
|
||||
expect(handle.header).toMatchObject({ id: m.id, cwd: '/work' })
|
||||
expect(await handle.read()).toEqual(oneTurnLog())
|
||||
const read = await handle.read()
|
||||
expect(read.eventState).toBe('shared-frozen')
|
||||
expect(read.events).toEqual(oneTurnLog())
|
||||
expect(read.events.every(event => Object.isFrozen(event) && Object.isFrozen(event.data))).toBe(true)
|
||||
const reread = await handle.read()
|
||||
expect(reread.events).not.toBe(read.events)
|
||||
expect(reread.events[0]).toBe(read.events[0])
|
||||
expect((await handle.read(read.events.length)).eventState).toBe('shared-frozen')
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
@@ -597,7 +680,7 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
|
||||
await expect(stat(currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('publishes v2 beside an unchanged v0 source before returning a read handle', async () => {
|
||||
it('serves a migrated v0 read without publishing at the service durability barrier', async () => {
|
||||
const header = meta('released-v0-read', '/work')
|
||||
const sourcePath = historicalLogPath(root, header.cwd, header.id)
|
||||
const currentPath = rawLogPath(root, header.cwd, header.id)
|
||||
@@ -607,18 +690,147 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
|
||||
await mkdir(dirname(sourcePath), { recursive: true })
|
||||
await writeFile(sourcePath, source)
|
||||
|
||||
await expect(readAll(ctx.sessionPersistence, header.id)).resolves.toEqual({
|
||||
const restored = await readAll(ctx.sessionPersistence, header.id)
|
||||
expect(restored).toEqual({
|
||||
meta: { ...header, delegationDepth: 0 },
|
||||
events: oneTurnLog(),
|
||||
})
|
||||
const userMessage = restored.events.find(event => event.type === 'user/message')
|
||||
expect(userMessage).toBeDefined()
|
||||
expect(Object.isFrozen(userMessage?.data)).toBe(true)
|
||||
expect(await readFile(sourcePath)).toEqual(source)
|
||||
const current = (await readFile(currentPath, 'utf8')).trimEnd().split('\n')
|
||||
expect(JSON.parse(current[0] as string)).toMatchObject({
|
||||
id: header.id,
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
})
|
||||
await expect(readFile(currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect((await readdir(dirname(sourcePath))).filter(name => name.startsWith('session')).sort())
|
||||
.toEqual(['session.jsonl', 'session.v2.jsonl'])
|
||||
.toEqual(['session.jsonl'])
|
||||
})
|
||||
|
||||
it('resolves absent, current, and historical current-generation paths', async () => {
|
||||
const persistence = ctx.sessionPersistence as JsonlSessionPersistence
|
||||
expect(await persistence.resolveCurrentLog(SessionId('missing-generation'))).toBeUndefined()
|
||||
|
||||
const current = meta('resolved-current', '/work')
|
||||
const currentHandle = await ctx.sessionPersistence.create(current)
|
||||
await currentHandle.flush()
|
||||
await currentHandle.close()
|
||||
await expect(persistence.resolveCurrentLog(current.id)).resolves.toBe(rawLogPath(root, current.cwd, current.id))
|
||||
|
||||
const historical = meta('resolved-historical', '/work')
|
||||
const sourcePath = historicalLogPath(root, historical.cwd, historical.id)
|
||||
await mkdir(dirname(sourcePath), { recursive: true })
|
||||
await writeFile(sourcePath, `${JSON.stringify(releasedV0Header(historical))}\n`)
|
||||
await expect(persistence.resolveCurrentLog(historical.id)).resolves.toBeUndefined()
|
||||
|
||||
const future = meta('resolved-future', '/work')
|
||||
const futurePath = generationLogPath(root, future.cwd, future.id, SESSION_FORMAT_VERSION + 1, 'none')
|
||||
await mkdir(dirname(futurePath), { recursive: true })
|
||||
await writeFile(futurePath, `${JSON.stringify({ ...toHeaderLine(future), version: SESSION_FORMAT_VERSION + 1 })}\n`)
|
||||
await expect(persistence.resolveCurrentLog(future.id)).rejects.toMatchObject({
|
||||
name: 'SessionFormatUnsupportedError',
|
||||
})
|
||||
})
|
||||
|
||||
it('singleflights concurrent historical reads and keeps service flush read-only', async () => {
|
||||
const header = meta('released-v0-source-drift', '/work')
|
||||
const sourcePath = historicalLogPath(root, header.cwd, header.id)
|
||||
await mkdir(dirname(sourcePath), { recursive: true })
|
||||
await writeFile(sourcePath, `${JSON.stringify(releasedV0Header(header))}\n`)
|
||||
readTally.enabled = true
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
ctx.sessionPersistence.open(header.id, 'read'),
|
||||
ctx.sessionPersistence.open(header.id, 'read'),
|
||||
])
|
||||
expect((await first.read()).events).toEqual([])
|
||||
expect((await second.read()).events).toEqual([])
|
||||
expect(readTally.bySuffix.get(sourcePath)).toBe(1)
|
||||
await appendFile(sourcePath, '\n')
|
||||
|
||||
await expect(ctx.sessionPersistence.flush()).resolves.toBeUndefined()
|
||||
await expect(stat(rawLogPath(root, header.cwd, header.id))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
await Promise.all([first.close(), second.close()])
|
||||
await ctx.fiber.dispose()
|
||||
ctx = new Context()
|
||||
})
|
||||
|
||||
it('does not join an in-flight historical preparation for an older source revision', async () => {
|
||||
const header = meta('released-v0-revision-singleflight', '/work')
|
||||
const sourcePath = historicalLogPath(root, header.cwd, header.id)
|
||||
await mkdir(dirname(sourcePath), { recursive: true })
|
||||
await writeFile(sourcePath, `${JSON.stringify(releasedV0Header(header))}\n`)
|
||||
const pause = pausePhysicalRead(sourcePath)
|
||||
readTally.enabled = true
|
||||
|
||||
const firstOpening = ctx.sessionPersistence.open(header.id, 'read')
|
||||
await pause.entered
|
||||
await appendFile(sourcePath, `${eventLines(releasedV1OneTurnLog())}\n`)
|
||||
const secondOpening = ctx.sessionPersistence.open(header.id, 'read')
|
||||
let tallyFailure: unknown
|
||||
try {
|
||||
await vi.waitFor(() => { expect(readTally.bySuffix.get(sourcePath)).toBe(2) })
|
||||
} catch (error: unknown) {
|
||||
tallyFailure = error
|
||||
} finally {
|
||||
pause.release()
|
||||
}
|
||||
|
||||
const [first, second] = await Promise.all([firstOpening, secondOpening])
|
||||
try {
|
||||
if (tallyFailure !== undefined) throw tallyFailure
|
||||
expect((await first.read()).events).toEqual(oneTurnLog())
|
||||
expect((await second.read()).events).toEqual(oneTurnLog())
|
||||
} finally {
|
||||
await Promise.all([first.close(), second.close()])
|
||||
}
|
||||
})
|
||||
|
||||
it('lets one historical-open caller abort without cancelling another waiter', async () => {
|
||||
const header = meta('released-v0-shared-cancellation', '/work')
|
||||
const sourcePath = historicalLogPath(root, header.cwd, header.id)
|
||||
await mkdir(dirname(sourcePath), { recursive: true })
|
||||
await writeFile(sourcePath, `${JSON.stringify(releasedV0Header(header))}\n`)
|
||||
const pause = pausePhysicalRead(sourcePath)
|
||||
readTally.enabled = true
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('first historical waiter cancelled')
|
||||
|
||||
const first = ctx.sessionPersistence.open(header.id, 'read', { signal: controller.signal })
|
||||
const second = ctx.sessionPersistence.open(header.id, 'read')
|
||||
await pause.entered
|
||||
await scheduler.yield()
|
||||
controller.abort(reason)
|
||||
await expect(first).rejects.toBe(reason)
|
||||
pause.release()
|
||||
const handle = await second
|
||||
expect((await handle.read()).events).toEqual([])
|
||||
expect(readTally.bySuffix.get(sourcePath)).toBe(1)
|
||||
await handle.close()
|
||||
})
|
||||
|
||||
it('cancels shared historical preparation after its last waiter leaves', async () => {
|
||||
const header = meta('released-v0-last-waiter-cancellation', '/work')
|
||||
const sourcePath = historicalLogPath(root, header.cwd, header.id)
|
||||
await mkdir(dirname(sourcePath), { recursive: true })
|
||||
await writeFile(sourcePath, `${JSON.stringify(releasedV0Header(header))}\n`)
|
||||
const pause = pausePhysicalRead(sourcePath)
|
||||
readTally.enabled = true
|
||||
const controller = new AbortController()
|
||||
const reason = 'last historical waiter cancelled'
|
||||
|
||||
const opening = ctx.sessionPersistence.open(header.id, 'read', { signal: controller.signal })
|
||||
await pause.entered
|
||||
controller.abort(reason)
|
||||
await expect(opening).rejects.toMatchObject({
|
||||
message: 'session migration preparation aborted',
|
||||
cause: reason,
|
||||
})
|
||||
pause.release()
|
||||
await pause.finished
|
||||
await scheduler.yield()
|
||||
|
||||
const retried = await ctx.sessionPersistence.open(header.id, 'read')
|
||||
expect((await retried.read()).events).toEqual([])
|
||||
expect(readTally.bySuffix.get(sourcePath)).toBe(2)
|
||||
await retried.close()
|
||||
})
|
||||
|
||||
it('migrates released-v0 retry, repeated-compaction, provenance, and late-title shapes', async () => {
|
||||
@@ -648,16 +860,15 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
|
||||
expect(titleBlock).toMatchObject({ type: 'text' })
|
||||
if (titleBlock?.type !== 'text') throw new Error('fixture title request lacks its text block')
|
||||
expect(titleBlock.text).toContain('{"seq":21,"text":"late"}')
|
||||
const currentRows = (await readFile(currentPath, 'utf8')).trimEnd().split('\n')
|
||||
.map(line => JSON.parse(line) as Record<string, unknown>)
|
||||
expect(currentRows.find(row => row['type'] === 'user/message'
|
||||
&& (row['data'] as { source?: { plugin?: string } }).source?.plugin === 'compact'))
|
||||
expect(restored.events.find(event => event.type === 'user/message'
|
||||
&& (event.data as { source?: { plugin?: string } }).source?.plugin === 'compact'))
|
||||
.toMatchObject({
|
||||
seq: 14,
|
||||
sourceEventSeqs: [12, 13, 11, 2, 3, 4],
|
||||
surfaceOp: { op: 'replace', start: 11, end: 4 },
|
||||
})
|
||||
expect(await readFile(sourcePath)).toEqual(source)
|
||||
await expect(readFile(currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('publishes v2 beside an unchanged physical v1 source with packed chunk rows', async () => {
|
||||
@@ -677,10 +888,9 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
|
||||
.toMatchObject({ data: { message: { content: [{ type: 'text', text: 'hello' }] } } })
|
||||
|
||||
expect(await readFile(sourcePath)).toEqual(source)
|
||||
expect(JSON.parse((await readFile(currentPath, 'utf8')).split('\n')[0] as string))
|
||||
.toMatchObject({ id: header.id, version: SESSION_FORMAT_VERSION })
|
||||
await expect(readFile(currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect((await readdir(dirname(sourcePath))).filter(name => name.startsWith('session')).sort())
|
||||
.toEqual(['session.v1.jsonl', 'session.v2.jsonl'])
|
||||
.toEqual(['session.v1.jsonl'])
|
||||
})
|
||||
|
||||
it('selects v1 from a v0/v1 directory, then v2 from the retained three-generation set', async () => {
|
||||
@@ -695,8 +905,12 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
|
||||
|
||||
const migrated = await readAll(ctx.sessionPersistence, header.id)
|
||||
expect(migrated.events.map(event => event.type)).toContain('assistant/message')
|
||||
const writer = await ctx.sessionPersistence.open(header.id, 'write')
|
||||
await writer.close()
|
||||
expect((await readdir(directory)).filter(name => name.startsWith('session')).sort())
|
||||
.toEqual(['session.jsonl', 'session.v1.jsonl', 'session.v2.jsonl'])
|
||||
.toEqual(process.platform === 'win32'
|
||||
? ['session.jsonl', 'session.v1.jsonl', 'session.v2.jsonl']
|
||||
: ['session.jsonl', 'session.lock', 'session.v1.jsonl', 'session.v2.jsonl'])
|
||||
|
||||
await writeFile(v0Path, 'corrupt lower v0\n')
|
||||
await writeFile(v1Path, 'corrupt lower v1\n')
|
||||
@@ -704,18 +918,17 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
|
||||
expect(await readFile(v2Path, 'utf8')).toContain('"version":2')
|
||||
})
|
||||
|
||||
it('uses the same migration path for a handle storage resolution', async () => {
|
||||
it('does not publish a historical generation through handle storage resolution', async () => {
|
||||
const header = meta('released-v0-handle-read', '/work')
|
||||
const sourcePath = historicalLogPath(root, header.cwd, header.id)
|
||||
const currentPath = rawLogPath(root, header.cwd, header.id)
|
||||
await mkdir(dirname(sourcePath), { recursive: true })
|
||||
await writeFile(sourcePath, `${JSON.stringify(releasedV0Header(header))}\n`)
|
||||
const storage = ctx.sessionPersistence as unknown as {
|
||||
resolveLog(id: SessionId, signal?: AbortSignal): Promise<string | undefined>
|
||||
}
|
||||
const persistence = ctx.sessionPersistence as JsonlSessionPersistence
|
||||
|
||||
await expect(storage.resolveLog(header.id, new AbortController().signal)).resolves.toBe(currentPath)
|
||||
await expect(persistence.resolveCurrentLog(header.id, new AbortController().signal)).resolves.toBeUndefined()
|
||||
expect(await readFile(sourcePath, 'utf8')).toBe(`${JSON.stringify(releasedV0Header(header))}\n`)
|
||||
await expect(readFile(currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('opens the migrated successor for append while retaining the historical source', async () => {
|
||||
@@ -740,6 +953,68 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('switches an existing prepared read handle to the published append tail', async () => {
|
||||
const header = meta('released-v0-read-handoff', '/work')
|
||||
const sourcePath = historicalLogPath(root, header.cwd, header.id)
|
||||
await mkdir(dirname(sourcePath), { recursive: true })
|
||||
await writeFile(
|
||||
sourcePath,
|
||||
`${JSON.stringify(releasedV0Header(header))}\n${eventLines(releasedV1OneTurnLog())}\n`,
|
||||
)
|
||||
const reader = await ctx.sessionPersistence.open(header.id, 'read')
|
||||
const suffix: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: SessionSeq(6), time: 9, data: { turn: 2 } },
|
||||
{ type: 'turn/end', seq: SessionSeq(7), time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
]
|
||||
try {
|
||||
expect((await reader.read()).events).toEqual(oneTurnLog())
|
||||
await appendBatch(ctx.sessionPersistence, header.id, suffix)
|
||||
expect((await reader.read()).events).toEqual([...oneTurnLog(), ...suffix])
|
||||
} finally {
|
||||
await reader.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails a stale prepared publication once and re-prepares on the next write open', async () => {
|
||||
const header = meta('released-v0-write-source-drift', '/work')
|
||||
const sourcePath = historicalLogPath(root, header.cwd, header.id)
|
||||
const currentPath = rawLogPath(root, header.cwd, header.id)
|
||||
const source = `${JSON.stringify(releasedV0Header(header))}\n`
|
||||
await mkdir(dirname(sourcePath), { recursive: true })
|
||||
await writeFile(sourcePath, source)
|
||||
await readAll(ctx.sessionPersistence, header.id)
|
||||
vi.spyOn(scheduler, 'yield').mockImplementationOnce(async () => {
|
||||
await appendFile(sourcePath, '\n')
|
||||
})
|
||||
|
||||
await expect(ctx.sessionPersistence.open(header.id, 'write'))
|
||||
.rejects.toBeInstanceOf(JsonlGenerationSourceChangedError)
|
||||
await expect(readFile(currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
|
||||
const writer = await ctx.sessionPersistence.open(header.id, 'write')
|
||||
await writer.close()
|
||||
expect(await readFile(sourcePath, 'utf8')).toBe(`${source}\n`)
|
||||
expect(await readFile(currentPath, 'utf8')).toContain('"version":2')
|
||||
})
|
||||
|
||||
it('finishes publication before rejecting a write open cancelled during publication', async () => {
|
||||
const header = meta('released-v0-publication-cancellation', '/work')
|
||||
const sourcePath = historicalLogPath(root, header.cwd, header.id)
|
||||
const currentPath = rawLogPath(root, header.cwd, header.id)
|
||||
await mkdir(dirname(sourcePath), { recursive: true })
|
||||
await writeFile(sourcePath, `${JSON.stringify(releasedV0Header(header))}\n`)
|
||||
await readAll(ctx.sessionPersistence, header.id)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('write open cancelled during publication')
|
||||
vi.spyOn(scheduler, 'yield').mockImplementationOnce(async () => { controller.abort(reason) })
|
||||
|
||||
await expect(ctx.sessionPersistence.open(header.id, 'write', { signal: controller.signal }))
|
||||
.rejects.toBe(reason)
|
||||
expect(await readFile(currentPath, 'utf8')).toContain('"version":2')
|
||||
const writer = await ctx.sessionPersistence.open(header.id, 'write')
|
||||
await writer.close()
|
||||
})
|
||||
|
||||
it('treats a historical generation as an existing id at create', async () => {
|
||||
const header = meta('released-v0-collision', '/work')
|
||||
const sourcePath = historicalLogPath(root, header.cwd, header.id)
|
||||
@@ -822,6 +1097,12 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
|
||||
await expect(ctx.sessionPersistence.open(header.id, 'read')).rejects.toMatchObject({ code: 'EACCES' })
|
||||
statFailure.error = new DOMException('source read aborted', 'AbortError')
|
||||
await expect(ctx.sessionPersistence.open(header.id, 'read')).rejects.toMatchObject({ name: 'AbortError' })
|
||||
statFailure.error = undefined
|
||||
readFailure.path = sourcePath
|
||||
readFailure.error = new DOMException('source read failed', 'InvalidStateError')
|
||||
await expect(ctx.sessionPersistence.open(header.id, 'read')).rejects.toMatchObject({
|
||||
name: 'SessionPersistenceCorruptionError',
|
||||
})
|
||||
})
|
||||
|
||||
it('selects the highest opposite-encoding generation for its refusal', async () => {
|
||||
@@ -930,6 +1211,7 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => {
|
||||
it('lazy materialization: create() writes no file until the first append', async () => {
|
||||
const m = meta('lazy', '/work')
|
||||
const handle = await ctx.sessionPersistence.create(m)
|
||||
expect((await handle.read()).eventState).toBe('detached')
|
||||
// create() materializes no file before the first append — while the
|
||||
// created session is already visible to this process.
|
||||
const dir = sessionDir(root, '/work', m.id)
|
||||
@@ -951,7 +1233,10 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => {
|
||||
await handle.close()
|
||||
|
||||
expect(await readFile(rawLogPath(root, '/work', m.id), 'utf8')).toBe(`${JSON.stringify(toHeaderLine(m))}\n`)
|
||||
await expect(readAll(ctx.sessionPersistence, m.id)).resolves.toMatchObject({ events: [] })
|
||||
const reader = await ctx.sessionPersistence.open(m.id, 'read')
|
||||
const read = await reader.read()
|
||||
expect(read).toEqual({ eventState: 'shared-frozen', events: [] })
|
||||
await reader.close()
|
||||
})
|
||||
|
||||
it('close drains a routed event that arrives while it waits for an in-flight append', async () => {
|
||||
@@ -1062,7 +1347,7 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => {
|
||||
// ...and the immediate write-open (resume) reuses the parsed log through
|
||||
// the revision guard instead of re-reading the file.
|
||||
const writer = await ctx.sessionPersistence.open(m.id, 'write')
|
||||
expect((await writer.read()).length).toBe(oneTurnLog().length)
|
||||
expect((await writer.read()).events.length).toBe(oneTurnLog().length)
|
||||
expect(readTally.bySuffix.get(path)).toBe(1)
|
||||
|
||||
// A local append invalidates the memo: the next cold read re-parses and
|
||||
@@ -1122,7 +1407,7 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => {
|
||||
const internals = ctx.sessionPersistence as unknown as { coldLogMemo: Map<SessionId, unknown> }
|
||||
internals.coldLogMemo.clear()
|
||||
statRace.path = rawLogPath(root, '/work', m.id)
|
||||
expect(await handle.read()).toEqual(oneTurnLog())
|
||||
expect((await handle.read()).events).toEqual(oneTurnLog())
|
||||
// The memo probe, the initial identity, the mismatching post-read stat
|
||||
// (reused as the retry's pre-read identity), and the retry's matching
|
||||
// post-read stat.
|
||||
@@ -1146,7 +1431,7 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => {
|
||||
// serves the retry's pre-read committed prefix — here the whole log.
|
||||
// Four stats: the memo probe, the initial identity, and one mismatching
|
||||
// post-read stat per bounded attempt.
|
||||
expect(await handle.read()).toEqual(oneTurnLog())
|
||||
expect((await handle.read()).events).toEqual(oneTurnLog())
|
||||
expect(statRace.reads).toBe(4)
|
||||
} finally {
|
||||
statRace.path = undefined
|
||||
@@ -1300,7 +1585,7 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => {
|
||||
|
||||
// The retry now succeeds with NO seq gap — the log is contiguous 0..7.
|
||||
await handle.append(turn2)
|
||||
expect((await handle.read()).map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
|
||||
expect((await handle.read()).events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
@@ -1412,7 +1697,7 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => {
|
||||
], { signal })).rejects.toBe(reason)
|
||||
await expect(handle.flush({ signal })).rejects.toBe(reason)
|
||||
// The aborted mutations left the log untouched.
|
||||
expect((await handle.read()).map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5])
|
||||
expect((await handle.read()).events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5])
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
@@ -1473,7 +1758,7 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => {
|
||||
await handle.append([])
|
||||
await expect(stat(rawLogPath(root, '/work', m.id))).rejects.toThrow()
|
||||
await handle.append(oneTurnLog())
|
||||
expect((await handle.read()).map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5])
|
||||
expect((await handle.read()).events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5])
|
||||
await handle.close()
|
||||
})
|
||||
|
||||
@@ -1498,7 +1783,7 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => {
|
||||
const m = meta('erased-pending')
|
||||
const creator = await ctx.sessionPersistence.create(m)
|
||||
const reader = await ctx.sessionPersistence.open(m.id, 'read')
|
||||
expect(await reader.read()).toEqual([])
|
||||
expect((await reader.read()).events).toEqual([])
|
||||
// The creator closes without ever appending: the session never existed.
|
||||
await creator.close()
|
||||
await expect(reader.read()).rejects.toThrow(/not found/)
|
||||
@@ -1510,7 +1795,7 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => {
|
||||
await writeLog(ctx.sessionPersistence, m, oneTurnLog())
|
||||
const reader = await ctx.sessionPersistence.open(m.id, 'read')
|
||||
try {
|
||||
expect(await reader.read()).toHaveLength(6)
|
||||
expect((await reader.read()).events).toHaveLength(6)
|
||||
// Committed events are never rewritten; a shorter file is damage, not a
|
||||
// legal state, and a handle must not silently backtrack.
|
||||
await writeFile(rawLogPath(root, '/work', m.id), [
|
||||
|
||||
@@ -181,7 +181,7 @@ describe('cross-process write lock', () => {
|
||||
await pendingWinner.close()
|
||||
// Reads never touch the lock.
|
||||
const reader = await second.open(SessionId('excluded'), 'read')
|
||||
expect((await reader.read()).map(event => event.seq)).toEqual([0, 1])
|
||||
expect((await reader.read()).events.map(event => event.seq)).toEqual([0, 1])
|
||||
await reader.close()
|
||||
|
||||
await holder.close()
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('two-process write lock (built lib)', () => {
|
||||
await expect(mine.open(SessionId(SESSION), 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError)
|
||||
// Reads are unaffected across processes.
|
||||
const reader = await mine.open(SessionId(SESSION), 'read')
|
||||
expect((await reader.read()).map(event => event.seq)).toEqual([0, 1])
|
||||
expect((await reader.read()).events.map(event => event.seq)).toEqual([0, 1])
|
||||
await reader.close()
|
||||
|
||||
// Crash the holder: no release runs, but the kernel drops the lock with
|
||||
@@ -60,7 +60,7 @@ describe('two-process write lock (built lib)', () => {
|
||||
await exited
|
||||
const taken = await mine.open(SessionId(SESSION), 'write')
|
||||
await taken.append([{ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }])
|
||||
expect((await taken.read()).map(event => event.seq)).toEqual([0, 1, 2])
|
||||
expect((await taken.read()).events.map(event => event.seq)).toEqual([0, 1, 2])
|
||||
await taken.close()
|
||||
} finally {
|
||||
if (holder.exitCode === null) holder.kill('SIGKILL')
|
||||
|
||||
@@ -48,10 +48,12 @@ afterEach(() => {
|
||||
|
||||
describe('migration verifier Worker lifecycle', () => {
|
||||
it('resolves only after terminating a successful Worker', async () => {
|
||||
const verification = verifyCurrentGenerationInWorker('/stage', 'none', 'session', 2)
|
||||
const expectedPrefix = { bytes: 3, digest: 'a'.repeat(64) }
|
||||
const verification = verifyCurrentGenerationInWorker('/stage', 'none', 'session', 2, expectedPrefix)
|
||||
const instance = worker()
|
||||
expect(instance.options.workerData).toEqual({
|
||||
path: '/stage', compression: 'none', expectedId: 'session', expectedEventCount: 2,
|
||||
expectedPrefix,
|
||||
})
|
||||
instance.emit('message', { ok: true, result })
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { FileHandle } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { SESSION_FORMAT_VERSION, SessionSeq, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SessionSeq, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
@@ -68,7 +68,7 @@ async function writeLog(persistence: SessionPersistence, m: SessionHeader, event
|
||||
async function readAll(persistence: SessionPersistence, id: SessionId): Promise<{ meta: SessionHeader; events: readonly SessionEvent[] }> {
|
||||
const handle = await persistence.open(id, 'read')
|
||||
try {
|
||||
return { meta: handle.header, events: await handle.read() }
|
||||
return { meta: handle.header, events: (await handle.read()).events }
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
@@ -411,7 +411,7 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => {
|
||||
expect((await readAll(ctx.sessionPersistence, header.id)).events).toEqual(oneTurnLog())
|
||||
})
|
||||
|
||||
it('publishes v2 beside an unchanged compressed v0 source before returning a read handle', async () => {
|
||||
it('serves a migrated compressed v0 read without publishing a successor', async () => {
|
||||
const root = await freshRoot()
|
||||
const ctx = await mount(root)
|
||||
const header = meta('zstd-v0-read', '/work')
|
||||
@@ -429,11 +429,7 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => {
|
||||
events: oneTurnLog(),
|
||||
})
|
||||
expect(await readFile(sourcePath)).toEqual(source)
|
||||
const current = (await decodeCompleteFrames(await readFile(currentPath))).toString().split('\n')
|
||||
expect(JSON.parse(current[0] as string)).toMatchObject({
|
||||
id: header.id,
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
})
|
||||
await expect(readFile(currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user