Skip to content

Commit 647762c

Browse files
committed
Carry forward v1 inline manifests when appending
1 parent 6579349 commit 647762c

5 files changed

Lines changed: 92 additions & 29 deletions

File tree

src/manifest.js

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1+
import { avroMetadata } from './avro/avro.metadata.js'
2+
import { avroRead } from './avro/avro.read.js'
13
import { fetchAvroRecords, urlResolver } from './fetch.js'
24

35
/**
46
* Returns manifest entries for a snapshot. Defaults to the current snapshot;
57
* pass `snapshotId` to time-travel to a prior snapshot in the metadata's
68
* snapshot log.
79
*
8-
* @import {Resolver, TableMetadata, Manifest, ManifestEntry} from '../src/types.js'
10+
* @import {Resolver, TableMetadata, Manifest, ManifestEntry, Snapshot} from '../src/types.js'
911
* @typedef {{ url: string, entries: ManifestEntry[] }[]} ManifestList
1012
* @param {object} options
1113
* @param {TableMetadata} options.metadata
@@ -36,15 +38,56 @@ export async function icebergManifests({ metadata, resolver, snapshotId }) {
3638
const manifestListUrl = snapshot['manifest-list']
3739
manifests = /** @type {Manifest[]} */ (await fetchAvroRecords(manifestListUrl, resolver))
3840
} else if (snapshot.manifests) {
39-
// Use manifest URLs directly from snapshot
40-
manifests = snapshot.manifests
41+
// v1 snapshots list manifests inline instead of pointing at a manifest list
42+
manifests = await resolveInlineManifests(snapshot, resolver)
4143
} else {
4244
throw new Error('No manifest information found in snapshot')
4345
}
4446

4547
return await fetchManifests(manifests, resolver)
4648
}
4749

50+
/**
51+
* Turn a v1 snapshot's inline `manifests` (a list of manifest file locations)
52+
* into manifest list records. Each manifest is read once to recover the
53+
* length, spec id, and file counts a manifest list needs, so a table upgraded
54+
* from v1 can still be read and appended to without rewriting its inherited
55+
* metadata.
56+
*
57+
* @param {Snapshot} snapshot
58+
* @param {Resolver} resolver
59+
* @returns {Promise<Manifest[]>}
60+
*/
61+
export async function resolveInlineManifests(snapshot, resolver) {
62+
const inline = snapshot.manifests ?? []
63+
return await Promise.all(inline.map(async manifestPath => {
64+
const ab = await resolver.reader(manifestPath)
65+
const buffer = await ab.slice(0, ab.byteLength)
66+
const reader = { view: new DataView(buffer), offset: 0 }
67+
const { metadata, syncMarker } = await avroMetadata(reader)
68+
const entries = /** @type {ManifestEntry[]} */ (await avroRead({ reader, metadata, syncMarker }))
69+
const counts = [0, 0, 0]
70+
const rows = [0n, 0n, 0n]
71+
for (const entry of entries) {
72+
counts[entry.status]++
73+
rows[entry.status] += BigInt(entry.data_file.record_count)
74+
}
75+
return {
76+
manifest_path: manifestPath,
77+
manifest_length: BigInt(ab.byteLength),
78+
partition_spec_id: Number(metadata['partition-spec-id'] ?? 0),
79+
content: 0,
80+
added_snapshot_id: BigInt(snapshot['snapshot-id']),
81+
added_files_count: counts[1],
82+
existing_files_count: counts[0],
83+
deleted_files_count: counts[2],
84+
added_rows_count: rows[1],
85+
existing_rows_count: rows[0],
86+
deleted_rows_count: rows[2],
87+
}
88+
}))
89+
}
90+
4891
/**
4992
* Fetch manifest entries from a list of manifests in parallel.
5093
*

src/types.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ export interface Snapshot {
201201
'sequence-number': number
202202
'timestamp-ms': number
203203
'manifest-list': string
204-
manifests?: Manifest[]
204+
manifests?: string[] // v1 only: manifest file locations
205205
summary: {
206206
// spec: "value of these fields should be of string type"
207207
operation: string

src/write/snapshot.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { fetchAvroRecords } from '../fetch.js'
2+
import { resolveInlineManifests } from '../manifest.js'
23
import { writeManifestList } from './manifest-list.js'
34
import { computeFieldSummary } from './stats.js'
45
import { transformResultType } from './transform.js'
@@ -40,8 +41,13 @@ export function currentSnapshot(metadata) {
4041
*/
4142
export async function loadPriorManifests(metadata, resolver) {
4243
const snap = currentSnapshot(metadata)
43-
if (!snap?.['manifest-list']) return []
44-
return /** @type {Manifest[]} */ (await fetchAvroRecords(snap['manifest-list'], resolver))
44+
if (!snap) return []
45+
if (snap['manifest-list']) {
46+
return /** @type {Manifest[]} */ (await fetchAvroRecords(snap['manifest-list'], resolver))
47+
}
48+
// A table upgraded from v1 keeps snapshots that list manifests inline. Carry
49+
// them forward, or the next snapshot silently drops all existing data.
50+
return await resolveInlineManifests(snap, resolver)
4551
}
4652

