skip to content

A reconcile loop clones observed state with maps.Clone, mutates the clone, and never converges — how do you find the cause?

level: seniorimportance: nice to knowfreq 32%

answer

  1. the copy was never independent
  2. one level deep only
  3. nested slices share the same array
  4. mutate the clone, assert the original
  5. nil versus empty makes the diff never settle

basics

~20 s

maps.Clone and slices.Clone copy one level only, so nested slices and maps in the copy still share storage with the observed snapshot. Mutating the copy edits the snapshot too, and the desired-versus-observed diff comes out wrong on every tick.

solid answer

~50 s

First I reproduce it as a unit test: clone the observed state, mutate only the clone, and assert with `reflect.DeepEqual` that the original is untouched. That fails immediately when the clone is shallow — `maps.Clone` copies the top-level pairs by assignment, so a value that is itself a `[]string` or a map is shared. In-place helpers make it worse: `slices.Delete` shifts and zeroes *inside* the shared array, so deleting a tag from the copy deletes it from the snapshot. Then I check nil versus empty: if the desired side builds an empty non-nil map and the observed side decodes a nil one, `reflect.DeepEqual` calls them different forever and the loop rewrites the same object every tick, where `maps.Equal` would call them equal. The fix is an explicit clone of the nested containers, or never mutating the snapshot at all.

code

go · 8 lines
go
observed := map[string][]string{"web": {"prod", "canary"}}
copied := maps.Clone(observed)

copied["web"] = slices.Delete(copied["web"], 1, 2)

// copied["web"] is [prod]
// observed["web"] is [prod ""] — the shared array was
// shifted and its vacated slot zeroed

go deeper

for a junior

Know that maps.Clone and slices.Clone both copy one level only, so a copy whose values are slices or maps still shares those containers with the original.

for a middle

Explain how an in-place helper such as slices.Delete writes through a shared backing array, and how nil versus empty changes what reflect.DeepEqual reports about two otherwise identical maps.

for a senior

Drive the diagnosis: reproduce with a test that mutates the clone and asserts the original is untouched, then choose between a real deep clone and treating the snapshot as immutable.

for a principal

Own the invariant for the codebase — snapshots immutable by convention, or defensively cloned at one boundary — because ad-hoc clones scattered through a reconcile loop are a cost every future change pays.

## The shape of the bug A reconcile loop reads the observed state of a resource, computes the desired state, compares the two, and writes only if they differ. When it never converges, the loop is either writing on every tick (the diff never comes out empty) or never writing at all (the diff always comes out empty even though the world is wrong). Both symptoms fall out of the same root cause more often than anything else: **the "copy" of the observed state was never independent of the observed state.** ## Why the clone was shallow Both stdlib clone helpers copy exactly one level: - `maps.Clone(m)` allocates a new map and copies the key/value pairs with ordinary assignment. If the value type is `[]string`, each *header* is copied and each backing array is shared. If the value type is another map or a pointer, the pointee is shared. - `slices.Clone(s)` allocates a new array of `len(s)` elements and copies them with ordinary assignment. For `[]Resource` where `Resource` has a `Tags []string` field, every struct is copied but every `Tags` array is shared. So `desired := maps.Clone(observed)` followed by a mutation of `desired["web"]` writes straight through into `observed["web"]`. The diff is then computed between two structures that just moved together, and it comes out empty — the loop concludes there is nothing to do while the cluster stays wrong. ## Why in-place helpers make it worse `slices.Delete`, `slices.Compact`, `slices.Replace` and `slices.Insert` (when capacity allows) all work **inside the backing array**. They do not merely fail to protect the original; they actively rewrite it, and since Go 1.22 they also zero the slots they vacate. Concretely: ```go observed := map[string][]string{"web": {"prod", "canary"}} copied := maps.Clone(observed) copied["web"] = slices.Delete(copied["web"], 1, 2) ``` `copied["web"]` is now `[prod]`, and `observed["web"]` is `[prod ""]` — length 2, second element zeroed. The snapshot you were going to diff against has been corrupted in a way that looks like data loss upstream, which is what sends people hunting in the wrong system at 3am. ## The nil-versus-empty half of the problem The other way a reconcile loop fails to settle is a diff that is *never* empty. `reflect.DeepEqual` distinguishes a nil slice or map from an empty non-nil one: they are deeply equal only if both are nil or both are non-nil. A desired state built in code as `map[string]string{}` and an observed state decoded from an empty payload as `nil` will compare unequal forever, so the loop writes the same object on every tick — a hot loop with no visible change, which shows up as write pressure or rate limiting rather than as a bug. `maps.Equal` and `slices.Equal` do not have that rule: they compare pairs and elements, and a nil container simply has none, so nil and empty compare equal. Whichever you pick, pick it because it matches the contract — if "absent" and "present but empty" mean different things to your API, the comparison must preserve that; if they do not, use the comparison that ignores it. ## How to diagnose it 1. **Write the aliasing test.** Build the observed state, clone it, mutate every nested container in the clone, then assert with `reflect.DeepEqual` that the original still equals a freshly-built expected value. A shallow field fails this instantly and names itself in the diff. Do the reverse too — mutate the original, assert the clone is unchanged — because a single shared field only shows up in one direction if your mutation happens to be one-sided. 2. **Log the diff, not the decision.** Print the two structures the comparison actually received, not a boolean. A diff that is empty when the world is wrong points at aliasing; a diff that is permanently non-empty on a field that looks identical points at nil versus empty. 3. **Check the comparison function.** Grep for `reflect.DeepEqual` in the reconcile path. It is both the slowest option (reflection on every tick) and the one with the nil/empty rule. 4. **Then decide the fix, not just the patch.** Two defensible fixes exist: deep-clone the nested containers explicitly at the boundary where the snapshot enters the loop, or treat the observed snapshot as immutable and never mutate it at all, building the desired state from scratch. The second is usually better — a defensive clone is a cost paid on every tick and one forgotten nested field brings the bug back, whereas an immutability convention can be reviewed. ## What to write down afterwards The rule worth putting in the package doc is short: **`Clone` in the standard library is one level deep, and the `slices` mutators are in place.** Anything that must survive being handed to code that mutates it needs either a real deep copy written for that type, or a documented contract that the caller must not write to it.

  • How would you write the regression test once the clone is fixed?
    Build the observed state, clone it, mutate every nested container in the clone, and assert reflect.DeepEqual against a freshly built expected original. Then do the reverse — mutate the original and assert the clone is unchanged — because one shared field often shows up in only one direction, depending on which side you happened to write to.
  • Why is reflect.DeepEqual a poor choice for the desired-versus-observed comparison itself?
    It distinguishes nil from empty for slices and maps, which the two sides of a reconcile rarely agree on, and it walks the whole structure by reflection on every tick. maps.Equal and slices.Equal are typed at compile time, far cheaper, and treat nil and empty alike, which usually matches what the contract actually means.
  • What evidence tells you this is aliasing rather than genuine drift in the observed state?
    The snapshot changes without anything re-reading it. Capture the observed state into a separate copy before the mutation, run the mutation on the clone, and compare: if the snapshot moved while nothing fetched it again, the write went through a shared array. A stale-read bug would only change the snapshot after a refresh.

saying these in an interview costs you the question

  • Assumes maps.Clone deep-copies nested slices
  • Adds a retry or a sleep instead of finding the aliasing
  • Uses reflect.DeepEqual for the diff and never checks nil versus empty
  • Mutates the observed snapshot and calls the result desired state
  • Thinks slices.Delete on a shared array leaves the original intact