Skip to content

Commit 6b4b269

Browse files
philcunliffeclaude
andauthored
feat(sinks): partition iceberg exports by day with conversation sort (#91)
* feat(sinks): partition iceberg exports by day with conversation sort Lay out @hypaware/format-iceberg exports for an archive's job, not the cache's: partition by day(primaryTimestampColumn) — a writer-owned default, not the cache's conversation_id-identity cachePartitioning, which sets an unbounded ~1-file-per-conversation floor compaction can't beat — and sort each day partition by the dataset's lookup columns (conversation_id-led) so a conversation lookup prunes row groups by min/max instead of needing a partition per conversation. - Promote partitionSpecForDeclaration + validatePartitionSpecStability (and the declaration type) from src/core/cache/iceberg to a shared src/core/iceberg home, re-exported from src/core/index.js: they are core surface consumed by the registry, cache, plugin types, and now the export (LLP 0003). - format-iceberg derives the day grain + sort order per dataset at commit time, creates the table with both, and rejects partition-spec drift on append (iceberg_partition_spec_drift). Emits hyp_partition_spec and hyp_sort_order on commit spans. - Reframe maintenance compaction: available via icebergRewrite but not run in-daemon and not needed for a day grain (was "blocked by icebird"). Spec: LLP 0022 (rewritten from the abandoned cache-parity decision); xrefs in LLP 0014 and 0003. Tests: 10 (derivation + drift through the real icebird write path) plus a passing iceberg_export_partitioned_local_fs smoke asserting the layout and hyp_partition_spec. Clustering (icebird #22) and read pruning (#20/#21) require a published icebird containing commit 3edb15b; the package.json pin must move off 0.8.5 before those benefits land. The code degrades gracefully on 0.8.5 — partitioning and drift work; the sort order is recorded but inert. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(deps): bump icebird 0.8.5 -> 0.8.9 (sort-on-write + scan pruning) Activates the conversation sort within day partitions and read-side scan pruning that the partitioned export records in metadata. Clears the merge blocker: 0.8.9 contains icebird 3edb15b. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sinks): address PR #91 review — reverse drift guard, on-disk sort assertions, CLI text - commitBatch now rejects reverse partition-spec drift: appending with no derived partitioning onto an already-partitioned table throws iceberg_partition_spec_drift instead of silently skipping the guard (and mislabeling spans as unpartitioned). LLP 0022#drift-rejection updated to record the guard as bidirectional; new test alongside the forward-drift case. - The conversation sort is now asserted on disk, not just in metadata: both the commitBatch integration test and the partitioned smoke read a day-partition parquet file back with hyparquet and assert conversation_id row order — fails on an icebird that records the sort order but writes unsorted. Verified against icebird 0.8.9. - hyp sink maintain CLI text reframed to match maintenance.js / LLP 0022: compaction is not run by this sink (out-of-band via icebergRewrite), not 'unsupported by icebird'; action label is now compaction_out_of_band. - Removed stray </content> artifact from the end of LLP 0022. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 656da57 commit 6b4b269

22 files changed

Lines changed: 1154 additions & 122 deletions

collectivus-plugin-kernel-types.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
*/
1717

1818
import type { AsyncDataSource, ScanOptions, ScanResults } from 'squirreling'
19-
import type { CachePartitioningDeclaration } from './src/core/cache/types.d.ts'
19+
import type { CachePartitioningDeclaration } from './src/core/iceberg/types.d.ts'
2020

2121
export type { AsyncDataSource, ScanOptions, ScanResults }
2222

hypaware-core/plugins-workspace/format-iceberg/src/commit.js

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ import {
77
loadLatestFileCatalogMetadata,
88
} from 'icebird'
99

10+
import { validatePartitionSpecStability } from '../../../../src/core/iceberg/partition-spec.js'
11+
1012
import { icebergSchemaForColumns, mergeFieldIdsFromTable, rowsToIcebergRecords } from './schema.js'
1113

1214
/**
1315
* @import { ColumnSpec } from '../../../../collectivus-plugin-kernel-types.d.ts'
14-
* @import { CommitInput, CommitResult, TableState } from './types.d.ts'
15-
* @import { Lister, Resolver, Snapshot, TableMetadata } from 'icebird/src/types.js'
16+
* @import { CommitInput, CommitResult, DatasetPartitioning, TableState } from './types.d.ts'
17+
* @import { Lister, PartitionSpec, Resolver, Snapshot, TableMetadata } from 'icebird/src/types.js'
1618
*/
1719

1820
/**
@@ -80,15 +82,46 @@ export async function commitBatch(input, priorState) {
8082

8183
if (!priorState.exists) {
8284
try {
85+
// @ref LLP 0022#partition-derivation — create with the writer-owned
86+
// day-grain partitionSpec + conversation sort order. Both default to
87+
// unpartitioned/unsorted in icebird when `partitioning` is absent.
8388
await icebergCreateTable({
8489
catalog,
8590
tableUrl: input.tableUrl,
8691
schema: targetSchema,
8792
formatVersion: 3,
93+
partitionSpec: input.partitioning?.partitionSpec,
94+
sortOrder: input.partitioning?.sortOrder,
8895
})
8996
} catch (err) {
9097
throw wrapCommitError(err, 'iceberg_commit_failed', `create table failed at '${input.tableUrl}'`)
9198
}
99+
} else if (priorState.metadata) {
100+
// @ref LLP 0022#drift-rejection — an existing table whose partition spec no
101+
// longer matches the dataset's derived day grain is rejected; the export
102+
// cannot retroactively repartition object-store data files. [constrained-by]
103+
const existingSpec = currentPartitionSpec(priorState.metadata) ?? { 'spec-id': 0, fields: [] }
104+
if (input.partitioning) {
105+
try {
106+
validatePartitionSpecStability(input.partitioning.declaration, existingSpec, targetSchema)
107+
} catch (err) {
108+
const message = err instanceof Error ? err.message : String(err)
109+
throw newError(
110+
'iceberg_partition_spec_drift',
111+
`iceberg-format: partition spec drift at '${input.tableUrl}': ${message}`
112+
)
113+
}
114+
} else if (existingSpec.fields.length > 0) {
115+
// Reverse drift: the dataset stopped deriving partitioning but the
116+
// table on disk is partitioned. The append itself would succeed (icebird
117+
// keeps routing rows through the existing spec) while the sink reports
118+
// `unpartitioned` — reject rather than let layout and telemetry diverge.
119+
const existingLabel = existingSpec.fields.map((f) => `${f.transform}(${f.name})`).join(',')
120+
throw newError(
121+
'iceberg_partition_spec_drift',
122+
`iceberg-format: partition spec drift at '${input.tableUrl}': dataset derives no partitioning but the existing table is partitioned (${existingLabel})`
123+
)
124+
}
92125
}
93126

94127
/** @type {TableMetadata} */
@@ -142,6 +175,7 @@ const DEFAULT_STREAM_ROW_LIMIT = 100_000
142175
* rows: AsyncIterable<Record<string, unknown>>,
143176
* resolver: Resolver,
144177
* lister: Lister,
178+
* partitioning?: DatasetPartitioning | null,
145179
* }} input
146180
* @param {{ exists: boolean, metadata: TableMetadata | null }} priorState
147181
* @param {{ batchByteLimit?: number, batchRowLimit?: number }} [opts]
@@ -167,7 +201,7 @@ export async function commitRowStream(input, priorState, opts = {}) {
167201
async function flushBatch() {
168202
if (batch.length === 0) return
169203
const result = await commitBatch(
170-
{ tableUrl: input.tableUrl, columns: input.columns, rows: batch, resolver: input.resolver, lister: input.lister },
204+
{ tableUrl: input.tableUrl, columns: input.columns, rows: batch, resolver: input.resolver, lister: input.lister, partitioning: input.partitioning },
171205
state
172206
)
173207
state = { exists: true, metadata: result.metadata }
@@ -225,6 +259,23 @@ function schemaFromExistingMetadata(columns, metadata) {
225259
return mergeFieldIdsFromTable(columns, existing)
226260
}
227261

262+
/**
263+
* Resolve the table's current `PartitionSpec` from metadata (the default spec,
264+
* falling back to the last). Used for the on-append drift check.
265+
*
266+
* @param {TableMetadata} metadata
267+
* @returns {PartitionSpec | undefined}
268+
*/
269+
function currentPartitionSpec(metadata) {
270+
const specId = metadata['default-spec-id']
271+
const specs = metadata['partition-specs']
272+
if (specs?.length) {
273+
const match = specs.find((s) => s['spec-id'] === specId)
274+
return match ?? specs[specs.length - 1]
275+
}
276+
return undefined
277+
}
278+
228279
/**
229280
* @param {unknown} value
230281
*/

hypaware-core/plugins-workspace/format-iceberg/src/maintenance.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,12 @@ export async function discoverExportDatasets(blobStore, prefix) {
117117
* Run export maintenance on all datasets under a prefix: snapshot
118118
* expiration per dataset, plus a compaction status report.
119119
*
120-
* icebird V1 does not expose `rewrite-data-files` or `delete-data-files`,
121-
* so compaction is not supported. The report signals
122-
* `compactionSupported: false` so the CLI can surface a clear message.
120+
* @ref LLP 0022#compaction — icebird now exposes `icebergRewrite`
121+
* (read-rewrite compaction), but the export deliberately does not run it:
122+
* day-grain partitioning already yields large files, and an in-daemon
123+
* read-rewrite risks the OOM/blocking failure seen with the parquet sink.
124+
* `compactionSupported: false` here means "not run by this sink" (out-of-band
125+
* only), not "impossible".
123126
*
124127
* @param {{
125128
* blobStore: BlobStore
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// @ts-check
2+
3+
import { partitionSpecForDeclaration } from '../../../../src/core/iceberg/partition-spec.js'
4+
5+
import { icebergSchemaForColumns } from './schema.js'
6+
7+
/**
8+
* @import { ColumnSpec, DatasetRegistration } from '../../../../collectivus-plugin-kernel-types.d.ts'
9+
* @import { CachePartitioningDeclaration } from '../../../../src/core/iceberg/types.d.ts'
10+
* @import { Schema, SortField, SortOrder } from 'icebird/src/types.js'
11+
* @import { DatasetPartitioning } from './types.d.ts'
12+
*/
13+
14+
// @ref LLP 0022#partition-derivation — the export partitions by a writer-owned
15+
// day grain on the dataset's primaryTimestampColumn, derived independently of
16+
// the cache's `cachePartitioning` (which would impose an unbounded
17+
// per-conversation file count on an archive). [implements]
18+
/**
19+
* Derive the export table's layout for a dataset: a `day(primaryTimestampColumn)`
20+
* partition plus a within-partition sort on the dataset's lookup columns.
21+
* Returns `null` when the dataset declares no `primaryTimestampColumn` present
22+
* in its schema — that dataset exports unpartitioned (V1 behavior unchanged).
23+
*
24+
* @param {DatasetRegistration | undefined} reg
25+
* @param {readonly ColumnSpec[]} columns
26+
* @returns {DatasetPartitioning | null}
27+
*/
28+
export function derivePartitioning(reg, columns) {
29+
if (!reg) return null
30+
const tsColumn = typeof reg.primaryTimestampColumn === 'string' ? reg.primaryTimestampColumn : ''
31+
if (!tsColumn) return null
32+
// A primaryTimestampColumn that isn't in the exported schema can't anchor a
33+
// day grain; fall back to unpartitioned rather than synthesize a bad spec.
34+
if (!columns.some((c) => c.name === tsColumn)) return null
35+
36+
const schema = icebergSchemaForColumns(columns)
37+
/** @type {CachePartitioningDeclaration} */
38+
const declaration = {
39+
source: { columns: [tsColumn] },
40+
iceberg: { fields: [{ column: tsColumn, transform: 'day', required: true }] },
41+
}
42+
const partitionSpec = partitionSpecForDeclaration(declaration, schema)
43+
const sortOrder = sortOrderForLookup(reg, schema)
44+
return {
45+
declaration,
46+
partitionSpec,
47+
sortOrder,
48+
partitionSpecLabel: `day(${tsColumn})`,
49+
sortOrderLabel: sortOrder.fields.map((f) => nameForSourceId(schema, f)).join(','),
50+
}
51+
}
52+
53+
// @ref LLP 0022#within-partition-sort — cluster each day partition by the
54+
// dataset's declared identity (lookup) columns so a conversation lookup prunes
55+
// row groups by min/max, without the file-count cost of partitioning on it.
56+
// This is the one place the export reads `cachePartitioning` — sort axis only.
57+
// [implements]
58+
/**
59+
* Build a sort order from the dataset's declared identity columns
60+
* (`cachePartitioning.iceberg.fields`, transform `identity`), in declared order.
61+
* Returns an empty (unsorted) order when none apply — icebird treats that as a
62+
* no-op, so an undeclared dataset is day-partitioned but unsorted.
63+
*
64+
* @param {DatasetRegistration} reg
65+
* @param {Schema} schema
66+
* @returns {SortOrder}
67+
*/
68+
function sortOrderForLookup(reg, schema) {
69+
/** @type {Map<string, number>} */
70+
const idByName = new Map(schema.fields.map((f) => [f.name, f.id]))
71+
const declared = reg.cachePartitioning?.iceberg?.fields ?? []
72+
/** @type {SortField[]} */
73+
const fields = []
74+
for (const f of declared) {
75+
if (f.transform !== 'identity') continue
76+
const id = idByName.get(f.column)
77+
if (id === undefined) continue
78+
fields.push({
79+
'source-id': id,
80+
transform: 'identity',
81+
direction: 'asc',
82+
'null-order': 'nulls-last',
83+
})
84+
}
85+
// order-id 0 is conventionally "unsorted"; a real order uses 1.
86+
return fields.length > 0 ? { 'order-id': 1, fields } : { 'order-id': 0, fields: [] }
87+
}
88+
89+
/**
90+
* @param {Schema} schema
91+
* @param {SortField} field
92+
* @returns {string}
93+
*/
94+
function nameForSourceId(schema, field) {
95+
const id = field['source-id']
96+
const match = schema.fields.find((f) => f.id === id)
97+
return match ? match.name : String(id)
98+
}

hypaware-core/plugins-workspace/format-iceberg/src/table-format.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getTracer, SpanStatusCode } from '../../../../src/core/observability/in
55
import { createBlobStoreIO, pathToKey, tableUrlForBlobPrefix } from './blob-io.js'
66
import { commitBatch, commitRowStream, probeTable } from './commit.js'
77
import { expireExportSnapshots, normalizeExportRetentionConfig } from './maintenance.js'
8+
import { derivePartitioning } from './partitioning.js'
89
import { loadMarker, markerKey, markerSubsumedBySnapshot, writeMarker } from './state.js'
910

1011
/**
@@ -195,6 +196,11 @@ async function exportDataset({ ctx, batch, dataset, partitions, prefix, log, mai
195196
return { partitionsExported: partitions.length, bytesWritten: 0, status: 'skipped' }
196197
}
197198

199+
// @ref LLP 0022#partition-derivation — derived per dataset at commit time
200+
// because `createSink` runs once for a sink that exports many datasets, so
201+
// the spec cannot be resolved up front. [implements]
202+
const partitioning = derivePartitioning(ctx.query.getDataset(dataset), columns)
203+
198204
const blobPrefix = joinKeys(pathToKey(prefix), sanitizeDataset(dataset))
199205
const tableUrl = tableUrlForBlobPrefix(blobPrefix)
200206
// Track the most recent metadata.json write so the commit span can
@@ -283,14 +289,18 @@ async function exportDataset({ ctx, batch, dataset, partitions, prefix, log, mai
283289
hyp_dataset: dataset,
284290
hyp_batch_id: batch.batchId,
285291
encoder_format: ctx.encoder.format,
292+
// @ref LLP 0022#observability — surface the resolved layout so a smoke
293+
// can assert what was written, not just that rows landed.
294+
hyp_partition_spec: partitioning?.partitionSpecLabel ?? 'unpartitioned',
295+
hyp_sort_order: partitioning?.sortOrderLabel ?? '',
286296
status: 'ok',
287297
...destinationAttrs,
288298
},
289299
},
290300
async (span) => {
291301
try {
292302
const result = await commitRowStream(
293-
{ tableUrl, columns, rows: rowStream(), resolver, lister },
303+
{ tableUrl, columns, rows: rowStream(), resolver, lister, partitioning },
294304
{ exists: priorState.exists, metadata: priorState.metadata }
295305
)
296306
span.setAttribute('snapshot_id', result.snapshotId)

hypaware-core/plugins-workspace/format-iceberg/src/types.d.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ColumnSpec } from '../../../../collectivus-plugin-kernel-types.d.ts'
2-
import type { TableMetadata, Resolver, Lister } from 'icebird/src/types.js'
2+
import type { TableMetadata, Resolver, Lister, PartitionSpec, SortOrder } from 'icebird/src/types.js'
3+
import type { CachePartitioningDeclaration } from '../../../../src/core/iceberg/types.d.ts'
34

45
export interface TableState {
56
/** True when at least one metadata file is visible. */
@@ -8,6 +9,24 @@ export interface TableState {
89
currentSnapshotId: string | undefined
910
}
1011

12+
/**
13+
* Writer-owned export layout for one dataset (LLP 0022): a day-grain partition
14+
* derived from `primaryTimestampColumn`, plus a within-partition sort on the
15+
* dataset's declared identity (lookup) columns.
16+
*/
17+
export interface DatasetPartitioning {
18+
/** Synthesized day-grain declaration — kept for the on-append drift check. */
19+
declaration: CachePartitioningDeclaration
20+
/** Iceberg partition spec passed to `icebergCreateTable`. */
21+
partitionSpec: PartitionSpec
22+
/** Within-partition sort order; an empty order means unsorted (no-op). */
23+
sortOrder: SortOrder
24+
/** Span label, e.g. `day(message_created_at)`. */
25+
partitionSpecLabel: string
26+
/** Span label, e.g. `conversation_id,cwd,date` (empty when unsorted). */
27+
sortOrderLabel: string
28+
}
29+
1130
export interface CommitInput {
1231
/** Table URL the resolver understands. */
1332
tableUrl: string
@@ -17,6 +36,8 @@ export interface CommitInput {
1736
rows: readonly Record<string, unknown>[]
1837
resolver: Resolver
1938
lister: Lister
39+
/** Day-grain partition + sort layout; absent ⇒ unpartitioned table. */
40+
partitioning?: DatasetPartitioning | null
2041
}
2142

2243
export interface CommitResult {

hypaware-core/smoke/flows/iceberg_export_local_fs.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ export async function run({ harness, expect }) {
272272
(ds) => Array.isArray(ds) && ds.some((d) => d.dataset === DATASET)
273273
)
274274
expect.that(
275-
'maintain: compactionSupported is false (icebird V1 limitation)',
275+
'maintain: compactionSupported is false (not run by this sink — out-of-band only, LLP 0022)',
276276
maintainReport.compactionSupported,
277277
(v) => v === false
278278
)

0 commit comments

Comments
 (0)