Skip to content

Dependent with a ForceNew input reading a replaced resource's computed attribute is not replaced when the value is unchanged #421

Description

@iwahbe

Summary

When a resource is replaced, OpenTofu also replaces any dependent whose ForceNew input reads one of the replaced resource's computed attributes — the decision is made at plan time, while the attribute is unknown. pulumi-hcl makes the decision at registration time, after the attribute has resolved to a concrete value. When the provider recomputes the same value (stable ARNs, deterministic names, constant tokens), the dependent's diff is empty and pulumi-hcl leaves it in place, while OpenTofu replaces it.

Found by #413, which carries a failing tfcompat test (TestL2CbdDependentReplaceOrder).

Reproduction

resource "anchor" "a" {
  knob = "v1"                # ForceNew input; edited to "v2" to force a replace
  # token: computed output — provider returns the constant "tok" on every create
  lifecycle { create_before_destroy = true }
}

resource "child" "b" {
  parent = anchor.a.token    # ForceNew input reading a's computed attribute
  # no lifecycle → default delete-before-create
}

Apply once, then change knob to "v2" and apply again.

OpenTofu (plan on unknowns): a is replaced, so planned a.token is (known after apply); an unknown value in b's ForceNew parent forces b's replacement. Apply order: destroy(b_old) → create(a_new) → create(b_new) → destroy(a_old).

pulumi-hcl (diff on concrete values): a's replacement completes before b registers, so b.parent diffs "tok" vs "tok" — no change, b is untouched. Final state diverges: OpenTofu replaced b, pulumi-hcl did not.

Why it happens

The Pulumi engine already implements OpenTofu's plan semantics for the delete-before-replace branch: calculateDependentReplacements walks the old snapshot, substitutes unknowns into each dependent's properties that depend on the condemned resource, and asks the provider's Diff whether that forces replacement — then eagerly deletes the condemned dependents. Dropping create_before_destroy from a in the program above produces identical behavior to OpenTofu.

The create-before-delete branch (which every create_before_destroy resource takes, and which is pulumi-hcl's mapping for lifecycle { create_before_destroy = true }) has no dependent handling at all. That is the hole.

What a full fix looks like (engine-side, pulumi/pulumi)

  1. Replacement-set parity. In the create-before-delete replace branch, run the same calculateDependentReplacements computation, but record the condemned dependents in a forced-replacement set instead of deleting them. When a dependent later registers with an unchanged value, force the replace; its own deleteBeforeReplace goal orders its delete/create. This fixes the final-state divergence. Because Pulumi's value-based diffing is intentional for the other SDKs, this likely ships gated behind a per-resource opt-in flag that pulumi-hcl sets on every resource.

  2. Operation-order parity. OpenTofu deterministically destroys a delete-before-create dependent before creating the dependency's replacement (DestroyEdgeTransformer connects the dependency's create to the dependent's destroy). The engine cannot do this today because the dependent's deleteBeforeReplace preference is a goal option, not persisted in state, and the dependent has not registered yet when the root's steps are generated. Fix: persist deleteBeforeReplace in the checkpoint (precedent: ReplaceWith), then eagerly emit the dependent's DeleteReplacement before the root's CreateReplacement. Without this piece, final states match but the interleaving differs (the old dependent briefly coexists with the new dependency).

A langhost-side stopgap was prototyped on the PR branch (mark computed outputs, translate schema WillReplaceOnChanges consumption into ReplaceWith) but rejected: it re-implements the provider's replace decision approximately in the language runtime (top-level properties only; misses nested ForceNew fields, Optional+Computed attributes left unset in config, and chains through input echoes).

Impact

Bites only when all of these hold: a resource is replaced, a dependent reads one of its computed attributes into a ForceNew input, and the recomputed value is identical to the old one. The dependent then silently keeps running against the new upstream instance where OpenTofu would have recreated it. Deferred until this shows up in a real migration.

Metadata

Metadata

Assignees

No one assigned

    Labels

    kind/bugSome behavior is incorrect or out of spec

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions