You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iceberg.apache.org by ze...@apache.org on 2026/07/09 17:15:06 UTC
(iceberg-go) branch main updated: feat(metrics): add report and wire types (#1236) (#1421)
This is an automated email from the ASF dual-hosted git repository.
zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git
The following commit(s) were added to refs/heads/main by this push:
new 6c02f843 feat(metrics): add report and wire types (#1236) (#1421)
6c02f843 is described below
commit 6c02f843058bfedfb0b865ad8e5608a1c9abe329
Author: Tanmay Rauth <t_...@apple.com>
AuthorDate: Thu Jul 9 10:15:01 2026 -0700
feat(metrics): add report and wire types (#1236) (#1421)
Second PR of the metrics reporting framework. Adds the spec-compliant
report types and their JSON wire format: ScanReport, CommitReport, the
ScanMetricsResult/CommitMetricsResult metric sets, CounterResult and
TimerResult, plus the ReportMetricsRequest envelope with the flattened
report-type discriminator.
Metric names, units, and the request shape match Java verbatim and are
locked by round-trip interop tests against fixtures in the Java wire
format. ScanReport.filter defaults to the always-true expression for v1;
unknown metric keys are tolerated.
Related: #1236
---
metrics/commit_report.go | 81 +++++++++++++++++
metrics/request.go | 114 ++++++++++++++++++++++++
metrics/result.go | 61 +++++++++++++
metrics/scan_report.go | 85 ++++++++++++++++++
metrics/testdata/commit_report.json | 25 ++++++
metrics/testdata/scan_report.json | 24 +++++
metrics/wire_test.go | 170 ++++++++++++++++++++++++++++++++++++
7 files changed, 560 insertions(+)
diff --git a/metrics/commit_report.go b/metrics/commit_report.go
new file mode 100644
index 00000000..795dbc8a
--- /dev/null
+++ b/metrics/commit_report.go
@@ -0,0 +1,81 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package metrics
+
+// CommitMetricsResult is the serializable set of commit metrics. Field names
+// and units match Java's CommitMetricsResult / CommitMetrics so the wire
+// format is identical across implementations. Unset metrics are omitted.
+//
+// The counter values are derived from the snapshot summary; note that several
+// of Java's commit-report metric names differ from iceberg-go's snapshot
+// summary keys (e.g. added-files-size-bytes vs the summary's added-files-size),
+// and the commit instrumentation is responsible for emitting them under these
+// Java names.
+type CommitMetricsResult struct {
+ TotalDuration *TimerResult `json:"total-duration,omitempty"`
+ Attempts *CounterResult `json:"attempts,omitempty"`
+
+ AddedDataFiles *CounterResult `json:"added-data-files,omitempty"`
+ RemovedDataFiles *CounterResult `json:"removed-data-files,omitempty"`
+ TotalDataFiles *CounterResult `json:"total-data-files,omitempty"`
+
+ AddedDeleteFiles *CounterResult `json:"added-delete-files,omitempty"`
+ RemovedDeleteFiles *CounterResult `json:"removed-delete-files,omitempty"`
+ TotalDeleteFiles *CounterResult `json:"total-delete-files,omitempty"`
+
+ AddedEqualityDeleteFiles *CounterResult `json:"added-equality-delete-files,omitempty"`
+ RemovedEqualityDeleteFiles *CounterResult `json:"removed-equality-delete-files,omitempty"`
+
+ AddedPositionalDeleteFiles *CounterResult `json:"added-positional-delete-files,omitempty"`
+ RemovedPositionalDeleteFiles *CounterResult `json:"removed-positional-delete-files,omitempty"`
+
+ AddedDVs *CounterResult `json:"added-dvs,omitempty"`
+ RemovedDVs *CounterResult `json:"removed-dvs,omitempty"`
+
+ AddedRecords *CounterResult `json:"added-records,omitempty"`
+ RemovedRecords *CounterResult `json:"removed-records,omitempty"`
+ TotalRecords *CounterResult `json:"total-records,omitempty"`
+
+ AddedFilesSizeBytes *CounterResult `json:"added-files-size-bytes,omitempty"`
+ RemovedFilesSizeBytes *CounterResult `json:"removed-files-size-bytes,omitempty"`
+ TotalFilesSizeBytes *CounterResult `json:"total-files-size-bytes,omitempty"`
+
+ AddedPositionalDeletes *CounterResult `json:"added-positional-deletes,omitempty"`
+ RemovedPositionalDeletes *CounterResult `json:"removed-positional-deletes,omitempty"`
+ TotalPositionalDeletes *CounterResult `json:"total-positional-deletes,omitempty"`
+
+ AddedEqualityDeletes *CounterResult `json:"added-equality-deletes,omitempty"`
+ RemovedEqualityDeletes *CounterResult `json:"removed-equality-deletes,omitempty"`
+ TotalEqualityDeletes *CounterResult `json:"total-equality-deletes,omitempty"`
+
+ ManifestsCreated *CounterResult `json:"manifests-created,omitempty"`
+ ManifestsReplaced *CounterResult `json:"manifests-replaced,omitempty"`
+ ManifestsKept *CounterResult `json:"manifests-kept,omitempty"`
+ ManifestEntriesProcessed *CounterResult `json:"manifest-entries-processed,omitempty"`
+}
+
+// CommitReport is emitted after a commit completes. It maps to the spec's
+// CommitReport schema.
+type CommitReport struct {
+ TableName string `json:"table-name"`
+ SnapshotID int64 `json:"snapshot-id"`
+ SequenceNumber int64 `json:"sequence-number"`
+ Operation string `json:"operation"`
+ Metrics CommitMetricsResult `json:"metrics"`
+ Metadata map[string]string `json:"metadata,omitempty"`
+}
diff --git a/metrics/request.go b/metrics/request.go
new file mode 100644
index 00000000..20d1eea5
--- /dev/null
+++ b/metrics/request.go
@@ -0,0 +1,114 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package metrics
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+)
+
+// Report-type discriminator values, matching Java's ReportType wire form.
+const (
+ reportTypeScan = "scan-report"
+ reportTypeCommit = "commit-report"
+)
+
+// reportTypeField is the discriminator key in the request body.
+const reportTypeField = "report-type"
+
+// ReportMetricsRequest is the body POSTed to the catalog metrics endpoint
+// (POST /v1/{prefix}/namespaces/{namespace}/tables/{table}/metrics). It maps to
+// the spec's ReportMetricsRequest: a ScanReport or CommitReport flattened
+// alongside a "report-type" discriminator (the report's fields are siblings of
+// report-type, not nested).
+type ReportMetricsRequest struct {
+ Report MetricsReport
+}
+
+// NewReportMetricsRequest wraps a report for transport.
+func NewReportMetricsRequest(report MetricsReport) ReportMetricsRequest {
+ return ReportMetricsRequest{Report: report}
+}
+
+// MarshalJSON writes report-type plus the report's own fields at the top level.
+func (r ReportMetricsRequest) MarshalJSON() ([]byte, error) {
+ // Guard nil reports up front. This catches both an untyped nil and a typed
+ // nil pointer (e.g. (*ScanReport)(nil)), which would otherwise match the
+ // type switch below and marshal to a bare "null" — silently producing a
+ // report with no required fields instead of an error. Mirrors isNilReport
+ // used by the built-in reporters.
+ if isNilReport(r.Report) {
+ return nil, errors.New("metrics: cannot marshal nil report")
+ }
+
+ var reportType string
+ switch r.Report.(type) {
+ case ScanReport, *ScanReport:
+ reportType = reportTypeScan
+ case CommitReport, *CommitReport:
+ reportType = reportTypeCommit
+ default:
+ return nil, fmt.Errorf("metrics: cannot marshal report of type %T", r.Report)
+ }
+
+ body, err := json.Marshal(r.Report)
+ if err != nil {
+ return nil, err
+ }
+
+ fields := map[string]json.RawMessage{}
+ if err := json.Unmarshal(body, &fields); err != nil {
+ return nil, err
+ }
+ fields[reportTypeField], _ = json.Marshal(reportType)
+
+ return json.Marshal(fields)
+}
+
+// UnmarshalJSON reads the report-type discriminator and decodes the matching
+// concrete report.
+func (r *ReportMetricsRequest) UnmarshalJSON(data []byte) error {
+ var disc struct {
+ ReportType string `json:"report-type"`
+ }
+ if err := json.Unmarshal(data, &disc); err != nil {
+ return err
+ }
+
+ switch disc.ReportType {
+ case reportTypeScan:
+ var sr ScanReport
+ if err := json.Unmarshal(data, &sr); err != nil {
+ return err
+ }
+ r.Report = sr
+ case reportTypeCommit:
+ var cr CommitReport
+ if err := json.Unmarshal(data, &cr); err != nil {
+ return err
+ }
+ r.Report = cr
+ case "":
+ return fmt.Errorf("metrics: missing %q in metrics request", reportTypeField)
+ default:
+ return fmt.Errorf("metrics: unknown report-type %q", disc.ReportType)
+ }
+
+ return nil
+}
diff --git a/metrics/result.go b/metrics/result.go
new file mode 100644
index 00000000..7a0cad26
--- /dev/null
+++ b/metrics/result.go
@@ -0,0 +1,61 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package metrics
+
+// Unit identifies what a counter measures. The wire values match Java's
+// MetricsContext.Unit display names.
+type Unit string
+
+const (
+ UnitUndefined Unit = "undefined"
+ UnitCount Unit = "count"
+ UnitBytes Unit = "bytes"
+)
+
+// TimeUnit values for a TimerResult, matching Java's lowercased
+// java.util.concurrent.TimeUnit names. iceberg-go records durations in
+// nanoseconds.
+const (
+ TimeUnitNanoseconds = "nanoseconds"
+)
+
+// CounterResult is the serializable snapshot of a single counter. It maps to
+// the spec's CounterResult schema.
+type CounterResult struct {
+ Unit Unit `json:"unit"`
+ Value int64 `json:"value"`
+}
+
+// NewCounterResult returns a CounterResult with the given unit and value.
+func NewCounterResult(unit Unit, value int64) *CounterResult {
+ return &CounterResult{Unit: unit, Value: value}
+}
+
+// TimerResult is the serializable snapshot of a single timer. It maps to the
+// spec's TimerResult schema. TotalDuration is expressed in TimeUnit.
+type TimerResult struct {
+ TimeUnit string `json:"time-unit"`
+ Count int64 `json:"count"`
+ TotalDuration int64 `json:"total-duration"`
+}
+
+// NewNanosTimerResult returns a TimerResult recording totalNanos nanoseconds
+// observed over count samples.
+func NewNanosTimerResult(count, totalNanos int64) *TimerResult {
+ return &TimerResult{TimeUnit: TimeUnitNanoseconds, Count: count, TotalDuration: totalNanos}
+}
diff --git a/metrics/scan_report.go b/metrics/scan_report.go
new file mode 100644
index 00000000..d0e10405
--- /dev/null
+++ b/metrics/scan_report.go
@@ -0,0 +1,85 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package metrics
+
+import "encoding/json"
+
+// alwaysTrueFilter is the structured Expression emitted for ScanReport.filter
+// when no filter is set. Iceberg serializes the always-true expression as the
+// bare JSON boolean true (see AlwaysTrue.MarshalJSON in the root package, which
+// mirrors Java's ExpressionParser); a {"type":...} object is used only for
+// predicates and and/or/not nodes. Emitting anything else here would be
+// rejected as invalid Expression JSON by a spec-compliant catalog.
+var alwaysTrueFilter = json.RawMessage(`true`)
+
+// ScanMetricsResult is the serializable set of scan-planning metrics. Field
+// names and units match Java's ScanMetricsResult so the wire format is
+// identical across implementations. Unset metrics are omitted.
+type ScanMetricsResult struct {
+ TotalPlanningDuration *TimerResult `json:"total-planning-duration,omitempty"`
+ ResultDataFiles *CounterResult `json:"result-data-files,omitempty"`
+ ResultDeleteFiles *CounterResult `json:"result-delete-files,omitempty"`
+ TotalDataManifests *CounterResult `json:"total-data-manifests,omitempty"`
+ TotalDeleteManifests *CounterResult `json:"total-delete-manifests,omitempty"`
+ ScannedDataManifests *CounterResult `json:"scanned-data-manifests,omitempty"`
+ ScannedDeleteManifests *CounterResult `json:"scanned-delete-manifests,omitempty"`
+ SkippedDataManifests *CounterResult `json:"skipped-data-manifests,omitempty"`
+ SkippedDeleteManifests *CounterResult `json:"skipped-delete-manifests,omitempty"`
+ SkippedDataFiles *CounterResult `json:"skipped-data-files,omitempty"`
+ SkippedDeleteFiles *CounterResult `json:"skipped-delete-files,omitempty"`
+ TotalFileSizeInBytes *CounterResult `json:"total-file-size-in-bytes,omitempty"`
+ TotalDeleteFileSizeInBytes *CounterResult `json:"total-delete-file-size-in-bytes,omitempty"`
+ IndexedDeleteFiles *CounterResult `json:"indexed-delete-files,omitempty"`
+ EqualityDeleteFiles *CounterResult `json:"equality-delete-files,omitempty"`
+ PositionalDeleteFiles *CounterResult `json:"positional-delete-files,omitempty"`
+ DVs *CounterResult `json:"dvs,omitempty"`
+}
+
+// ScanReport is emitted after scan planning completes. It maps to the spec's
+// ScanReport schema. Filter holds the scan filter as Expression JSON (a bare
+// boolean for always-true/false, or a {"type":...} object for predicates and
+// and/or/not nodes); when unset it marshals as always-true (see
+// [alwaysTrueFilter]).
+type ScanReport struct {
+ TableName string `json:"table-name"`
+ SnapshotID int64 `json:"snapshot-id"`
+ SchemaID int `json:"schema-id"`
+ ProjectedFieldIDs []int `json:"projected-field-ids"`
+ ProjectedFieldNames []string `json:"projected-field-names"`
+ Filter json.RawMessage `json:"filter"`
+ Metrics ScanMetricsResult `json:"metrics"`
+ Metadata map[string]string `json:"metadata,omitempty"`
+}
+
+// MarshalJSON defaults an unset Filter to the "always true" expression so the
+// spec-required filter field is always present.
+func (s ScanReport) MarshalJSON() ([]byte, error) {
+ type alias ScanReport
+ a := alias(s)
+ if len(a.Filter) == 0 {
+ a.Filter = alwaysTrueFilter
+ }
+ if a.ProjectedFieldIDs == nil {
+ a.ProjectedFieldIDs = []int{}
+ }
+ if a.ProjectedFieldNames == nil {
+ a.ProjectedFieldNames = []string{}
+ }
+
+ return json.Marshal(a)
+}
diff --git a/metrics/testdata/commit_report.json b/metrics/testdata/commit_report.json
new file mode 100644
index 00000000..22f72792
--- /dev/null
+++ b/metrics/testdata/commit_report.json
@@ -0,0 +1,25 @@
+{
+ "report-type": "commit-report",
+ "table-name": "nyc.taxis",
+ "snapshot-id": 3450729581828559851,
+ "sequence-number": 2,
+ "operation": "append",
+ "metrics": {
+ "total-duration": {
+ "count": 1,
+ "time-unit": "nanoseconds",
+ "total-duration": 1234567890
+ },
+ "attempts": {"unit": "count", "value": 1},
+ "added-data-files": {"unit": "count", "value": 4},
+ "total-data-files": {"unit": "count", "value": 4},
+ "added-records": {"unit": "count", "value": 12345},
+ "total-records": {"unit": "count", "value": 12345},
+ "added-files-size-bytes": {"unit": "bytes", "value": 4096000},
+ "total-files-size-bytes": {"unit": "bytes", "value": 4096000},
+ "manifests-created": {"unit": "count", "value": 1},
+ "manifests-kept": {"unit": "count", "value": 0},
+ "manifest-entries-processed": {"unit": "count", "value": 4}
+ },
+ "metadata": {"engine": "go"}
+}
diff --git a/metrics/testdata/scan_report.json b/metrics/testdata/scan_report.json
new file mode 100644
index 00000000..13a9e031
--- /dev/null
+++ b/metrics/testdata/scan_report.json
@@ -0,0 +1,24 @@
+{
+ "report-type": "scan-report",
+ "table-name": "nyc.taxis",
+ "snapshot-id": 3450729581828559851,
+ "schema-id": 0,
+ "projected-field-ids": [1, 2, 3],
+ "projected-field-names": ["vendor_id", "trip_distance", "fare_amount"],
+ "filter": true,
+ "metrics": {
+ "total-planning-duration": {
+ "count": 1,
+ "time-unit": "nanoseconds",
+ "total-duration": 2644235116
+ },
+ "result-data-files": {"unit": "count", "value": 1},
+ "result-delete-files": {"unit": "count", "value": 0},
+ "total-data-manifests": {"unit": "count", "value": 1},
+ "scanned-data-manifests": {"unit": "count", "value": 1},
+ "skipped-data-manifests": {"unit": "count", "value": 0},
+ "total-file-size-in-bytes": {"unit": "bytes", "value": 10},
+ "total-delete-file-size-in-bytes": {"unit": "bytes", "value": 0}
+ },
+ "metadata": {"engine": "go"}
+}
diff --git a/metrics/wire_test.go b/metrics/wire_test.go
new file mode 100644
index 00000000..8ab45295
--- /dev/null
+++ b/metrics/wire_test.go
@@ -0,0 +1,170 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package metrics
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/apache/iceberg-go"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// remarshalEqual asserts that JSON in -> value -> JSON out is semantically
+// identical (key order / whitespace independent).
+func assertJSONEqual(t *testing.T, want, got []byte) {
+ t.Helper()
+ var w, g any
+ require.NoError(t, json.Unmarshal(want, &w))
+ require.NoError(t, json.Unmarshal(got, &g))
+ assert.Equal(t, w, g)
+}
+
+func TestReportMetricsRequest_ScanInterop(t *testing.T) {
+ fixture, err := os.ReadFile(filepath.Join("testdata", "scan_report.json"))
+ require.NoError(t, err)
+
+ var req ReportMetricsRequest
+ require.NoError(t, json.Unmarshal(fixture, &req))
+
+ sr, ok := req.Report.(ScanReport)
+ require.True(t, ok, "expected ScanReport, got %T", req.Report)
+
+ // Spot-check decoded fields.
+ assert.Equal(t, "nyc.taxis", sr.TableName)
+ assert.Equal(t, int64(3450729581828559851), sr.SnapshotID)
+ assert.Equal(t, []int{1, 2, 3}, sr.ProjectedFieldIDs)
+ assert.JSONEq(t, `true`, string(sr.Filter))
+ require.NotNil(t, sr.Metrics.TotalPlanningDuration)
+ assert.Equal(t, TimeUnitNanoseconds, sr.Metrics.TotalPlanningDuration.TimeUnit)
+ assert.Equal(t, int64(2644235116), sr.Metrics.TotalPlanningDuration.TotalDuration)
+ require.NotNil(t, sr.Metrics.TotalFileSizeInBytes)
+ assert.Equal(t, UnitBytes, sr.Metrics.TotalFileSizeInBytes.Unit)
+ assert.Equal(t, int64(10), sr.Metrics.TotalFileSizeInBytes.Value)
+ assert.Equal(t, map[string]string{"engine": "go"}, sr.Metadata)
+
+ // Round-trip must reproduce the fixture exactly (semantically).
+ out, err := json.Marshal(req)
+ require.NoError(t, err)
+ assertJSONEqual(t, fixture, out)
+}
+
+func TestReportMetricsRequest_CommitInterop(t *testing.T) {
+ fixture, err := os.ReadFile(filepath.Join("testdata", "commit_report.json"))
+ require.NoError(t, err)
+
+ var req ReportMetricsRequest
+ require.NoError(t, json.Unmarshal(fixture, &req))
+
+ cr, ok := req.Report.(CommitReport)
+ require.True(t, ok, "expected CommitReport, got %T", req.Report)
+
+ assert.Equal(t, "nyc.taxis", cr.TableName)
+ assert.Equal(t, int64(2), cr.SequenceNumber)
+ assert.Equal(t, "append", cr.Operation)
+ require.NotNil(t, cr.Metrics.AddedRecords)
+ assert.Equal(t, int64(12345), cr.Metrics.AddedRecords.Value)
+ require.NotNil(t, cr.Metrics.AddedFilesSizeBytes)
+ assert.Equal(t, UnitBytes, cr.Metrics.AddedFilesSizeBytes.Unit)
+ require.NotNil(t, cr.Metrics.TotalDuration)
+ assert.Equal(t, int64(1234567890), cr.Metrics.TotalDuration.TotalDuration)
+
+ out, err := json.Marshal(req)
+ require.NoError(t, err)
+ assertJSONEqual(t, fixture, out)
+}
+
+func TestScanReport_DefaultsFilterToAlwaysTrue(t *testing.T) {
+ // A ScanReport with no Filter must still emit the spec-required filter field.
+ sr := ScanReport{TableName: "t", SnapshotID: 1}
+ out, err := json.Marshal(sr)
+ require.NoError(t, err)
+
+ var decoded map[string]json.RawMessage
+ require.NoError(t, json.Unmarshal(out, &decoded))
+ require.Contains(t, decoded, "filter")
+ assert.JSONEq(t, `true`, string(decoded["filter"]))
+ // Empty projected lists serialize as [] (not null) to match the spec arrays.
+ assert.JSONEq(t, `[]`, string(decoded["projected-field-ids"]))
+ assert.JSONEq(t, `[]`, string(decoded["projected-field-names"]))
+}
+
+// TestScanReport_FilterMatchesCanonicalAlwaysTrue pins the default filter to
+// iceberg's canonical always-true wire form (a bare JSON boolean) rather than a
+// hand-written literal, so the two can't silently drift. A {"type":...} object
+// or any other shape would be rejected as invalid Expression JSON by a
+// spec-compliant catalog.
+func TestScanReport_FilterMatchesCanonicalAlwaysTrue(t *testing.T) {
+ want, err := json.Marshal(iceberg.AlwaysTrue{})
+ require.NoError(t, err)
+
+ out, err := json.Marshal(ScanReport{TableName: "t"})
+ require.NoError(t, err)
+ var decoded map[string]json.RawMessage
+ require.NoError(t, json.Unmarshal(out, &decoded))
+ assert.JSONEq(t, string(want), string(decoded["filter"]))
+}
+
+func TestReportMetricsRequest_MarshalDiscriminator(t *testing.T) {
+ scanOut, err := json.Marshal(NewReportMetricsRequest(ScanReport{TableName: "t"}))
+ require.NoError(t, err)
+ var m map[string]json.RawMessage
+ require.NoError(t, json.Unmarshal(scanOut, &m))
+ assert.JSONEq(t, `"scan-report"`, string(m["report-type"]))
+ assert.Contains(t, m, "table-name", "report fields are flattened, not nested")
+
+ commitOut, err := json.Marshal(NewReportMetricsRequest(CommitReport{TableName: "t"}))
+ require.NoError(t, err)
+ require.NoError(t, json.Unmarshal(commitOut, &m))
+ assert.JSONEq(t, `"commit-report"`, string(m["report-type"]))
+}
+
+func TestReportMetricsRequest_MarshalErrors(t *testing.T) {
+ _, err := json.Marshal(NewReportMetricsRequest(nil))
+ assert.Error(t, err, "nil report must not marshal")
+
+ // A typed-nil pointer is a non-nil interface, so it must be rejected too
+ // rather than silently marshaling to a report with no fields.
+ _, err = json.Marshal(NewReportMetricsRequest((*ScanReport)(nil)))
+ assert.Error(t, err, "typed-nil report must not marshal")
+}
+
+func TestReportMetricsRequest_UnmarshalErrors(t *testing.T) {
+ var req ReportMetricsRequest
+ assert.Error(t, json.Unmarshal([]byte(`{}`), &req), "missing report-type")
+ assert.Error(t, json.Unmarshal([]byte(`{"report-type":"bogus"}`), &req), "unknown report-type")
+}
+
+func TestScanMetricsResult_ToleratesUnknownKeys(t *testing.T) {
+ // Readers must tolerate metric keys they don't recognize (the spec metrics
+ // field is an open map) — unknown keys are ignored, not an error.
+ in := []byte(`{
+ "report-type":"scan-report",
+ "table-name":"t","snapshot-id":1,"schema-id":0,
+ "projected-field-ids":[],"projected-field-names":[],"filter":{"type":"true"},
+ "metrics":{"result-data-files":{"unit":"count","value":2},"some-future-metric":{"unit":"count","value":9}}
+ }`)
+ var req ReportMetricsRequest
+ require.NoError(t, json.Unmarshal(in, &req))
+ sr := req.Report.(ScanReport)
+ require.NotNil(t, sr.Metrics.ResultDataFiles)
+ assert.Equal(t, int64(2), sr.Metrics.ResultDataFiles.Value)
+}