|
7 | 7 | // commit re-serializing loaded metadata with a snapshot id > 2^53 succeeds |
8 | 8 | // and preserves the id exactly. |
9 | 9 |
|
10 | | -import { describe, expect, it } from 'vitest' |
| 10 | +import { afterEach, describe, expect, it, vi } from 'vitest' |
11 | 11 | import { fileCatalog } from '../../src/catalog/file.js' |
| 12 | +import { restCatalogConnect } from '../../src/catalog/rest.js' |
12 | 13 | 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' |
14 | 16 | import { memResolver } from '../helpers.js' |
15 | 17 |
|
16 | 18 | /** |
@@ -121,3 +123,79 @@ describe('commit round-trips snapshot ids above 2^53', () => { |
121 | 123 | expect(child['parent-snapshot-id']).toBe(BIG_ID) |
122 | 124 | }) |
123 | 125 | }) |
| 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