4753
/**

test/manifest.test.js

Lines changed: 9 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -71,29 +71,22 @@ describe('Iceberg Manifests', () => {
7171
'timestamp-ms': 0,
7272
'manifest-list': '',
7373
summary: { operation: 'append' },
74-
manifests: [{
75-
manifest_path: manifestPath,
76-
manifest_length: BigInt(manifestLength),
77-
partition_spec_id: 0,
78-
content: 1,
79-
added_snapshot_id: 1n,
80-
added_files_count: 1,
81-
existing_files_count: 0,
82-
deleted_files_count: 0,
83-
added_rows_count: 1n,
84-
existing_rows_count: 0n,
85-
deleted_rows_count: 0n,
86-
}],
74+
// v1 shape: manifest file locations listed inline on the snapshot
75+
manifests: [manifestPath],
8776
}],
8877
}
8978

9079
const manifests = await icebergManifests({ metadata, resolver: countingResolver })
9180

9281
expect(manifests).toHaveLength(1)
93-
expect(calls).toEqual([{ url: manifestPath, byteLength: manifestLength }])
82+
// first read recovers the manifest length, second read passes it through
83+
expect(calls).toEqual([
84+
{ url: manifestPath, byteLength: undefined },
85+
{ url: manifestPath, byteLength: manifestLength },
86+
])
9487
})
9588

96-
it('inherits a null entry snapshot id from the manifest list', async () => {
89+
it('inherits a null entry snapshot id from a v1 snapshot', async () => {
9790
const { resolver: memory } = memResolver()
9891
const manifestPath = 'http://test/inherited-snapshot-id.avro'
9992
const writer = memory.writer?.(manifestPath)
@@ -117,14 +110,7 @@ describe('Iceberg Manifests', () => {
117110
'current-snapshot-id': 77,
118111
snapshots: [{
119112
'snapshot-id': 77,
120-
manifests: [{
121-
manifest_path: manifestPath,
122-
manifest_length: BigInt(writer.offset),
123-
partition_spec_id: 0,
124-
content: 0,
125-
sequence_number: 5n,
126-
added_snapshot_id: 77n,
127-
}],
113+
manifests: [manifestPath],
128114
}],
129115
})
130116

test/write/stage.test.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,34 @@ describe('icebergStageAppend', () => {
7777
expect(read).toEqual(records)
7878
})
7979

80+
it('carries forward v1 manifest locations when appending', async () => {
81+
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
82+
const tableUrl = 'http://test/stage-v1-manifest-paths'
83+
const { resolver } = memResolver()
84+
85+
const created = await icebergCreate({ tableUrl, resolver, schema })
86+
const first = [{ id: 1n, name: 'alice' }]
87+
const staged1 = await icebergStageAppend({ tableUrl, metadata: created, records: first, resolver })
88+
const committed1 = await fileCatalogCommit({ tableUrl, metadata: created, staged: staged1, resolver })
89+
90+
// The spec's v1 shape: `manifests` is a list of manifest file locations.
91+
const snap = committed1.snapshots?.find(s => s['snapshot-id'] === committed1['current-snapshot-id'])
92+
if (!snap) throw new Error('expected current snapshot')
93+
const manifests = /** @type {any[]} */ (await fetchAvroRecords(snap['manifest-list'], resolver))
94+
const inline = /** @type {any} */ ({ ...snap, manifests: manifests.map(m => m.manifest_path) })
95+
delete inline['manifest-list']
96+
/** @type {TableMetadata} */
97+
const inherited = { ...committed1, snapshots: [inline] }
98+
expect(await icebergRead({ tableUrl, metadata: inherited, resolver })).toEqual(first)
99+
100+
const second = [{ id: 2n, name: 'bob' }]
101+
const staged2 = await icebergStageAppend({ tableUrl, metadata: inherited, records: second, resolver })
102+
const committed2 = await fileCatalogCommit({ tableUrl, metadata: inherited, staged: staged2, resolver })
103+
104+
const read = await icebergRead({ tableUrl, metadata: committed2, resolver })
105+
expect(read).toEqual([...first, ...second])
106+
})
107+
80108
it('assigns row lineage for v3 appends', async () => {
81109
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
82110
const tableUrl = 'http://test/stage-v3'

0 commit comments

Comments
 (0)