Skip to content

Commit ec15ac7

Browse files
authored
Fix: REST catalog commit crashes serializing snapshot ids above 2^53 (#32)
* fix: serialize BigInt snapshot ids in REST catalog commit body restCatalogUpdateTable built its request body with plain JSON.stringify, which throws "Do not know how to serialize a BigInt" on the snapshot ids that parseIcebergJson keeps as BigInt above 2^53. Since Spark and the Java library assign random 64-bit snapshot ids, every write to such a table through a REST catalog failed. The file catalog path already went through stringifyIcebergJson, and test/write/commit.bigint-snapshot-id.test.js pins that contract; the REST path was the remaining caller still on JSON.stringify. Route it through the same serializer so the ids stay bare JSON numbers, and extend that test file to cover the REST catalog commit.
1 parent 9f049bc commit ec15ac7

3 files changed

Lines changed: 83 additions & 5 deletions

File tree

src/catalog/rest.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { parseIcebergJson } from '../json.js'
1+
import { parseIcebergJson, stringifyIcebergJson } from '../json.js'
22

33
/**
44
* Iceberg REST Catalog client.
@@ -236,7 +236,7 @@ export async function restCatalogUpdateTable(ctx, { namespace, table, requiremen
236236
const res = await restFetch(ctx, `namespaces/${ns}/tables/${tbl}`, {
237237
method: 'POST',
238238
headers: { 'content-type': 'application/json' },
239-
body: JSON.stringify({ requirements, updates }),
239+
body: stringifyIcebergJson({ requirements, updates }),
240240
})
241241
const responseBody = parseIcebergJson(await res.text())
242242
return {

src/write/write.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ export async function icebergDelete({ catalog, namespace, table, tableUrl, resol
151151
* @param {string} [options.tableUrl]
152152
* @param {Resolver} [options.resolver]
153153
* @param {string} options.ref
154-
* @param {number} options.snapshotId
154+
* @param {number | bigint} options.snapshotId
155155
* @param {'branch'|'tag'} [options.type]
156156
* @param {number} [options.minSnapshotsToKeep]
157157
* @param {number} [options.maxSnapshotAgeMs]

test/write/commit.bigint-snapshot-id.test.js

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
// commit re-serializing loaded metadata with a snapshot id > 2^53 succeeds
88
// and preserves the id exactly.
99

10-
import { describe, expect, it } from 'vitest'
10+
import { afterEach, describe, expect, it, vi } from 'vitest'
1111
import { fileCatalog } from '../../src/catalog/file.js'
12+
import { restCatalogConnect } from '../../src/catalog/rest.js'
1213
import { loadLatestFileCatalogMetadata } from '../../src/metadata.js'
13-
import { icebergAppend, icebergCreateTable, icebergExpireSnapshots } from '../../src/write/write.js'
14+
import { icebergAppend, icebergCreateTable, icebergExpireSnapshots, icebergSetRef } from '../../src/write/write.js'
15+
import { makeFetch } from '../catalog.rest.helpers.js'
1416
import { memResolver } from '../helpers.js'
1517

1618
/**
@@ -121,3 +123,79 @@ describe('commit round-trips snapshot ids above 2^53', () => {
121123
expect(child['parent-snapshot-id']).toBe(BIG_ID)
122124
})
123125
})
126+
127+
describe('rest catalog commit round-trips snapshot ids above 2^53', () => {
128+
afterEach(() => { vi.unstubAllGlobals() })
129+
130+
/**
131+
* Stub a REST catalog whose `loadTable` reports a table already sitting on a
132+
* snapshot id above 2^53 — the normal state of any table written by Spark or
133+
* the Java library, whose ids are random 64-bit longs. The response body is
134+
* built as raw text so the id crosses the wire as a bare JSON number, which
135+
* is what `parseIcebergJson` promotes to BigInt on the way in.
136+
*
137+
* @returns {{url: string, init: RequestInit | undefined}[]} calls made against the stub
138+
*/
139+
function stubCatalogAtBigId() {
140+
const metadata = `{
141+
"format-version": 2,
142+
"table-uuid": "uuid-1",
143+
"location": "http://test/t",
144+
"current-schema-id": 0,
145+
"schemas": [${JSON.stringify(schema)}],
146+
"current-snapshot-id": ${BIG_ID},
147+
"snapshots": [{
148+
"snapshot-id": ${BIG_ID},
149+
"sequence-number": 1,
150+
"timestamp-ms": 1,
151+
"manifest-list": "http://test/t/metadata/snap-1.avro",
152+
"summary": { "operation": "append" }
153+
}],
154+
"refs": { "main": { "snapshot-id": ${BIG_ID}, "type": "branch" } }
155+
}`
156+
const mock = makeFetch({
157+
'https://cat/v1/config': {},
158+
// A thunk, not a bare Response: the route is hit twice (loadTable, then
159+
// the commit) and a Response body can only be read once.
160+
'https://cat/v1/namespaces/db/tables/orders': () => new Response(
161+
`{"metadata-location": "http://test/t/metadata/v1.metadata.json", "metadata": ${metadata}}`,
162+
{ status: 200, headers: { 'content-type': 'application/json' } }
163+
),
164+
})
165+
vi.stubGlobal('fetch', mock.fn)
166+
return mock.calls
167+
}
168+
169+
it('POSTs a BigInt snapshot id as a bare JSON number', async () => {
170+
const calls = stubCatalogAtBigId()
171+
const catalog = await restCatalogConnect({ url: 'https://cat' })
172+
173+
// Re-pointing main asserts the ref against its current value and sets it
174+
// again, so the BigInt lands in both the requirements and the updates.
175+
// Before the fix this threw "Do not know how to serialize a BigInt" out
176+
// of the commit body, making every write to such a table impossible.
177+
await icebergSetRef({
178+
catalog, namespace: 'db', table: 'orders',
179+
ref: 'main', type: 'branch', snapshotId: BIG_ID,
180+
})
181+
182+
const post = calls.find(c => c.init?.method === 'POST')
183+
const body = /** @type {string} */ (post?.init?.body)
184+
// Assert on the raw text: the id must be an unquoted number, since a
185+
// replacer emitting `"9151314442816847871"` would be silently rejected
186+
// (or coerced) by the catalog rather than failing loudly here.
187+
expect(body).toContain(`"snapshot-id": ${BIG_ID}`)
188+
expect(body).not.toContain(`"${BIG_ID}"`)
189+
190+
// Both halves of the commit carry the id, so check each one.
191+
const parsed = JSON.parse(body)
192+
const ref = parsed.requirements
193+
.find((/** @type {any} */ r) => r.type === 'assert-ref-snapshot-id')
194+
const update = parsed.updates
195+
.find((/** @type {any} */ u) => u.action === 'set-snapshot-ref')
196+
// JSON.parse rounds to a double, so compare against the same rounding to
197+
// prove the digits on the wire were exact rather than pre-truncated.
198+
expect(ref['snapshot-id']).toBe(Number(BIG_ID))
199+
expect(update['snapshot-id']).toBe(Number(BIG_ID))
200+
})
201+
})

0 commit comments

Comments
 (0)