Skip to content

Marshal resource types by value, not only by pointer - #6551

Merged
denik merged 5 commits into
mainfrom
denik/marshal-receiver-symmetry
Sep 8, 2026
Merged

Marshal resource types by value, not only by pointer#6551
denik merged 5 commits into
mainfrom
denik/marshal-receiver-symmetry

Conversation

@denik

@denik denik commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #6542.

A pointer-receiver MarshalJSON is satisfied by *T but not T, so json.Marshal(&x) uses
it while json.Marshal(x) falls back to plain encoding/json — which ignores
ForceSendFields (json:"-"):

PostgresProjectConfig, PurgeOnDelete=false, ForceSendFields=["PurgeOnDelete"]
  VALUE   {"project_id":""}
  POINTER {"project_id":"","purge_on_delete":false}

Latent today, not a live bug: the state path marshals pointers throughout and bundle config
travels through libs/dyn. So no golden output moves and no changelog entry.

Give the 8 affected types a value receiver (7 Postgres*Config + Secret, the last outer
type still on a pointer). UnmarshalJSON stays on the pointer, as it is across the package.

TestMarshalerValueReceiver walks every type reachable from the 34 adapter surfaces and
asserts none has a pointer-only MarshalJSON; reverting the 8 flags exactly those 8. It keys
on the asymmetry, not the mere absence of a marshaler — many types legitimately have none. The
walk descends into embedded members and named fields, not just the surface type:
PostgresRoleConfig is only reachable as an embedded member and would otherwise be missed.

A pointer-receiver MarshalJSON is satisfied by *T but not by T, and encoding/json
reaches it only for an addressable value. json.Marshal(&x) uses the marshaler
while json.Marshal(x) silently falls back to plain struct-field encoding -- two
code paths for one type. They disagree on more than key order, because
encoding/json knows nothing about ForceSendFields (tagged json:"-"):

    PurgeOnDelete=false, ForceSendFields=["PurgeOnDelete"]
      VALUE   {"project_id":""}
      POINTER {"project_id":"","purge_on_delete":false}

Give the 8 affected types a value receiver, and assert the invariant over every
adapter surface. The existing round-trip tests cannot catch it: they build values
with reflect.New, so they only ever marshal a pointer.

Nothing marshals these by value today (the state path passes pointers throughout,
and bundle config travels through libs/dyn), so this is a latent trap rather than
live corruption -- no golden output moves.
@eng-dev-ecosystem-bot

eng-dev-ecosystem-bot commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Integration test report

Commit: a16e960

Run: 34208400656

Env 🔄​flaky 💚​RECOVERED ✅​pass 🙈​skip Time
💚​ aws linux 1 275 15 6:05
💚​ aws windows 1 277 13 5:21
💚​ azure linux 1 274 15 5:29
🔄​ azure windows 2 1 274 13 4:28
💚​ gcp linux 1 275 15 6:38
💚​ gcp windows 1 277 13 5:41
Test Name aws linux aws windows azure linux azure windows gcp linux gcp windows
💚​ TestAccept 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R
🔄​ TestFsCpSourceIsDirectoryButTargetIsFile ✅​p ✅​p ✅​p 🔄​f ✅​p ✅​p
🔄​ TestFsCpSourceIsDirectoryButTargetIsFile/uc-volumes_to_dbfs ✅​p ✅​p ✅​p 🔄​f ✅​p ✅​p
Top 3 slowest tests (at least 2 minutes):
duration env testname
5:19 aws windows TestAccept
4:08 azure windows TestAccept
3:57 gcp windows TestAccept

The first version of the test only looked at each adapter surface type, so it
covered 7 of the 8 receivers this branch flips. PostgresRoleConfig is reachable
only as an embedded member of PostgresRole -- PostgresRoleState is its own struct
rather than an alias to the Config -- and PostgresRole declares its own value
receiver, which hides the member's asymmetry from a top-level check. Reverting
that one receiver left the test passing.

Walk the reachable type graph instead. Reverting all 8 now flags exactly those 8.

The walk also covers named fields and collection elements, which is where the
same defect would be live rather than latent: marshal's structAsMap stores those
into a map via .Interface(), and a map value is not addressable, so a pointer
receiver is unreachable there. None exist today.
Four defects in the detector, from an adversarial review pass:

- Restricting to Kind()==Struct skipped named non-struct types, which diverge
  identically. `type ID string` with a pointer-receiver MarshalJSON as a struct
  field marshals as {"n":"real"} by value and {"n":"MARSHALER"} by pointer.
- derefType stripped pointers in a loop, so `type L *L` -- legal Go, where Elem()
  returns the type itself -- spun forever. Pointers are now followed as graph
  edges, terminating on seen.
- A type reachable only through a json:"-" field was reported even though it is
  never serialized. Skipped now, for the same reason unexported fields are.
- The doc comment claimed collection elements are non-addressable. Slice elements
  are addressable and do reach a pointer receiver; only map values (and array
  elements in a non-addressable slot) do not.

Widening reports no new types in the tree, and reverting the 8 receivers still
flags exactly those 8.
TestMarshalerValueReceiver accumulated every surface into one shared walk and
asserted once at the end, unlike its neighbours which subtest per resource type.
The reason given was deduplication, and it does not hold: measured at the
pre-fix baseline, each violating type is reachable from exactly one resource, so
there is nothing to deduplicate.

Subtest per resource type instead. A failure now names the resource, so
`-run TestMarshalerValueReceiver/postgres_roles` selects it.

Each subtest owns its maps rather than sharing them, since a shared seen map
would attribute a type to whichever subtest reached it first and iteration order
over SupportedResources is random. That re-walks shared SDK types: 1628 type
visits instead of 1111, 3.8ms instead of 0.8ms, against a test that already
takes ~0.6s.
TestMarshalerValueReceiver asserts the invariant reflectively; these pin the
concrete behaviour it protects, as a plain marshal-and-compare that reads at a
glance. Both fail with a pointer receiver and pass with a value one:

- ForceSendFields on an omitempty zero value (purge_on_delete) is emitted only
  by the SDK marshaler; the plain encoding/json fallback a value takes under a
  pointer receiver would drop it.
- json.Marshal(x) and json.Marshal(&x) produce identical bytes.
@denik
denik marked this pull request as ready for review September 8, 2026 10:39
// TestPostgresBranchConfigMarshalHonorsForceSendFields pins the reason
// PostgresBranchConfig.MarshalJSON is declared on a value receiver.
//
// PurgeOnDelete is omitempty, so its zero value is emitted only because

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume a marshaller on the struct itself is also used for the pointer type then?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I see you have a test right below.

@shreyas-goenka shreyas-goenka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description is very hard to parse. Could you make it easier to understand?

@denik
denik added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit 6ea4d4f Sep 8, 2026
40 checks passed
@denik
denik deleted the denik/marshal-receiver-symmetry branch September 8, 2026 11:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants