Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions apps/cli/src/commands/archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
type LoadedTuningConfig,
} from "../server/archives/config"
import { ARCHIVE_SIGNALS, isArchiveSignalName, type ArchiveSignalName } from "../server/archives/signals"
import { expireArchiveDay, retireLiveDay } from "../server/archives/retention"
import { validateRangeDate } from "../server/archives/paths"
import {
type CalibrationBudget,
Expand Down Expand Up @@ -119,6 +120,16 @@ const dryRunFlag = Flag.boolean("dry-run").pipe(
Flag.withDefault(false),
)

const applyFlag = Flag.boolean("apply").pipe(
Flag.withDescription("Apply the destructive operation (omitting this flag is a non-mutating refusal)"),
Flag.withDefault(false),
)

const localPortFlag = Flag.integer("port").pipe(
Flag.withDescription("Private Maple local-query port"),
Flag.withDefault(4318),
)

const keepFlag = Flag.integer("keep").pipe(
Flag.withDescription(
"Newest superseded generations to retain per signal/range (default 1; 0 reclaims all superseded)",
Expand Down Expand Up @@ -584,6 +595,61 @@ export const archiveGc = Command.make("gc", {
),
)

export const archiveExpire = Command.make("expire", {
dataDir: dataDirFlag,
archiveDir: archiveDirFlag,
scratchRoot: scratchRootFlag,
rangeDate: rangeDateArgument,
apply: applyFlag,
}).pipe(
Command.withDescription("Expire one complete active archived UTC day across all six signals"),
Command.withHandler(
Effect.fnUntraced(function* (a) {
if (!a.apply)
return yield* new ArchiveError({ message: "refusing archive expiration without --apply" })
const roots = resolveRoots(a.dataDir, a.archiveDir, a.scratchRoot)
yield* Effect.tryPromise({
try: () => expireArchiveDay(roots.dataDir, roots.archiveDir, a.rangeDate),
catch: (error) =>
new ArchiveError({ message: error instanceof Error ? error.message : String(error) }),
})
yield* Effect.sync(() =>
process.stdout.write(`${green("✓")} expired archive day ${a.rangeDate}\n`),
)
}),
),
)

export const archiveRetireLive = Command.make("retire-live", {
dataDir: dataDirFlag,
archiveDir: archiveDirFlag,
scratchRoot: scratchRootFlag,
rangeDate: rangeDateArgument,
port: localPortFlag,
apply: applyFlag,
}).pipe(
Command.withDescription("Remove one UTC day from live raw tables after complete archive verification"),
Command.withHandler(
Effect.fnUntraced(function* (a) {
if (!a.apply)
return yield* new ArchiveError({ message: "refusing live retirement without --apply" })
const roots = resolveRoots(a.dataDir, a.archiveDir, a.scratchRoot)
yield* Effect.tryPromise({
try: () =>
retireLiveDay({
dataDir: roots.dataDir,
archiveDir: roots.archiveDir,
rangeDate: a.rangeDate,
port: a.port,
}),
catch: (error) =>
new ArchiveError({ message: error instanceof Error ? error.message : String(error) }),
})
yield* Effect.sync(() => process.stdout.write(`${green("✓")} retired live day ${a.rangeDate}\n`))
}),
),
)

const formatBytes = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`
Expand Down Expand Up @@ -1764,6 +1830,8 @@ export const archive = Command.make("archive").pipe(
archiveRebuild,
archiveReconcile,
archiveGc,
archiveExpire,
archiveRetireLive,
archiveCalibrate,
archiveCalibrateRun,
archiveCalibrateSession,
Expand Down
4 changes: 4 additions & 0 deletions apps/cli/src/commands/server-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface DetachedChildArgs {
readonly dataDir: string
readonly offline: boolean
readonly chdbConfigFile: string | undefined
readonly rawTelemetryRetentionDays: number | undefined
readonly onDirtyStore: DirtyStorePolicy
}

Expand All @@ -43,6 +44,9 @@ export const buildDetachedChildArgs = (options: DetachedChildArgs): string[] =>
"--on-dirty-store",
options.onDirtyStore,
...(options.chdbConfigFile ? ["--chdb-config-file", options.chdbConfigFile] : []),
...(options.rawTelemetryRetentionDays !== undefined
? ["--raw-telemetry-retention-days", String(options.rawTelemetryRetentionDays)]
: []),
...(options.offline ? ["--offline"] : []),
]
}
13 changes: 13 additions & 0 deletions apps/cli/src/commands/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,14 @@ const chdbConfigFileFlag = Flag.optional(
),
)

const rawTelemetryRetentionDaysFlag = Flag.optional(
Flag.integer("raw-telemetry-retention-days").pipe(
Flag.withDescription(
"Override raw-table TTLs in days for coordinated archive rotation (default: preserve schema TTLs)",
),
),
)

const backgroundFlag = Flag.boolean("background").pipe(
Flag.withAlias("d"),
Flag.withDescription("Run the server detached (logs to ~/.maple/maple.log); stop with `maple stop`"),
Expand Down Expand Up @@ -232,6 +240,7 @@ const startDetached = (
dataDir: string,
offline: boolean,
chdbConfigFile: string | undefined,
rawTelemetryRetentionDays: number | undefined,
onDirtyStore: DirtyStorePolicy,
): Effect.Effect<void, ServerError> =>
Effect.gen(function* () {
Expand All @@ -248,6 +257,7 @@ const startDetached = (
dataDir,
offline,
chdbConfigFile,
rawTelemetryRetentionDays,
onDirtyStore,
})

Expand Down Expand Up @@ -303,6 +313,7 @@ export const start = Command.make("start", {
port,
dataDir: dataDirFlag,
chdbConfigFile: chdbConfigFileFlag,
rawTelemetryRetentionDays: rawTelemetryRetentionDaysFlag,
background: backgroundFlag,
offline: offlineFlag,
reset: resetFlag,
Expand Down Expand Up @@ -433,6 +444,7 @@ export const start = Command.make("start", {
dataDir,
a.offline,
Option.getOrUndefined(a.chdbConfigFile),
Option.getOrUndefined(a.rawTelemetryRetentionDays),
a.onDirtyStore,
)

Expand Down Expand Up @@ -474,6 +486,7 @@ export const start = Command.make("start", {
port: a.port,
dataDir,
configFile: Option.getOrUndefined(a.chdbConfigFile),
rawTelemetryRetentionDays: Option.getOrUndefined(a.rawTelemetryRetentionDays),
assets,
}).pipe(
Effect.mapError((e) => new ServerError({ message: `failed to start: ${e.message}` })),
Expand Down
63 changes: 49 additions & 14 deletions apps/cli/src/server/archives/listing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,21 +188,11 @@ export interface ArchiveVerification {
readonly verifiedBytes: number
}

/** Explicitly verify every selected active shard against its manifest SHA-256.
* Listing errors fail the operation before any partial success is reported. */
export const verifyActiveGenerations = async (
const verifyGenerationSummaries = async (
archiveDir: string,
signal?: ArchiveSignalName,
active: ReadonlyArray<ActiveGenerationSummary>,
signals: ReadonlyArray<string>,
): Promise<ArchiveVerification> => {
const listing = listActiveGenerations(archiveDir)
const relevantErrors = signal ? listing.errors.filter((error) => error.signal === signal) : listing.errors
if (relevantErrors.length > 0) {
const detail = relevantErrors
.map((error) => `${error.signal}/${error.rangeStart || "(root)"}: ${error.error}`)
.join("; ")
throw new Error(`refusing archive integrity verification: ${detail}`)
}
const active = signal ? listing.active.filter((summary) => summary.signal === signal) : listing.active
let shardCount = 0
let verifiedBytes = 0
for (const summary of active) {
Expand Down Expand Up @@ -242,13 +232,58 @@ export const verifyActiveGenerations = async (
}
return {
archiveDir,
signals: signal ? [signal] : listing.signals,
signals,
generationCount: active.length,
shardCount,
verifiedBytes,
}
}

/** Explicitly verify every selected active shard against its manifest SHA-256.
* Listing errors fail the operation before any partial success is reported. */
export const verifyActiveGenerations = async (
archiveDir: string,
signal?: ArchiveSignalName,
): Promise<ArchiveVerification> => {
const listing = listActiveGenerations(archiveDir)
const relevantErrors = signal ? listing.errors.filter((error) => error.signal === signal) : listing.errors
if (relevantErrors.length > 0) {
const detail = relevantErrors
.map((error) => `${error.signal}/${error.rangeStart || "(root)"}: ${error.error}`)
.join("; ")
throw new Error(`refusing archive integrity verification: ${detail}`)
}
const active = signal ? listing.active.filter((summary) => summary.signal === signal) : listing.active
return verifyGenerationSummaries(archiveDir, active, signal ? [signal] : listing.signals)
}

/** Verify exactly one active generation for a signal/day without re-hashing
* unrelated archive history. Used immediately before destructive live
* retirement. */
export const verifyActiveGeneration = async (
archiveDir: string,
signal: ArchiveSignalName,
rangeDate: string,
): Promise<ArchiveVerification> => {
const listing = listActiveGenerations(archiveDir)
const relevantErrors = listing.errors.filter(
(error) => error.signal === signal && (error.rangeStart === "" || error.rangeStart === rangeDate),
)
if (relevantErrors.length > 0) {
const detail = relevantErrors
.map((error) => `${error.signal}/${error.rangeStart || "(root)"}: ${error.error}`)
.join("; ")
throw new Error(`refusing archive integrity verification: ${detail}`)
}
const active = listing.active.filter(
(summary) => summary.signal === signal && summary.rangeStart === rangeDate,
)
if (active.length !== 1) {
throw new Error(`archive date ${rangeDate} lacks one active ${signal} generation`)
}
return verifyGenerationSummaries(archiveDir, active, [signal])
}

/**
* Resolve the active Parquet shard paths for one signal across all sealed
* ranges, excluding superseded generations. This is the machine-readable output
Expand Down
Loading