Repository: pulumi/pulumi-terraform-module
Pull request #254: Handle partial delete operations
Author: login: corymhall
Created: 2025-03-28T18:38:54Z | Merged: 2025-04-11T18:14:13Z

This PR handles the case where a deletion of the module component fails.
In order to handle this case, I've added additional logic to the child
`Delete` method. It now waits for the final state to be written by the
module state resource and checks to make sure that it can't find itself
in the state.

- If it _can't_ find itself in the state, then it was deleted.
- If it _can_ find itself in the state, then it was not deleted; return an
  error
- If there is no state at all then also return an error because
  something went wrong (note on this below).

If a tofu destroy fails with some resources being deleted then we will
fail our deletion operation. This means that the `ModuleState` resource
will still have the prior state that existed before the destroy
operation. This is fine because `tofu` runs `refresh` and will present a
new plan based on the latest view of the world.

**Note on nil state**

When `tofu destroy` completes, whether successful or not, there is
always a state file. On a successful destroy, the state file will not
have any resources, but it will still be valid and will return something
from `show`. So for *all cases we will have a `State` result that we can
send to the child resources to process.

I've also added logic for the off chance that something goes wrong and
there is no state file at all after the deletion. In that case we fail
the delete across the board (module component, module state, and child
resources) so that the Pulumi state remains the same as it was prior to
the delete operation.

re #183 


Diff:
diff --git a/pkg/modprovider/child.go b/pkg/modprovider/child.go
index abac3aad..5137c7e1 100644
--- a/pkg/modprovider/child.go
+++ b/pkg/modprovider/child.go
@@ -342,8 +342,13 @@ func (h *childHandler) Update(
 
 func (h *childHandler) Delete(
 	_ context.Context,
-	_ *pulumirpc.DeleteRequest,
+	req *pulumirpc.DeleteRequest,
 ) (*emptypb.Empty, error) {
+	modUrn, addr := h.mustParseAddress(req.GetOldInputs())
+	isDeleted := h.planStore.IsResourceDeleted(modUrn, addr)
+	if !isDeleted {
+		return nil, fmt.Errorf("Deletion failed")
+	}
 	return &emptypb.Empty{}, nil
 }
 
@@ -361,9 +366,6 @@ func (h *childHandler) Read(
 		// Refresh has removed the resource to reflect that it can no longer be found.
 		return &pulumirpc.ReadResponse{Id: ""}, nil
 	}
-	if err != nil {
-		return nil, fmt.Errorf("Error during child resource Read: %w", err)
-	}
 	inputs := childResourceInputs(modUrn, rstate.Address(), rstate.AttributeValues())
 	return &pulumirpc.ReadResponse{
 		Id:         childResourceID(rstate),
diff --git a/pkg/modprovider/child_test.go b/pkg/modprovider/child_test.go
index a58f19b1..e6780f80 100644
--- a/pkg/modprovider/child_test.go
+++ b/pkg/modprovider/child_test.go
@@ -26,8 +26,12 @@ import (
 	"github.com/pulumi/pulumi/sdk/v3/go/common/resource/urn"
 	"github.com/pulumi/pulumi/sdk/v3/go/common/tokens"
 	pulumirpc "github.com/pulumi/pulumi/sdk/v3/proto/go"
+
+	"github.com/pulumi/pulumi-terraform-module/pkg/tfsandbox"
 )
 
+const modUrn = "urn:pulumi:test::prog::randmod:index:Module::mymod"
+
 func TestChildResoruceTypeToken(t *testing.T) {
 	pkgName := testPackageName()
 	tok := childResourceTypeToken(pkgName, "aws_s3_bucket")
@@ -85,8 +89,6 @@ func TestChildResourceCreate(t *testing.T) {
 	ctx := context.Background()
 	h := newChildHandler(&planStore{})
 
-	modUrn := "urn:pulumi:test::prog::randmod:index:Module::mymod"
-
 	h.planStore.SetState(urn.URN(modUrn), &testState{&testResourceState{
 		address: "module.s3_bucket.aws_s3_bucket.this[0]",
 		name:    "this",
@@ -124,8 +126,128 @@ func TestChildResourceUpdate(t *testing.T) {
 }
 
 func TestChildResourceDelete(t *testing.T) {
-	t.Skip("TODO")
+	t.Run("delete successful", func(t *testing.T) {
+		ctx := context.Background()
+		h := newChildHandler(&planStore{})
+
+		h.planStore.SetState(urn.URN(modUrn), &testState{&testResourceState{}})
+		properties, err := structpb.NewStruct(map[string]any{
+			childResourceAddressPropName: "module.s3_bucket.aws_s3_bucket.this[0]",
+			moduleURNPropName:            modUrn,
+			"force_destroy":              true,
+		})
+		require.NoError(t, err)
+		_, err = h.Delete(ctx, &pulumirpc.DeleteRequest{
+			Type:      "terraform-aws-module:tf:aws_s3_bucket",
+			OldInputs: properties,
+		})
+		require.NoErrorf(t, err, "expected destroy to succeed")
+	})
+
+	t.Run("replacement successful", func(t *testing.T) {
+		ctx := context.Background()
+		h := newChildHandler(&planStore{})
+
+		h.planStore.SetPlan(urn.URN(modUrn), &testPlan{byAddress: map[ResourceAddress]testResourcePlan{
+			"module.s3_bucket.aws_s3_bucket.this[0]": {
+				resourceAddress: "module.s3_bucket.aws_s3_bucket.this[0]",
+				changeKind:      tfsandbox.Replace,
+				name:            "this",
+				resType:         "s3_bucket",
+				plannedValues: resource.PropertyMap{
+					"force_destroy": resource.NewBoolProperty(true),
+				},
+			},
+		}})
+		h.planStore.SetState(urn.URN(modUrn), &testState{&testResourceState{
+			address: "module.s3_bucket.aws_s3_bucket.this[0]",
+			name:    "this",
+			index:   float64(0),
+			attrs: resource.PropertyMap{
+				"force_destroy": resource.NewBoolProperty(true),
+			},
+		}})
+		properties, err := structpb.NewStruct(map[string]any{
+			childResourceAddressPropName: "module.s3_bucket.aws_s3_bucket.this[0]",
+			moduleURNPropName:            modUrn,
+			"force_destroy":              true,
+		})
+		require.NoError(t, err)
+		_, err = h.Delete(ctx, &pulumirpc.DeleteRequest{
+			Type:      "terraform-aws-module:tf:aws_s3_bucket",
+			OldInputs: properties,
+		})
+		require.NoErrorf(t, err, "expected destroy to succeed")
+	})
 
+	t.Run("delete in update successful", func(t *testing.T) {
+		ctx := context.Background()
+		h := newChildHandler(&planStore{})
+
+		h.planStore.SetPlan(urn.URN(modUrn), &testPlan{byAddress: map[ResourceAddress]testResourcePlan{}})
+		h.planStore.SetState(urn.URN(modUrn), &testState{&testResourceState{}})
+		properties, err := structpb.NewStruct(map[string]any{
+			childResourceAddressPropName: "module.s3_bucket.aws_s3_bucket.this[0]",
+			moduleURNPropName:            modUrn,
+			"force_destroy":              true,
+		})
+		require.NoError(t, err)
+		_, err = h.Delete(ctx, &pulumirpc.DeleteRequest{
+			Type:      "terraform-aws-module:tf:aws_s3_bucket",
+			OldInputs: properties,
+		})
+		require.NoErrorf(t, err, "expected destroy to succeed")
+	})
+
+	t.Run("delete in update failed", func(t *testing.T) {
+		ctx := context.Background()
+		h := newChildHandler(&planStore{})
+
+		h.planStore.SetPlan(urn.URN(modUrn), &testPlan{byAddress: map[ResourceAddress]testResourcePlan{}})
+		h.planStore.SetState(urn.URN(modUrn), &testState{&testResourceState{
+			address: "module.s3_bucket.aws_s3_bucket.this[0]",
+			name:    "this",
+			index:   float64(0),
+			attrs: resource.PropertyMap{
+				"force_destroy": resource.NewBoolProperty(true),
+			},
+		}})
+		properties, err := structpb.NewStruct(map[string]any{
+			childResourceAddressPropName: "module.s3_bucket.aws_s3_bucket.this[0]",
+			moduleURNPropName:            modUrn,
+			"force_destroy":              true,
+		})
+		require.NoError(t, err)
+		_, err = h.Delete(ctx, &pulumirpc.DeleteRequest{
+			Type:      "terraform-aws-module:tf:aws_s3_bucket",
+			OldInputs: properties,
+		})
+		require.Errorf(t, err, "expected destroy to fail")
+	})
+
+	t.Run("delete failed, partial state", func(t *testing.T) {
+		ctx := context.Background()
+		h := newChildHandler(&planStore{})
+
+		// some other resource is still in the state, but not this resource
+		h.planStore.SetState(urn.URN(modUrn), &testState{&testResourceState{
+			address: "module.s3_bucket.aws_s3_bucket.other[0]",
+			resType: "aws_s3_bucket",
+			name:    "other",
+			index:   float64(0),
+		}})
+		properties, err := structpb.NewStruct(map[string]any{
+			childResourceAddressPropName: "module.s3_bucket.aws_s3_bucket.this[0]",
+			moduleURNPropName:            modUrn,
+			"force_destroy":              true,
+		})
+		require.NoError(t, err)
+		_, err = h.Delete(ctx, &pulumirpc.DeleteRequest{
+			Type:      "terraform-aws-module:tf:aws_s3_bucket",
+			OldInputs: properties,
+		})
+		require.NoErrorf(t, err, "expected destroy to succeed")
+	})
 }
 
 type testResourceState struct {
@@ -149,6 +271,10 @@ type testState struct {
 	res *testResourceState
 }
 
+func (ts *testState) IsValidState() bool {
+	return ts.res != nil
+}
+
 func (ts *testState) VisitResources(visitor func(ResourceState)) {
 	visitor(ts.res)
 }
diff --git a/pkg/modprovider/module_component.go b/pkg/modprovider/module_component.go
index 4f3de2d1..ee7f683c 100644
--- a/pkg/modprovider/module_component.go
+++ b/pkg/modprovider/module_component.go
@@ -77,15 +77,6 @@ func newModuleComponentResource(
 
 	urn := component.MustURN(ctx.Context())
 
-	defer func() {
-		// TODO[pulumi/pulumi-terraform-module#108] avoid deadlock
-		//
-		// This is only safe to run after all the children are done processing.
-		// Perhaps when fixing 108 this method will stop blocking to wait on that,
-		// in that case this cleanup has to move accordingly.
-		planStore.Forget(urn)
-	}()
-
 	var providerSelfRef pulumi.ProviderResource
 	if providerSelfURN != "" {
 		providerSelfRef = newProviderSelfReference(ctx, providerSelfURN)
@@ -212,6 +203,10 @@ func newModuleComponentResource(
 	} else {
 		// DryRun() = false corresponds to running pulumi up
 		tfState, applyErr = tf.Apply(ctx.Context(), logger)
+		// If we don't have a state we can work with, just fail
+		if !tfState.IsValidState() {
+			return nil, nil, nil, fmt.Errorf("Apply failed: %w", applyErr)
+		}
 
 		planStore.SetState(urn, tfState)
 
diff --git a/pkg/modprovider/planstore.go b/pkg/modprovider/planstore.go
index 59144e3c..2aa030bb 100644
--- a/pkg/modprovider/planstore.go
+++ b/pkg/modprovider/planstore.go
@@ -36,6 +36,19 @@ type planStore struct {
 	states map[urn.URN]*stateEntry
 }
 
+// getPlanEntry returns the plan entry for the given URN.
+// If the plan entry does not exist, callers are responsible for handling
+// NOTE: you probably want to use `getOrCreatePlanEntry` instead of this method.
+func (s *planStore) getPlanEntry(u urn.URN) (*planEntry, bool) {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+	if s.plans == nil {
+		return nil, false
+	}
+	entry, ok := s.plans[u]
+	return entry, ok
+}
+
 func (s *planStore) getOrCreatePlanEntry(u urn.URN) *planEntry {
 	s.mutex.Lock()
 	defer s.mutex.Unlock()
@@ -117,7 +130,7 @@ func (e *stateEntry) Await() State {
 	case <-ch:
 		return e.state
 	case <-time.After(*waitTimeout):
-		panic("Timeout waiting on planEntry")
+		panic("Timeout waiting on stateEntry")
 	}
 }
 
@@ -126,18 +139,6 @@ func (e *stateEntry) Set(state State) {
 	e.waitGroup.Done()
 }
 
-// Avoid memory leaks and clean the state when done.
-func (s *planStore) Forget(modUrn urn.URN) {
-	s.mutex.Lock()
-	defer s.mutex.Unlock()
-	if s.plans != nil {
-		delete(s.plans, modUrn)
-	}
-	if s.states != nil {
-		delete(s.states, modUrn)
-	}
-}
-
 func (s *planStore) SetPlan(modUrn urn.URN, plan Plan) {
 	entry := s.getOrCreatePlanEntry(modUrn)
 	entry.Set(plan)
@@ -156,6 +157,56 @@ func (e unknownAddressError) Error() string {
 	return fmt.Sprintf("unknown address: %q", e.addr)
 }
 
+// IsResourceDeleted returns true if the resource is deleted (not in the state).
+//
+// There are a couple different delete cases that this function handles:
+//  1. This is a `pulumi dn` operation and the entire stack is being deleted
+//     In this case there will be no `plan` because the `moduleStateHandler.Delete` method does not run plan.
+//  2. This is a `pulumi up` operation and this resource is being deleted or replaced.
+//     2a. If the resource is deleted (removed) then it won't be in the plan
+//     and we can check the state to know if it was successful
+//     2b. If the resource is replaced then the resource will remain in the state
+//     for both the success and failure case. We can't know for sure
+//
+// NOTE: This should only be called from within the `Delete` method of the child resource
+//
+// TODO[pulumi/pulumi-terraform-module#265] determine if the delete for this
+// specific resource succeeded
+func (s *planStore) IsResourceDeleted(
+	modUrn urn.URN,
+	addr ResourceAddress,
+) bool {
+	modState := s.getOrCreateStateEntry(modUrn).Await()
+	if planEntry, ok := s.getPlanEntry(modUrn); ok {
+		// If there is a planEntry then this is not a result of `moduleStateHandler.Delete`
+		// It is either a resource deletion due to removal or a resource replacement
+		plan := planEntry.Await()
+		_, ok := plan.FindResourceStateOrPlan(addr)
+		if !ok {
+			// If we have a state entry, and we don't have a plan entry for this resource
+			// then this is a true delete (not a replacement). We can check
+			// for the resource in state
+			_, ok := modState.FindResourceStateOrPlan(addr)
+			return !ok
+		}
+		// otherwise if there is an entry in the plan then this must
+		// be a replacement and we have to assume it succeeded
+		return true
+	}
+	// Otherwise this is a result of `moduleStateHandler.Delete`
+	// and we just need to check the state
+	if !modState.IsValidState() {
+		// if we don't have a valid state, then stop here and return false
+		// If the underlying state is nil, it means something went wrong with the state after
+		// the tofu destroy operation. In that case, the ModuleState resource will
+		// not be deleted and we should keep all the child resources as well so we can
+		// try again (essentially treat the operation as a no-op).
+		return false
+	}
+	_, ok := modState.FindResourceStateOrPlan(addr)
+	return !ok
+}
+
 func (s *planStore) FindResourceState(
 	modUrn urn.URN,
 	addr ResourceAddress,
diff --git a/pkg/modprovider/planstore_test.go b/pkg/modprovider/planstore_test.go
index 300bda24..071e89b7 100644
--- a/pkg/modprovider/planstore_test.go
+++ b/pkg/modprovider/planstore_test.go
@@ -100,6 +100,10 @@ func (x *testResourcePlan) Values() resource.PropertyMap        { return x.plann
 var _ ResourcePlan = (*testResourcePlan)(nil)
 var _ ResourceStateOrPlan = (*testResourcePlan)(nil)
 
+func (p *testPlan) IsValidPlan() bool {
+	return p.byAddress != nil
+}
+
 func (p *testPlan) FindResourceStateOrPlan(addr ResourceAddress) (ResourceStateOrPlan, bool) {
 	r, ok := p.byAddress[addr]
 	if !ok {
diff --git a/pkg/modprovider/resources.go b/pkg/modprovider/resources.go
index a4c8ab50..dc8a40c1 100644
--- a/pkg/modprovider/resources.go
+++ b/pkg/modprovider/resources.go
@@ -46,12 +46,14 @@ var _ ResourceState = (*tfsandbox.ResourceState)(nil)
 
 type Plan interface {
 	Resources
+	IsValidPlan() bool
 }
 
 var _ Plan = (*tfsandbox.Plan)(nil)
 
 type State interface {
 	Resources // returns ResourceStateOrPlan=ResourceState
+	IsValidState() bool
 }
 
 var _ State = (*tfsandbox.State)(nil)
diff --git a/pkg/modprovider/state.go b/pkg/modprovider/state.go
index 430e9635..94cdb932 100644
--- a/pkg/modprovider/state.go
+++ b/pkg/modprovider/state.go
@@ -303,13 +303,15 @@ func (h *moduleStateHandler) Delete(
 		return nil, fmt.Errorf("Init failed: %w", err)
 	}
 
-	err = tf.Destroy(ctx, logger)
-	if err != nil {
-		return nil, fmt.Errorf("Delete failed: %w", err)
+	state, destroyErr := tf.Destroy(ctx, logger)
+	if destroyErr != nil {
+		logger.Log(ctx, tfsandbox.Debug, fmt.Sprintf("error running tofu destroy in delete: %v", destroyErr))
 	}
+	// always set the state because child resources are waiting for it
+	h.planStore.SetState(urn, state)
 
 	// Send back empty pb if no error.
-	return &emptypb.Empty{}, nil
+	return &emptypb.Empty{}, destroyErr
 }
 
 func (h *moduleStateHandler) Read(
diff --git a/pkg/tfsandbox/apply.go b/pkg/tfsandbox/apply.go
index 91905a3f..690c07f3 100644
--- a/pkg/tfsandbox/apply.go
+++ b/pkg/tfsandbox/apply.go
@@ -4,7 +4,6 @@ import (
 	"context"
 	"fmt"
 
-	"github.com/hashicorp/terraform-exec/tfexec"
 	tfjson "github.com/hashicorp/terraform-json"
 )
 
@@ -13,11 +12,12 @@ import (
 // Apply can return both a non-nil State and a non-nil error. If the apply
 // fails, but some resources were created and written to the TF State we will return
 // the state and the apply error.
+// Both the State and error can be non-nil
 func (t *Tofu) Apply(ctx context.Context, logger Logger) (*State, error) {
 	state, applyErr := t.apply(ctx, logger)
 	s, err := newState(state)
 	if err != nil {
-		return nil, err
+		return s, err
 	}
 	return s, applyErr
 }
@@ -34,11 +34,9 @@ func (t *Tofu) apply(ctx context.Context, logger Logger) (*tfjson.State, error)
 		logger.Log(ctx, Debug, fmt.Sprintf("error running tofu apply: %v", applyErr))
 	}
 
-	// NOTE: the recommended default from terraform-json is to set JSONNumber=true
-	// otherwise some number values will lose precision when converted to float64
-	state, err := t.tf.Show(ctx, tfexec.JSONNumber(true))
+	state, err := t.Show(ctx)
 	if err != nil {
-		return nil, fmt.Errorf("error running tofu show: %w", err)
+		return nil, err
 	}
 
 	return state, applyErr
diff --git a/pkg/tfsandbox/destroy.go b/pkg/tfsandbox/destroy.go
index 9f8c6ce1..206214e3 100644
--- a/pkg/tfsandbox/destroy.go
+++ b/pkg/tfsandbox/destroy.go
@@ -3,16 +3,81 @@ package tfsandbox
 import (
 	"context"
 	"fmt"
+
+	tfjson "github.com/hashicorp/terraform-json"
 )
 
 // Destroy runs the terraform destroy command
-func (t *Tofu) Destroy(ctx context.Context, log Logger) error {
+// This handles a couple of special cases around destroy.
+// The returned State and Error can both be not nil. This would be the case
+// if the destroy failed and some resources were destroyed and some were not.
+//
+// There are two special cases that callers should be aware of:
+//  1. If the destroy was successful, we return a non-nil state with no resources
+//  2. If the destroy failed, we return the state with the resources that failed to destroy
+func (t *Tofu) Destroy(ctx context.Context, log Logger) (*State, error) {
+	var rawState *tfjson.State
+	rawState, destroyErr := t.destroy(ctx, log)
+	state, err := newDestroyState(rawState)
+	if err != nil {
+		log.Log(ctx, Debug, fmt.Sprintf("error creating destroy state: %v", err))
+		return state, err
+	}
+	return state, destroyErr
+}
+
+// Destroy runs the terraform destroy command
+// This handles a couple of special cases around destroy.
+//  1. If the destroy was successful, we return the state, nil error
+//  2. If the destroy failed, then we have 2 possibilities
+//     a. Show fails, either because we have a nil state or something else.
+//     In this case, we return a nil state, and the error.
+//     callers need to handle this case and decide what to do (probably just fail)
+//     b. Show succeeds, in this case we return the state, and the error.
+//     This would most likely be a partial failure where some resources were destroyed
+//     and some were not. The resource that were not destroyed would still exist in the state
+func (t *Tofu) destroy(ctx context.Context, log Logger) (state *tfjson.State, err error) {
 	logWriter := newJSONLogPipe(ctx, log)
 	defer logWriter.Close()
 
-	if err := t.tf.DestroyJSON(ctx, logWriter); err != nil {
-		return fmt.Errorf("error running tofu destroy: %w", err)
+	err = t.tf.DestroyJSON(ctx, logWriter)
+	if err != nil {
+		log.Log(ctx, Debug, fmt.Sprintf("error running tofu destroy: %v", err))
 	}
+	var showErr error
+	state, showErr = t.Show(ctx)
+	if showErr != nil {
+		log.Log(ctx, Debug, fmt.Sprintf("error running tofu show: %v", showErr))
+		return nil, showErr
+	}
+
+	return state, err
+}
 
-	return nil
+// newDestroyState creates a new destroy state from the raw state
+// This handles cases where the rawState is nil or the root values/module is nil
+// which would happen if the destroy was successful
+//
+// Note that the returned State will never be nil
+func newDestroyState(rawState *tfjson.State) (*State, error) {
+	if rawState == nil {
+		return emptyState(newT), nil
+	}
+	var resources stateResources
+	var err error
+	if rawState.Values == nil || rawState.Values.RootModule == nil {
+		resources = stateResources{}
+	} else {
+		resources, err = newStateResources(rawState.Values.RootModule)
+		if err != nil {
+			return emptyState(newT), err
+		}
+	}
+	return &State{
+		Resources: Resources[*ResourceState]{
+			resources: resources,
+			newT:      newT,
+		},
+		rawState: rawState,
+	}, nil
 }
diff --git a/pkg/tfsandbox/details.go b/pkg/tfsandbox/details.go
index 31c98c2a..2aad7ca9 100644
--- a/pkg/tfsandbox/details.go
+++ b/pkg/tfsandbox/details.go
@@ -152,6 +152,10 @@ type Plan struct {
 	rawPlan *tfjson.Plan
 }
 
+func (p *Plan) IsValidPlan() bool {
+	return p.rawPlan != nil
+}
+
 func newPlan(rawPlan *tfjson.Plan) (*Plan, error) {
 	// TODO[pulumi/pulumi-terraform-module#61] what about PreviousAddress, can TF plan
 	// resources changing addresses? How does this work?
@@ -243,27 +247,48 @@ type State struct {
 	rawState *tfjson.State
 }
 
+var newT = func(resource tfjson.StateResource) *ResourceState {
+	return &ResourceState{
+		Resource: Resource{
+			sr:    resource,
+			props: extractPropertyMapFromState(resource),
+		},
+	}
+}
+
+// IsValidState returns true if the state is valid and can be used
+// If false it means the underlying state is nil and can't be used
+func (s *State) IsValidState() bool {
+	return s.rawState != nil
+}
+
+// newState creates a new state from the raw state
+// Both state and error can be non-nil. State.IsValidState() can be
+// used to check if the state is valid (i.e. not nil)
 func newState(rawState *tfjson.State) (*State, error) {
 	resources, err := newStateResources(rawState.Values.RootModule)
 	if err != nil {
-		return nil, err
+		return emptyState(newT), err
 	}
 	return &State{
 		Resources: Resources[*ResourceState]{
 			resources: resources,
-			newT: func(resource tfjson.StateResource) *ResourceState {
-				return &ResourceState{
-					Resource: Resource{
-						sr:    resource,
-						props: extractPropertyMapFromState(resource),
-					},
-				}
-			},
+			newT:      newT,
 		},
 		rawState: rawState,
 	}, nil
 }
 
+func emptyState(newT func(resource tfjson.StateResource) *ResourceState) *State {
+	return &State{
+		Resources: Resources[*ResourceState]{
+			resources: stateResources{},
+			newT:      newT,
+		},
+		rawState: nil,
+	}
+}
+
 // outputIsSecret returns true if the output is a secret based on the value of the
 // corresponding is_secret output.
 func (s *State) outputIsSecret(outputName string) bool {
diff --git a/pkg/tfsandbox/show.go b/pkg/tfsandbox/show.go
new file mode 100644
index 00000000..2242cb74
--- /dev/null
+++ b/pkg/tfsandbox/show.go
@@ -0,0 +1,22 @@
+package tfsandbox
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/hashicorp/terraform-exec/tfexec"
+	tfjson "github.com/hashicorp/terraform-json"
+)
+
+// Show runs the terraform show command and returns the current state
+//
+// NOTE: the recommended default from terraform-json is to set JSONNumber=true
+// otherwise some number values will lose precision when converted to float64
+func (t *Tofu) Show(ctx context.Context) (*tfjson.State, error) {
+	state, err := t.tf.Show(ctx, tfexec.JSONNumber(true))
+	if err != nil {
+		return nil, fmt.Errorf("error running tofu show: %w", err)
+	}
+
+	return state, nil
+}
diff --git a/pkg/tfsandbox/tofu_test.go b/pkg/tfsandbox/tofu_test.go
index c1cb6ec5..8b0b3f5c 100644
--- a/pkg/tfsandbox/tofu_test.go
+++ b/pkg/tfsandbox/tofu_test.go
@@ -91,7 +91,7 @@ func TestTofuApply(t *testing.T) {
 	assert.NoError(t, err, "error running tofu refresh")
 	assert.Equal(t, "module.test.terraform_data.example", state.Values.RootModule.ChildModules[0].Resources[0].Address)
 
-	err = tofu.Destroy(ctx, DiscardLogger)
+	_, err = tofu.Destroy(ctx, DiscardLogger)
 	assert.NoErrorf(t, err, "error running tofu destroy")
 }
 
diff --git a/tests/acc_test.go b/tests/acc_test.go
index 2392f422..a9be339e 100644
--- a/tests/acc_test.go
+++ b/tests/acc_test.go
@@ -1,6 +1,7 @@
 package tests
 
 import (
+	"bytes"
 	"context"
 	"encoding/json"
 	"errors"
@@ -22,12 +23,15 @@ import (
 	iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
 	"github.com/aws/aws-sdk-go-v2/service/lambda"
 	lambdatypes "github.com/aws/aws-sdk-go-v2/service/lambda/types"
+	"github.com/aws/aws-sdk-go-v2/service/s3"
 	"github.com/hexops/autogold/v2"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
 	"github.com/pulumi/providertest/pulumitest"
 	"github.com/pulumi/providertest/pulumitest/opttest"
+	"github.com/pulumi/pulumi/sdk/v3/go/auto/debug"
+	"github.com/pulumi/pulumi/sdk/v3/go/auto/optdestroy"
 	"github.com/pulumi/pulumi/sdk/v3/go/auto/optpreview"
 	"github.com/pulumi/pulumi/sdk/v3/go/auto/optup"
 	"github.com/pulumi/pulumi/sdk/v3/go/common/apitype"
@@ -207,6 +211,249 @@ func TestPartialApply(t *testing.T) {
 	assert.Contains(t, upRes2.Outputs, "roleArn")
 }
 
+func TestPartialDestroy(t *testing.T) {
+	localProviderBinPath := ensureCompiledProvider(t)
+	skipLocalRunsWithoutCreds(t)
+
+	// Module written to support the test.
+	localMod, err := filepath.Abs(filepath.Join("testdata", "programs", "ts", "partial-destroy", "local_module"))
+	require.NoError(t, err)
+
+	testProgram := filepath.Join("testdata", "programs", "ts", "partial-destroy")
+	localPath := opttest.LocalProviderPath("terraform-module", filepath.Dir(localProviderBinPath))
+	integrationTest := pulumitest.NewPulumiTest(t, testProgram, localPath)
+
+	// Get a prefix for resource names
+	prefix := generateTestResourcePrefix()
+
+	// Set prefix via config
+	integrationTest.SetConfig(t, "prefix", prefix)
+
+	// Generate package
+	pulumiPackageAdd(t, integrationTest, localProviderBinPath, localMod, "localmod")
+	upRes, err := integrationTest.CurrentStack().Up(integrationTest.Context(), optup.Diff(),
+		optup.ErrorProgressStreams(os.Stderr),
+		optup.ProgressStreams(os.Stdout),
+	)
+	assert.NoErrorf(t, err, "unexpected error on up")
+	changes := *upRes.Summary.ResourceChanges
+	created := changes["create"]
+	assert.Equal(t, 5, created)
+
+	bucketName, ok := upRes.Outputs["bucketName"].Value.(string)
+	require.True(t, ok, "expected bucketName string output")
+
+	ctx := context.Background()
+	cfg, err := config.LoadDefaultConfig(ctx)
+	require.NoError(t, err)
+
+	// Create AWS clients
+	s3Client := s3.NewFromConfig(cfg)
+	_, err = s3Client.PutObject(ctx, &s3.PutObjectInput{
+		Bucket: &bucketName,
+		Key:    aws.String("test.txt"),
+		Body:   strings.NewReader("test"),
+	})
+	require.NoError(t, err, "failed to put object in S3 bucket")
+
+	_, err = integrationTest.CurrentStack().Destroy(ctx,
+		optdestroy.ErrorProgressStreams(os.Stderr),
+		optdestroy.ProgressStreams(os.Stdout),
+	)
+	assert.Errorf(t, err, "expected error on destroy")
+
+	// the tf state contains the resource that failed
+	assertTFStateResourceExists(t, integrationTest, "localmod", "module.test-localmod.aws_s3_bucket.bucket")
+
+	// only the bucket that failed still exists
+	mustFindDeploymentResourceByType(t, integrationTest, "localmod:tf:aws_s3_bucket")
+
+	_, err = s3Client.DeleteObject(ctx, &s3.DeleteObjectInput{
+		Bucket: &bucketName,
+		Key:    aws.String("test.txt"),
+	})
+	require.NoError(t, err, "failed to delete object in S3 bucket")
+
+	dnRes2 := integrationTest.Destroy(t,
+		optdestroy.ErrorProgressStreams(os.Stderr),
+		optdestroy.ProgressStreams(os.Stdout),
+	)
+	changes2 := *dnRes2.Summary.ResourceChanges
+	deleted := changes2["delete"]
+	// on the first failed destroy, we don't get back the ResourceChanges, but
+	// the first destroy should have destroyed 1 resource and then failed
+	// the second destroy will succeed and delete the rest
+	assert.Equal(t, created-1, deleted)
+}
+
+func TestPartialDestroyOnUpdate(t *testing.T) {
+	localProviderBinPath := ensureCompiledProvider(t)
+	skipLocalRunsWithoutCreds(t)
+
+	// Module written to support the test.
+	localMod, err := filepath.Abs(filepath.Join("testdata", "programs", "ts", "partial-destroy", "local_module"))
+	require.NoError(t, err)
+
+	testProgram := filepath.Join("testdata", "programs", "ts", "partial-destroy")
+	localPath := opttest.LocalProviderPath("terraform-module", filepath.Dir(localProviderBinPath))
+	integrationTest := pulumitest.NewPulumiTest(t, testProgram, localPath)
+
+	// Get a prefix for resource names
+	prefix := generateTestResourcePrefix()
+
+	// Set prefix via config
+	integrationTest.SetConfig(t, "prefix", prefix)
+
+	// Generate package
+	pulumiPackageAdd(t, integrationTest, localProviderBinPath, localMod, "localmod")
+	upRes, err := integrationTest.CurrentStack().Up(integrationTest.Context(), optup.Diff(),
+		optup.ErrorProgressStreams(os.Stderr),
+		optup.ProgressStreams(os.Stdout),
+	)
+	assert.NoErrorf(t, err, "unexpected error on up")
+	changes := *upRes.Summary.ResourceChanges
+	created := changes["create"]
+	assert.Equal(t, 5, created)
+
+	bucketName, ok := upRes.Outputs["bucketName"].Value.(string)
+	require.True(t, ok, "expected bucketName string output")
+
+	ctx := context.Background()
+	cfg, err := config.LoadDefaultConfig(ctx)
+	require.NoError(t, err)
+
+	// Create AWS clients
+	s3Client := s3.NewFromConfig(cfg)
+	_, err = s3Client.PutObject(ctx, &s3.PutObjectInput{
+		Bucket: &bucketName,
+		Key:    aws.String("test.txt"),
+		Body:   strings.NewReader("test"),
+	})
+	require.NoError(t, err, "failed to put object in S3 bucket")
+
+	// Update prefix via config, this will cause a replacement
+	integrationTest.SetConfig(t, "prefix", fmt.Sprintf("change-%s", prefix))
+
+	stdoutBuffer := bytes.NewBuffer(nil)
+	_, err = integrationTest.CurrentStack().Up(ctx,
+		optup.DebugLogging(debug.LoggingOptions{
+			Debug: true,
+		}),
+		optup.ProgressStreams(stdoutBuffer),
+	)
+
+	stdout := stdoutBuffer.String()
+	assert.Errorf(t, err, "expected error on up")
+	assert.Contains(t, stdout, "Plan: 2 to add, 0 to change, 2 to destroy.")
+	assert.Contains(t, stdout, "module.test-localmod.aws_s3_bucket.another_bucket[0]: Destruction complete")
+	assert.NotContains(t, stdout, "module.test-localmod.aws_s3_bucket.bucket[0]: Destruction complete")
+	assert.Contains(t, stdout, "Apply failed")
+
+	_, err = s3Client.DeleteObject(ctx, &s3.DeleteObjectInput{
+		Bucket: &bucketName,
+		Key:    aws.String("test.txt"),
+	})
+	require.NoError(t, err, "failed to delete object in S3 bucket")
+
+	stdoutBuffer = bytes.NewBuffer(nil)
+	upRes3 := integrationTest.Up(t,
+		optup.DebugLogging(debug.LoggingOptions{
+			Debug: true,
+		}),
+		optup.ProgressStreams(stdoutBuffer),
+	)
+	stdout = stdoutBuffer.String()
+	assert.Contains(t, stdout, "Plan: 1 to add, 0 to change, 1 to destroy.")
+	assert.Contains(t, stdout, "module.test-localmod.aws_s3_bucket.bucket[0]: Destruction complete")
+
+	changes2 := *upRes3.Summary.ResourceChanges
+	autogold.Expect(map[string]int{"replace": 1, "same": 3, "update": 1}).Equal(t, changes2)
+}
+
+func TestPartialDestroyFromRemovalOnUpdate(t *testing.T) {
+	localProviderBinPath := ensureCompiledProvider(t)
+	skipLocalRunsWithoutCreds(t)
+
+	// Module written to support the test.
+	localMod, err := filepath.Abs(filepath.Join("testdata", "programs", "ts", "partial-destroy", "local_module"))
+	require.NoError(t, err)
+
+	testProgram := filepath.Join("testdata", "programs", "ts", "partial-destroy")
+	localPath := opttest.LocalProviderPath("terraform-module", filepath.Dir(localProviderBinPath))
+	integrationTest := pulumitest.NewPulumiTest(t, testProgram, localPath)
+
+	// Get a prefix for resource names
+	prefix := generateTestResourcePrefix()
+
+	// Set prefix via config
+	integrationTest.SetConfig(t, "prefix", prefix)
+
+	// Generate package
+	pulumiPackageAdd(t, integrationTest, localProviderBinPath, localMod, "localmod")
+	upRes, err := integrationTest.CurrentStack().Up(integrationTest.Context(), optup.Diff(),
+		optup.ErrorProgressStreams(os.Stderr),
+		optup.ProgressStreams(os.Stdout),
+	)
+	assert.NoErrorf(t, err, "unexpected error on up")
+	changes := *upRes.Summary.ResourceChanges
+	created := changes["create"]
+	assert.Equal(t, 5, created)
+
+	bucketName, ok := upRes.Outputs["bucketName"].Value.(string)
+	require.True(t, ok, "expected bucketName string output")
+
+	ctx := context.Background()
+	cfg, err := config.LoadDefaultConfig(ctx)
+	require.NoError(t, err)
+
+	// Create AWS clients
+	s3Client := s3.NewFromConfig(cfg)
+	_, err = s3Client.PutObject(ctx, &s3.PutObjectInput{
+		Bucket: &bucketName,
+		Key:    aws.String("test.txt"),
+		Body:   strings.NewReader("test"),
+	})
+	require.NoError(t, err, "failed to put object in S3 bucket")
+
+	// Set enabled=false to force a removal of the bucket
+	integrationTest.SetConfig(t, "enabled", "false")
+
+	stdoutBuffer := bytes.NewBuffer(nil)
+	_, err = integrationTest.CurrentStack().Up(ctx,
+		optup.DebugLogging(debug.LoggingOptions{
+			Debug: true,
+		}),
+		optup.ProgressStreams(stdoutBuffer),
+	)
+
+	stdout := stdoutBuffer.String()
+	assert.Errorf(t, err, "expected error on up")
+	assert.Contains(t, stdout, "Plan: 0 to add, 0 to change, 2 to destroy.")
+	assert.Contains(t, stdout, "module.test-localmod.aws_s3_bucket.another_bucket[0]: Destruction complete")
+	assert.NotContains(t, stdout, "module.test-localmod.aws_s3_bucket.bucket[0]: Destruction complete")
+	assert.Contains(t, stdout, "Apply failed")
+
+	_, err = s3Client.DeleteObject(ctx, &s3.DeleteObjectInput{
+		Bucket: &bucketName,
+		Key:    aws.String("test.txt"),
+	})
+	require.NoError(t, err, "failed to delete object in S3 bucket")
+
+	stdoutBuffer = bytes.NewBuffer(nil)
+	upRes3 := integrationTest.Up(t,
+		optup.DebugLogging(debug.LoggingOptions{
+			Debug: true,
+		}),
+		optup.ProgressStreams(stdoutBuffer),
+	)
+	stdout = stdoutBuffer.String()
+	assert.Contains(t, stdout, "Plan: 0 to add, 0 to change, 1 to destroy.")
+	assert.Contains(t, stdout, "module.test-localmod.aws_s3_bucket.bucket[0]: Destruction complete")
+
+	changes2 := *upRes3.Summary.ResourceChanges
+	autogold.Expect(map[string]int{"delete": 2, "same": 2, "update": 1}).Equal(t, changes2)
+}
+
 // Sanity check that we can provision two instances of the same module side-by-side, in particular
 // this makes sure that URN selection is unique enough to avoid the "dulicate URN" problem.
 func Test_TwoInstances_TypeScript(t *testing.T) {
@@ -1283,6 +1530,7 @@ func assertTFStateResourceExists(t *testing.T, pt *pulumitest.PulumiTest, packag
 		name, ok := resMap["name"].(string)
 		require.Truef(t, ok, "name key must exist")
 		fullName := fmt.Sprintf("%s.%s.%s", module, typ, name)
+		t.Logf("Checking resource %s against %s", fullName, resourceAddress)
 		return fullName == resourceAddress
 	})
 	require.Truef(t, contains, "TF state must contain resource %s", resourceAddress)
@@ -1306,6 +1554,7 @@ func mustFindDeploymentResourceByType(
 
 	for _, r := range deployment.Resources {
 		if r.Type == resourceType {
+			t.Logf("Found resource %s of type %s", r.URN, resourceType.String())
 			res = r
 			found++
 		}
diff --git a/tests/schema_create_test.go b/tests/schema_create_test.go
index 1bb3e847..2c4ded01 100644
--- a/tests/schema_create_test.go
+++ b/tests/schema_create_test.go
@@ -489,124 +489,107 @@ resource "aws_s3_bucket_lifecycle_configuration" "this" {
 		// TypeMap (optional,computed) will show the entire value as computed if one of the values is computed
 		assertAttributeValuesForAddress(t, "module.local.aws_s3_bucket_lifecycle_configuration.this", "rule", *plan.RawPlan(),
 			func(actual interface{}) {
-				autogold.Expect([]interface{}{
-					map[string]interface{}{
-						"abort_incomplete_multipart_upload": []interface{}{},
-						"expiration":                        []interface{}{},
-						"filter": []interface{}{
-							map[string]interface{}{
-								"and": []interface{}{
-									map[string]interface{}{
-										"object_size_greater_than": json.Number("200"),
-										"tags":                     nil,
-									},
-								},
-								"prefix": "test",
-								"tag":    []interface{}{},
-							},
+				autogold.Expect([]interface{}{map[string]interface{}{
+					"abort_incomplete_multipart_upload": []interface{}{},
+					"expiration":                        []interface{}{},
+					"filter": []interface{}{map[string]interface{}{
+						"and": []interface{}{map[string]interface{}{
+							"object_size_greater_than": json.Number("200"),
+							"object_size_less_than":    json.Number("0"),
+							"prefix":                   "",
+							"tags":                     nil,
+						}},
+						"object_size_greater_than": nil,
+						"object_size_less_than":    nil,
+						"prefix":                   "test",
+						"tag":                      []interface{}{},
+					}},
+					"id":                            "rule_id",
+					"noncurrent_version_expiration": []interface{}{},
+					"noncurrent_version_transition": []interface{}{},
+					"prefix":                        "",
+					"status":                        "Enabled",
+					"transition": []interface{}{
+						map[string]interface{}{
+							"date":          nil,
+							"days":          json.Number("0"),
+							"storage_class": "GLACIER",
 						},
-						"id":                            "rule_id",
-						"noncurrent_version_expiration": []interface{}{},
-						"noncurrent_version_transition": []interface{}{},
-						"status":                        "Enabled",
-						"transition": []interface{}{
-							map[string]interface{}{
-								"date":          nil,
-								"days":          json.Number("30"),
-								"storage_class": "GLACIER",
-							},
-							map[string]interface{}{
-								"date":          nil,
-								"storage_class": "GLACIER",
-							},
+						map[string]interface{}{
+							"date":          nil,
+							"days":          json.Number("30"),
+							"storage_class": "GLACIER",
 						},
 					},
-				}).Equal(t, actual)
+				}}).Equal(t, actual)
 			})
 
 		assertResourceChangeForAddress(t, "module.local.aws_s3_bucket_lifecycle_configuration.this", "rule", *plan.RawPlan(),
 			findUnknownChange,
 			func(actual interface{}) {
-				autogold.Expect([]interface{}{
-					map[string]interface{}{
-						"abort_incomplete_multipart_upload": []interface{}{},
-						"expiration":                        []interface{}{},
-						"filter": []interface{}{
-							map[string]interface{}{
-								"and": []interface{}{
-									map[string]interface{}{
-										"object_size_less_than": true,
-										"prefix":                true,
-									},
-								},
-								"object_size_greater_than": true,
-								"object_size_less_than":    true,
-								"tag":                      []interface{}{},
-							},
-						},
-						"noncurrent_version_expiration": []interface{}{},
-						"noncurrent_version_transition": []interface{}{},
-						"prefix":                        true, // Attribute.StringAttribute (optional,computed)
-						"transition": []interface{}{
-							map[string]interface{}{},
-							map[string]interface{}{
-								"days": true,
-							},
-						},
+				autogold.Expect([]interface{}{map[string]interface{}{
+					"abort_incomplete_multipart_upload": []interface{}{},
+					"expiration":                        []interface{}{},
+					"filter": []interface{}{map[string]interface{}{
+						"and": []interface{}{map[string]interface{}{}},
+						"tag": []interface{}{},
+					}},
+					"noncurrent_version_expiration": []interface{}{},
+					"noncurrent_version_transition": []interface{}{},
+					"transition": []interface{}{
+						map[string]interface{}{},
+						map[string]interface{}{},
 					},
-				}).Equal(t, actual)
+				}}).Equal(t, actual)
 			})
 
 		assertPlanForAddress(t, "module.local.aws_s3_bucket_lifecycle_configuration.this", "rule", plan,
 			func(actual interface{}) {
-				autogold.Expect([]resource.PropertyValue{
-					resource.NewObjectProperty(
-						resource.PropertyMap{
-							"id":                                resource.NewStringProperty("rule_id"),
-							"status":                            resource.NewStringProperty("Enabled"),
-							"prefix":                            resource.MakeComputed(resource.NewStringProperty("")),
-							"abort_incomplete_multipart_upload": resource.NewArrayProperty([]resource.PropertyValue{}),
-							"expiration":                        resource.NewArrayProperty([]resource.PropertyValue{}),
-							"noncurrent_version_expiration":     resource.NewArrayProperty([]resource.PropertyValue{}),
-							"noncurrent_version_transition":     resource.NewArrayProperty([]resource.PropertyValue{}),
-							"transition": resource.NewArrayProperty([]resource.PropertyValue{
-								resource.NewObjectProperty(
-									resource.PropertyMap{
-										"date":          resource.NewNullProperty(),
-										"days":          resource.NewNumberProperty(30),
-										"storage_class": resource.NewStringProperty("GLACIER"),
-									},
-								),
-								resource.NewObjectProperty(
-									resource.PropertyMap{
-										"days":          resource.MakeComputed(resource.NewStringProperty("")),
-										"date":          resource.NewNullProperty(),
-										"storage_class": resource.NewStringProperty("GLACIER"),
-									},
-								),
-							}),
-							"filter": resource.NewArrayProperty([]resource.PropertyValue{
-								resource.NewObjectProperty(
-									resource.PropertyMap{
-										"and": resource.NewArrayProperty([]resource.PropertyValue{
-											resource.NewObjectProperty(
-												resource.PropertyMap{
-													"object_size_greater_than": resource.NewNumberProperty(200),
-													"tags":                     resource.NewNullProperty(),
-													"object_size_less_than":    resource.MakeComputed(resource.NewStringProperty("")),
-													"prefix":                   resource.MakeComputed(resource.NewStringProperty("")),
-												},
-											),
-										}),
-										"prefix":                   resource.NewStringProperty("test"),
-										"tag":                      resource.NewArrayProperty([]resource.PropertyValue{}),
-										"object_size_greater_than": resource.MakeComputed(resource.NewStringProperty("")),
-										"object_size_less_than":    resource.MakeComputed(resource.NewStringProperty("")),
-									},
-								),
-							}),
-						}),
-				}).Equal(t, actual)
+				autogold.Expect([]resource.PropertyValue{{
+					V: resource.PropertyMap{
+						resource.PropertyKey("abort_incomplete_multipart_upload"): resource.PropertyValue{
+							V: []resource.PropertyValue{},
+						},
+						resource.PropertyKey("expiration"): resource.PropertyValue{V: []resource.PropertyValue{}},
+						resource.PropertyKey("filter"): resource.PropertyValue{V: []resource.PropertyValue{{
+							V: resource.PropertyMap{
+								resource.PropertyKey("and"): resource.PropertyValue{
+									V: []resource.PropertyValue{{
+										V: resource.PropertyMap{
+											resource.PropertyKey("object_size_greater_than"): resource.PropertyValue{
+												V: 200,
+											},
+											resource.PropertyKey("object_size_less_than"): resource.PropertyValue{V: 0},
+											resource.PropertyKey("prefix"):                resource.PropertyValue{V: ""},
+											resource.PropertyKey("tags"):                  resource.PropertyValue{},
+										},
+									}},
+								},
+								resource.PropertyKey("object_size_greater_than"): resource.PropertyValue{},
+								resource.PropertyKey("object_size_less_than"):    resource.PropertyValue{},
+								resource.PropertyKey("prefix"):                   resource.PropertyValue{V: "test"},
+								resource.PropertyKey("tag"):                      resource.PropertyValue{V: []resource.PropertyValue{}},
+							},
+						}}},
+						resource.PropertyKey("id"):                            resource.PropertyValue{V: "rule_id"},
+						resource.PropertyKey("noncurrent_version_expiration"): resource.PropertyValue{V: []resource.PropertyValue{}},
+						resource.PropertyKey("noncurrent_version_transition"): resource.PropertyValue{V: []resource.PropertyValue{}},
+						resource.PropertyKey("prefix"):                        resource.PropertyValue{V: ""},
+						resource.PropertyKey("status"):                        resource.PropertyValue{V: "Enabled"},
+						resource.PropertyKey("transition"): resource.PropertyValue{V: []resource.PropertyValue{
+							{V: resource.PropertyMap{
+								resource.PropertyKey("date"):          resource.PropertyValue{},
+								resource.PropertyKey("days"):          resource.PropertyValue{V: 0},
+								resource.PropertyKey("storage_class"): resource.PropertyValue{V: "GLACIER"},
+							}},
+							{V: resource.PropertyMap{
+								resource.PropertyKey("date"):          resource.PropertyValue{},
+								resource.PropertyKey("days"):          resource.PropertyValue{V: 30},
+								resource.PropertyKey("storage_class"): resource.PropertyValue{V: "GLACIER"},
+							}},
+						}},
+					},
+				}}).Equal(t, actual)
 			})
 	})
 
diff --git a/tests/testdata/programs/ts/partial-destroy/Pulumi.yaml b/tests/testdata/programs/ts/partial-destroy/Pulumi.yaml
new file mode 100644
index 00000000..2abd8687
--- /dev/null
+++ b/tests/testdata/programs/ts/partial-destroy/Pulumi.yaml
@@ -0,0 +1,7 @@
+name: partial-destroy
+runtime: nodejs
+description: A minimal TypeScript Pulumi program
+config:
+  pulumi:tags:
+    value:
+      pulumi:template: typescript
diff --git a/tests/testdata/programs/ts/partial-destroy/index.ts b/tests/testdata/programs/ts/partial-destroy/index.ts
new file mode 100644
index 00000000..a6c7ac90
--- /dev/null
+++ b/tests/testdata/programs/ts/partial-destroy/index.ts
@@ -0,0 +1,14 @@
+import * as pulumi from "@pulumi/pulumi";
+import * as localmod from '@pulumi/localmod';
+
+const config = new pulumi.Config();
+const prefix = config.get('prefix') ?? pulumi.getStack();
+const enabled = config.getBoolean('enabled') ?? true;
+
+const mod = new localmod.Module('test-localmod', {
+    name_prefix: prefix,
+    enabled: enabled,
+});
+
+export const bucketName = mod.bucket_name;
+export const bucketArn = mod.bucket_arn;
diff --git a/tests/testdata/programs/ts/partial-destroy/local_module/main.tf b/tests/testdata/programs/ts/partial-destroy/local_module/main.tf
new file mode 100644
index 00000000..573e8cb7
--- /dev/null
+++ b/tests/testdata/programs/ts/partial-destroy/local_module/main.tf
@@ -0,0 +1,10 @@
+resource "aws_s3_bucket" "bucket" {
+  count         = var.enabled ? 1 : 0
+  bucket_prefix = var.name_prefix
+  force_destroy = false
+}
+
+resource "aws_s3_bucket" "another_bucket" {
+  count         = var.enabled ? 1 : 0
+  bucket_prefix = "another-${var.name_prefix}"
+}
diff --git a/tests/testdata/programs/ts/partial-destroy/local_module/outputs.tf b/tests/testdata/programs/ts/partial-destroy/local_module/outputs.tf
new file mode 100644
index 00000000..21fbbe5e
--- /dev/null
+++ b/tests/testdata/programs/ts/partial-destroy/local_module/outputs.tf
@@ -0,0 +1,7 @@
+output "bucket_name" {
+  value = try(aws_s3_bucket.bucket[0].id, "")
+}
+
+output "bucket_arn" {
+  value = try(aws_s3_bucket.bucket[0].arn, "")
+}
diff --git a/tests/testdata/programs/ts/partial-destroy/local_module/variables.tf b/tests/testdata/programs/ts/partial-destroy/local_module/variables.tf
new file mode 100644
index 00000000..acc79519
--- /dev/null
+++ b/tests/testdata/programs/ts/partial-destroy/local_module/variables.tf
@@ -0,0 +1,10 @@
+variable "name_prefix" {
+  type        = string
+  description = "Prefix to use for the name of the S3 bucket"
+}
+
+variable "enabled" {
+  type        = bool
+  description = "Whether to create the S3 bucket"
+  default     = true
+}
diff --git a/tests/testdata/programs/ts/partial-destroy/package.json b/tests/testdata/programs/ts/partial-destroy/package.json
new file mode 100644
index 00000000..e34972bb
--- /dev/null
+++ b/tests/testdata/programs/ts/partial-destroy/package.json
@@ -0,0 +1,10 @@
+{
+	"name": "partial-destroy",
+	"devDependencies": {
+		"@types/node": "^18"
+	},
+	"dependencies": {
+		"@pulumi/pulumi": "^3.0.0",
+		"typescript": "^5.0.0"
+	}
+}


Reviews:
user: login: t0yv0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T14:28:26Z
user: login: t0yv0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T14:32:29Z
user: login: t0yv0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T14:34:59Z
user: login: t0yv0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T14:35:52Z
user: login: corymhall
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T14:39:03Z
user: login: t0yv0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T14:41:12Z
user: login: t0yv0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T14:41:43Z
user: login: t0yv0
body: Thank you! Very important to have this additional robustness here. 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T14:42:48Z
user: login: t0yv0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T14:43:27Z
user: login: t0yv0
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T18:17:58Z
user: login: t0yv0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T20:37:28Z
user: login: t0yv0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T20:38:43Z
user: login: t0yv0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T20:42:16Z
user: login: t0yv0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T20:42:50Z
user: login: t0yv0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T20:51:27Z
user: login: t0yv0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T20:52:33Z
user: login: t0yv0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T21:11:51Z
user: login: t0yv0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T21:44:13Z
user: login: t0yv0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T22:08:03Z
user: login: corymhall
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T12:08:52Z
user: login: corymhall
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T12:10:23Z
user: login: corymhall
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T12:14:08Z
user: login: corymhall
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T12:21:58Z
user: login: t0yv0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T17:49:04Z
user: login: t0yv0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T17:49:58Z

Review comments:
diff_hunk: @@ -2,17 +2,95 @@ package tfsandbox
 
 import (
 	"context"
-	"fmt"
+
+	tfjson "github.com/hashicorp/terraform-json"
 )
 
 // Destroy runs the terraform destroy command
-func (t *Tofu) Destroy(ctx context.Context, log Logger) error {
+// This handles a couple of special cases around destroy.
+// The returned State and Error can both be not nil. This would be the case
+// if the destroy failed and some resources were destroyed and some were not.
+//
+// There are two special cases that callers should be aware of:
path: pkg/tfsandbox/destroy.go
user: login: t0yv0
body: 🙇 
created_at: 2025-04-07T14:28:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2,17 +2,95 @@ package tfsandbox
 
 import (
 	"context"
-	"fmt"
+
+	tfjson "github.com/hashicorp/terraform-json"
 )
 
 // Destroy runs the terraform destroy command
-func (t *Tofu) Destroy(ctx context.Context, log Logger) error {
+// This handles a couple of special cases around destroy.
+// The returned State and Error can both be not nil. This would be the case
+// if the destroy failed and some resources were destroyed and some were not.
+//
+// There are two special cases that callers should be aware of:
+//  1. If the destroy was successful, we return a non-nil state with no resources
+//  2. If the destroy failed, we either return the state with the resources that failed to destroy
+//     or a nil state if something went really wrong and there is not state left
+func (t *Tofu) Destroy(ctx context.Context, log Logger) (*State, error) {
+	rawState, success, destroyErr := t.destroy(ctx, log)
+	if success {
+		s, err := newDestroyState(rawState)
+		if err != nil {
+			return nil, err
+		}
+		return s, nil
+	}
+	if rawState != nil {
+		s, err := newDestroyState(rawState)
+		if err != nil {
+			return nil, err
+		}
+		return s, destroyErr
+	}
+	return nil, destroyErr
+}
+
+// Destroy runs the terraform destroy command
+// This handles a couple of special cases around destroy.
+//  1. If the destroy was successful, we return a nil state, success = true, nil error
path: pkg/tfsandbox/destroy.go
user: login: t0yv0
body: This seems to assume that successful `tofu destroy` always produces a state equivalent to a nil state, yes? That may be actually true. If we were to not assume it we could call `Show` anyway and check if the state is empty. Probably OK to assume it though, given what we know. 
created_at: 2025-04-07T14:32:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -297,9 +302,6 @@ func (h *childHandler) Read(
 		// Refresh has removed the resource to reflect that it can no longer be found.
 		return &pulumirpc.ReadResponse{Id: ""}, nil
 	}
-	if err != nil {
path: pkg/modprovider/child.go
user: login: t0yv0
body: Unrelated to the PR but nice dead code cleanup thanks!
created_at: 2025-04-07T14:34:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -101,9 +102,20 @@ type stateEntry struct {
 	state     State
 }
 
+func isNil(i any) bool {
+	if i == nil {
+		return true
+	}
+	val := reflect.ValueOf(i)
path: pkg/modprovider/planstore.go
user: login: t0yv0
body: This is the special case for interface nil values? Oof, Go!
created_at: 2025-04-07T14:35:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -101,9 +102,20 @@ type stateEntry struct {
 	state     State
 }
 
+func isNil(i any) bool {
+	if i == nil {
+		return true
+	}
+	val := reflect.ValueOf(i)
path: pkg/modprovider/planstore.go
user: login: corymhall
body: Yes, Oof is right! Is there a better way to handle this in Go? The only answer I could think of was just don't initialize an interface with nil values, but even then you should always check to make sure, right?
created_at: 2025-04-07T14:39:03Z
author_association: MEMBER
diff_hunk: @@ -101,9 +102,20 @@ type stateEntry struct {
 	state     State
 }
 
+func isNil(i any) bool {
+	if i == nil {
+		return true
+	}
+	val := reflect.ValueOf(i)
+	return val.Kind() == reflect.Ptr && val.IsNil()
+}
+
 func (e *stateEntry) Await() State {
 	if waitTimeout == nil {
 		e.waitGroup.Wait()
+		if isNil(e.state) {
path: pkg/modprovider/planstore.go
user: login: t0yv0
body: Nit: so this seems to be converting "weird nils" to "regular nils" - presumably some callers are trying to discriminate nil State values from non-nil state values, and they are using a nil check for this, is that right? Feels like a little bit of a code smell - if we need this perhaps adding some semantic method on State like this would be cleaner:

```
if state.exists() {
}
```

These are just my code style biases, "avoid attaching semantic to nil", but I'm still calibrating on what is idiomatic Go.
created_at: 2025-04-07T14:41:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -156,6 +171,24 @@ func (e unknownAddressError) Error() string {
 	return fmt.Sprintf("unknown address: %q", e.addr)
 }
 
+// IsResourceDeleted returns true if the resource is deleted (not in the state).
+//
+// If the state is nil, it means something went wrong with the state after
+// the tofu destroy operation. In that case, the ModuleState resource will
+// not be deleted and we should keep all the child resources as well so we can
+// try again (essentially treat the operation as a no-op).
+func (s *planStore) IsResourceDeleted(
+	modUrn urn.URN,
+	addr ResourceAddress,
+) bool {
+	modState := s.getOrCreateStateEntry(modUrn).Await()
+	if modState == nil {
path: pkg/modprovider/planstore.go
user: login: t0yv0
body: This is presumably one of these semantic nil checks discussed above. 
created_at: 2025-04-07T14:41:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -101,9 +102,20 @@ type stateEntry struct {
 	state     State
 }
 
+func isNil(i any) bool {
+	if i == nil {
+		return true
+	}
+	val := reflect.ValueOf(i)
path: pkg/modprovider/planstore.go
user: login: t0yv0
body: Do not use nil values AFAIK is the way.. 
created_at: 2025-04-07T14:43:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,56 @@
+# Resource Deletion
+
+Resource deletion can vary depending on under which scenario deletion is
+occurring. At a high level a resource can be deleted:
+
+- `RegisterResource` it not called for an existing resource.
+- `pulumi dn` is called to destroy the resource
+- During `pulumi up` `Diff` is called and reports a resource replacement. The
+  replacement can either have:
+  - DeleteBeforeReplace=true
+  - DeleteBeforeReplace=false
+
+## RegisterResource not called for existing resource
+
+In this case there is a resource that was previously created by the TF Module
+that is now not being created. As part of the `ModuleComponent` the `TF Apply`
+will be run and the updated state will be returned which will not contain the
+removed resource. The ModuleComponent will then not emit a `RegisterResource`
+call which will cause the Pulumi engine to call `Delete` on the child resource.
+
+- The `Delete` call will happen in a different provider process so it will not
path: docs/internals/delete.md
user: login: t0yv0
body: This really is so surprising. I wonder if this is worth tracking as a Pulumi bug.
created_at: 2025-04-09T20:37:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,56 @@
+# Resource Deletion
+
+Resource deletion can vary depending on under which scenario deletion is
+occurring. At a high level a resource can be deleted:
+
+- `RegisterResource` it not called for an existing resource.
+- `pulumi dn` is called to destroy the resource
+- During `pulumi up` `Diff` is called and reports a resource replacement. The
+  replacement can either have:
+  - DeleteBeforeReplace=true
+  - DeleteBeforeReplace=false
+
+## RegisterResource not called for existing resource
+
+In this case there is a resource that was previously created by the TF Module
+that is now not being created. As part of the `ModuleComponent` the `TF Apply`
+will be run and the updated state will be returned which will not contain the
+removed resource. The ModuleComponent will then not emit a `RegisterResource`
+call which will cause the Pulumi engine to call `Delete` on the child resource.
+
+- The `Delete` call will happen in a different provider process so it will not
+  have access to any of the shared state.
+- If the `TF Apply` fails for any reason, then the child resource `Delete` will
+  never be called. The resource will be deleted from TF, but not from Pulumi.
+
+The `TestPartialDestroyFromRemovalOnUpdate` test illustrates this case.
+
+## Pulumi destroy is called
+
+In this case `Delete` is called for both the `ModuleState` resource and the
+child resources and all the `Delete` calls share the same provider process which
+means they can share provider state.
+
+The `TestPartialDestroy` test illustrates this case.
+
+## Replacement with DeleteBeforeReplace=false
+
+In this case the `TF Plan` returns a plan which includes some resources that are
+being replaced. `DeleteBeforeReplace=false` is the default from TF. The child
+resource `Diff` method is called which returns a replacement diff. The Pulumi
+engine then calls `Create` and then `Delete` on the child resource. Both these
+calls share the same provider process that is already running, so they are able
+to access the provider state.
+
+The `TestPartialDestroyOnUpdate` test illustrates this case.
+
+## Replacement with DeleteBeforeReplace=true
+
+In this case the `TF Plan` returns a plan which includes some resources that are
+being replaced. `DeleteBeforeReplace=true` is not the default from TF so this
+might not occur very often. The child resource `Diff` method is called which
+returns a replacement diff. The Pulumi engine then calls `Delete` and then
+`Replace` on the child resource. The `Delete` call occurs in a separate provider
path: docs/internals/delete.md
user: login: t0yv0
body: This is even more bizzare. The Delete occurs on a different process than Create in this case? 
created_at: 2025-04-09T20:38:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -31,9 +31,41 @@ import (
 //
 // The provider will have a single planStore instance that stores the information indexed by URNs.
 type planStore struct {
-	mutex  sync.Mutex
-	plans  map[urn.URN]*planEntry
-	states map[urn.URN]*stateEntry
+	mutex     sync.Mutex
+	plans     map[urn.URN]*planEntry
+	states    map[urn.URN]*stateEntry
+	operation Operation
path: pkg/modprovider/planstore.go
user: login: t0yv0
body: There will be only one of these per process. That's by design right? Just confirming.
created_at: 2025-04-09T20:42:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -31,9 +31,41 @@ import (
 //
 // The provider will have a single planStore instance that stores the information indexed by URNs.
 type planStore struct {
-	mutex  sync.Mutex
-	plans  map[urn.URN]*planEntry
-	states map[urn.URN]*stateEntry
+	mutex     sync.Mutex
+	plans     map[urn.URN]*planEntry
+	states    map[urn.URN]*stateEntry
+	operation Operation
+}
+
+func (s *planStore) SetOperation(op Operation) {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+	s.operation = op
+}
+
+func (s *planStore) GetOperation() Operation {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+	return s.operation
+}
+
+type Operation string
+
+const (
+	OperationDelete Operation = "DELETE"
+)
+
+// getPlanEntry returns the plan entry for the given URN.
+// If the plan entry does not exist, it returns nil and callers
+// are responsible for handling
+// NOTE: you probably want to use `getOrCreatePlanEntry` instead of this method.
path: pkg/modprovider/planstore.go
user: login: t0yv0
body: Consider returning (*planEntry, bool) with an OK flag to force caller to handle (nit).
created_at: 2025-04-09T20:42:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -156,6 +188,70 @@ func (e unknownAddressError) Error() string {
 	return fmt.Sprintf("unknown address: %q", e.addr)
 }
 
+// IsResourceDeleted returns true if the resource is deleted (not in the state).
+//
+// There are a couple different delete cases that this function handles:
+//  1. This is a `pulumi dn` operation and the entire stack is being deleted
+//     In this case there will be no `plan` because the `moduleStateHandler.Delete` method does not run plan.
+//  2. This is a `pulumi up` operation and this resource is being deleted or replaced.
+//     - 2a.If DeleteBeforeReplace=false (TF default) then `Delete` is being run in the same provider process
+//     and we have access to shared info (i.e. plan/state).
+//     We can tell this case apart from 1 because there will be a plan (`up` runs Construct which runs Plan).
+//     - 2b. If DeleteBeforeReplace=true then `Delete` is being run in a different provider process
+//     and we do not have access to shared info (i.e. plan/state). We don't have any info to tell
+//     if the delete succeeded or not for this resource
+//     - 2c. If this resources is being deleted and not replaced (i.e. resource is removed) then the `Delete`
+//     will happen in a separate provider process and we will not have access to shared info (state/plan)
+//
+// NOTE: This should only be called from within the `Delete` method of the child resource
+//
+// TODO[pulumi/pulumi-terraform-module#265] determine if the delete for this
path: pkg/modprovider/planstore.go
user: login: t0yv0
body: Nit: perhaps this logic is not really part of the planStore.. separate file? I can't find a problem with it per se but it's really complicated. We have design problems..
created_at: 2025-04-09T20:51:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -250,6 +250,7 @@ func (h *moduleStateHandler) Delete(
 	moduleVersion TFModuleVersion,
 	providersConfig map[string]resource.PropertyMap,
 ) (*emptypb.Empty, error) {
+	h.planStore.SetOperation(OperationDelete)
path: pkg/modprovider/state.go
user: login: t0yv0
body: This concept is a bit flaky I wonder what happens if the operation is `pulumi up` but the module instance itself is not being registered any more. This will initiate a Delete on ModuleStateHandler. 
created_at: 2025-04-09T20:52:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,10 @@
+resource "aws_s3_bucket" "bucket" {
+  count         = var.enabled ? 1 : 0
path: tests/testdata/programs/ts/partial-destroy/local_module/main.tf
user: login: t0yv0
body: Oh neat so it can remove it.
created_at: 2025-04-09T21:11:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,56 @@
+# Resource Deletion
+
+Resource deletion can vary depending on under which scenario deletion is
+occurring. At a high level a resource can be deleted:
+
+- `RegisterResource` it not called for an existing resource.
+- `pulumi dn` is called to destroy the resource
+- During `pulumi up` `Diff` is called and reports a resource replacement. The
+  replacement can either have:
+  - DeleteBeforeReplace=true
+  - DeleteBeforeReplace=false
+
+## RegisterResource not called for existing resource
+
+In this case there is a resource that was previously created by the TF Module
+that is now not being created. As part of the `ModuleComponent` the `TF Apply`
+will be run and the updated state will be returned which will not contain the
+removed resource. The ModuleComponent will then not emit a `RegisterResource`
+call which will cause the Pulumi engine to call `Delete` on the child resource.
+
+- The `Delete` call will happen in a different provider process so it will not
+  have access to any of the shared state.
+- If the `TF Apply` fails for any reason, then the child resource `Delete` will
+  never be called. The resource will be deleted from TF, but not from Pulumi.
+
+The `TestPartialDestroyFromRemovalOnUpdate` test illustrates this case.
path: docs/internals/delete.md
user: login: t0yv0
body: Could you show me how to reproduce this. I tried extracting gRPC log entries in this test section that does the update, and it appears to me that only one sequence of Handshake/CheckConfig etc is involved. It appears to me only one plugin is loaded. And I see only one instance at a time in pgrep. Could the diagnosis here be mistaken? 

created_at: 2025-04-09T21:44:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,56 @@
+# Resource Deletion
+
+Resource deletion can vary depending on under which scenario deletion is
+occurring. At a high level a resource can be deleted:
+
+- `RegisterResource` it not called for an existing resource.
+- `pulumi dn` is called to destroy the resource
+- During `pulumi up` `Diff` is called and reports a resource replacement. The
+  replacement can either have:
+  - DeleteBeforeReplace=true
+  - DeleteBeforeReplace=false
+
+## RegisterResource not called for existing resource
+
+In this case there is a resource that was previously created by the TF Module
+that is now not being created. As part of the `ModuleComponent` the `TF Apply`
+will be run and the updated state will be returned which will not contain the
+removed resource. The ModuleComponent will then not emit a `RegisterResource`
+call which will cause the Pulumi engine to call `Delete` on the child resource.
+
+- The `Delete` call will happen in a different provider process so it will not
+  have access to any of the shared state.
+- If the `TF Apply` fails for any reason, then the child resource `Delete` will
+  never be called. The resource will be deleted from TF, but not from Pulumi.
+
+The `TestPartialDestroyFromRemovalOnUpdate` test illustrates this case.
path: docs/internals/delete.md
user: login: t0yv0
body: ```
[INFO] childHandler.Delete(addr="module.test-localmod.aws_s3_bucket.another_bucket[0]", pid=14537)
[INFO] moduleStateHandler.Update(pid=14537, preview=false)
[INFO] childHandler.Delete(addr="module.test-localmod.aws_s3_bucket.bucket[0]", pid=14537)
```

Yes I am pretty sure this is happening within the same PID. The issue might be ordering though. Without getting the awaits work correctly the Delete may be running ahead of another operation. 
created_at: 2025-04-09T22:08:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,56 @@
+# Resource Deletion
+
+Resource deletion can vary depending on under which scenario deletion is
+occurring. At a high level a resource can be deleted:
+
+- `RegisterResource` it not called for an existing resource.
+- `pulumi dn` is called to destroy the resource
+- During `pulumi up` `Diff` is called and reports a resource replacement. The
+  replacement can either have:
+  - DeleteBeforeReplace=true
+  - DeleteBeforeReplace=false
+
+## RegisterResource not called for existing resource
+
+In this case there is a resource that was previously created by the TF Module
+that is now not being created. As part of the `ModuleComponent` the `TF Apply`
+will be run and the updated state will be returned which will not contain the
+removed resource. The ModuleComponent will then not emit a `RegisterResource`
+call which will cause the Pulumi engine to call `Delete` on the child resource.
+
+- The `Delete` call will happen in a different provider process so it will not
+  have access to any of the shared state.
+- If the `TF Apply` fails for any reason, then the child resource `Delete` will
+  never be called. The resource will be deleted from TF, but not from Pulumi.
+
+The `TestPartialDestroyFromRemovalOnUpdate` test illustrates this case.
+
+## Pulumi destroy is called
+
+In this case `Delete` is called for both the `ModuleState` resource and the
+child resources and all the `Delete` calls share the same provider process which
+means they can share provider state.
+
+The `TestPartialDestroy` test illustrates this case.
+
+## Replacement with DeleteBeforeReplace=false
+
+In this case the `TF Plan` returns a plan which includes some resources that are
+being replaced. `DeleteBeforeReplace=false` is the default from TF. The child
+resource `Diff` method is called which returns a replacement diff. The Pulumi
+engine then calls `Create` and then `Delete` on the child resource. Both these
+calls share the same provider process that is already running, so they are able
+to access the provider state.
+
+The `TestPartialDestroyOnUpdate` test illustrates this case.
+
+## Replacement with DeleteBeforeReplace=true
+
+In this case the `TF Plan` returns a plan which includes some resources that are
+being replaced. `DeleteBeforeReplace=true` is not the default from TF so this
+might not occur very often. The child resource `Diff` method is called which
+returns a replacement diff. The Pulumi engine then calls `Delete` and then
+`Replace` on the child resource. The `Delete` call occurs in a separate provider
path: docs/internals/delete.md
user: login: corymhall
body: That I don't know. It does occur in a different process than the ModuleComponent `Construct`
created_at: 2025-04-10T12:08:52Z
author_association: MEMBER
diff_hunk: @@ -31,9 +31,41 @@ import (
 //
 // The provider will have a single planStore instance that stores the information indexed by URNs.
 type planStore struct {
-	mutex  sync.Mutex
-	plans  map[urn.URN]*planEntry
-	states map[urn.URN]*stateEntry
+	mutex     sync.Mutex
+	plans     map[urn.URN]*planEntry
+	states    map[urn.URN]*stateEntry
+	operation Operation
path: pkg/modprovider/planstore.go
user: login: corymhall
body: Yes that's right. Right now it's used to track whether this process was triggered from a `moduleStateHandler.Delete` or by `Construct`.
created_at: 2025-04-10T12:10:23Z
author_association: MEMBER
diff_hunk: @@ -250,6 +250,7 @@ func (h *moduleStateHandler) Delete(
 	moduleVersion TFModuleVersion,
 	providersConfig map[string]resource.PropertyMap,
 ) (*emptypb.Empty, error) {
+	h.planStore.SetOperation(OperationDelete)
path: pkg/modprovider/state.go
user: login: corymhall
body: I can add some more documentation to this. What this is trying to do is indicate where the `Tofu` operation is being run. Is `Tofu` being run from `moduleStateHandler.Delete` or `Construct`?

Although this brings up a good case where I don't think we've tested. I'll add some testing to this to see what happens in this type of Delete where you've removed the entire ModuleComponent.
created_at: 2025-04-10T12:14:08Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,56 @@
+# Resource Deletion
+
+Resource deletion can vary depending on under which scenario deletion is
+occurring. At a high level a resource can be deleted:
+
+- `RegisterResource` it not called for an existing resource.
+- `pulumi dn` is called to destroy the resource
+- During `pulumi up` `Diff` is called and reports a resource replacement. The
+  replacement can either have:
+  - DeleteBeforeReplace=true
+  - DeleteBeforeReplace=false
+
+## RegisterResource not called for existing resource
+
+In this case there is a resource that was previously created by the TF Module
+that is now not being created. As part of the `ModuleComponent` the `TF Apply`
+will be run and the updated state will be returned which will not contain the
+removed resource. The ModuleComponent will then not emit a `RegisterResource`
+call which will cause the Pulumi engine to call `Delete` on the child resource.
+
+- The `Delete` call will happen in a different provider process so it will not
+  have access to any of the shared state.
+- If the `TF Apply` fails for any reason, then the child resource `Delete` will
+  never be called. The resource will be deleted from TF, but not from Pulumi.
+
+The `TestPartialDestroyFromRemovalOnUpdate` test illustrates this case.
path: docs/internals/delete.md
user: login: corymhall
body: I think to reproduce this you have to update the `IsResourceDeleted` code. The way that I saw this issue occurring is that I would run each test individually and see which ones timed out do to `Await()` never finishing.

If you simplify `IsResourceDeleted` to this:

```go
func (s *planStore) IsResourceDeleted(
	modUrn urn.URN,
	addr ResourceAddress,
) bool {
	modState := s.getOrCreateStateEntry(modUrn).Await()
	if !modState.IsValidState() {
		return false

	}
	_, ok := modState.FindResourceStateOrPlan(addr)
	return !ok
}
```

And run the test with `PULUMI_TERRAFORM_MODULE_WAIT_TIMEOUT`. It could be that I was misinterpreting why the `Await()` was never finishing though.

```console
PULUMI_TERRAFORM_MODULE_WAIT_TIMEOUT="1m" go test -v -count=1 -run TestPartialDestroyFromRemovalOnUpdate$
```
created_at: 2025-04-10T12:21:58Z
author_association: MEMBER
diff_hunk: @@ -156,6 +157,56 @@ func (e unknownAddressError) Error() string {
 	return fmt.Sprintf("unknown address: %q", e.addr)
 }
 
+// IsResourceDeleted returns true if the resource is deleted (not in the state).
+//
+// There are a couple different delete cases that this function handles:
+//  1. This is a `pulumi dn` operation and the entire stack is being deleted
+//     In this case there will be no `plan` because the `moduleStateHandler.Delete` method does not run plan.
+//  2. This is a `pulumi up` operation and this resource is being deleted or replaced.
+//     2a. If the resource is deleted (removed) then it won't be in the plan
+//     and we can check the state to know if it was successful
+//     2b. If the resource is replaced then the resource will remain in the state
+//     for both the success and failure case. We can't know for sure
+//
+// NOTE: This should only be called from within the `Delete` method of the child resource
+//
+// TODO[pulumi/pulumi-terraform-module#265] determine if the delete for this
+// specific resource succeeded
+func (s *planStore) IsResourceDeleted(
+	modUrn urn.URN,
+	addr ResourceAddress,
+) bool {
+	modState := s.getOrCreateStateEntry(modUrn).Await()
+	if planEntry, ok := s.getPlanEntry(modUrn); ok {
path: pkg/modprovider/planstore.go
user: login: t0yv0
body: This is still the minimal logic necessary to pass the tests right? That is it has to look at plan entries right?
created_at: 2025-04-11T17:49:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -126,18 +139,6 @@ func (e *stateEntry) Set(state State) {
 	e.waitGroup.Done()
 }
 
-// Avoid memory leaks and clean the state when done.
path: pkg/modprovider/planstore.go
user: login: t0yv0
body: The mem optimization was not worth time spend debugging races.
created_at: 2025-04-11T17:49:58Z
author_association: CONTRIBUTOR

PR comments:
user: login: t0yv0
created_at: 2025-04-07T14:22:26Z
body: > If a tofu destroy fails with some resources being deleted then we will
fail our deletion operation. This means that the ModuleState resource
will still have the prior state that existed before the destroy
operation. This is fine because tofu runs refresh and will present a
new plan based on the latest view of the world.

Does this behavior match opentofu behavior? That is, if opentofu performs a destroy operation with partial failure, does it write updates to the statefile or it fails to do so? I think that if we are diverging from opentofu behavior here that might be benign but is not ideal, perhaps a backlog item is warranted.
author_association: CONTRIBUTOR
user: login: t0yv0
created_at: 2025-04-07T14:26:22Z
body: > I've also added logic for the off chance that something goes wrong and
there is no state file at all after the deletion. In that case we fail
the delete across the board (module component, module state, and child
resources) so that the Pulumi state remains the same as it was prior to
the delete operation.

This one is an interesting feature, something like a "state rollback" for failed operations? My instinct here is that since I cannot figure out the "something goes wrong" scenario we could start off with undefined behavior in this case or `contract.Assertf` hard failure; that is, given my level of understanding it appears this should never happen, but that may well be wrong. So I am not certain we need the rollback code but we can probably keep the rollback code unless it causes any trouble. 
author_association: CONTRIBUTOR
user: login: corymhall
created_at: 2025-04-07T14:33:15Z
body: > Does this behavior match opentofu behavior? That is, if opentofu performs a destroy operation with partial failure, does it write updates to the statefile or it fails to do so? I think that if we are diverging from opentofu behavior here that might be benign but is not ideal, perhaps a backlog item is warranted.

It does not match opentofu behavior. Opentofu will write the final state to the statefile. Our problem is that our source of truth for the opentofu state is the ModuleState resource and Pulumi can't update the state of a resource during a `Delete` operation. Ideally this is what we _would_ do

1. Delete called for `ModuleState` resource
2. Delete called for each child resource
3. All child resource deletes complete, some fail
4. `ModuleState` resource reads the final tofu state, fails the delete, and saves it's state to Pulumi
5. User calls `Delete` again and `ModuleState` contains the latest state.


author_association: MEMBER
user: login: corymhall
created_at: 2025-04-07T14:36:25Z
body: > This one is an interesting feature, something like a "state rollback" for failed operations? My instinct here is that since I cannot figure out the "something goes wrong" scenario we could start off with undefined behavior in this case or contract.Assertf hard failure; that is, given my level of understanding it appears this should never happen, but that may well be wrong. So I am not certain we need the rollback code but we can probably keep the rollback code unless it causes any trouble.

Yes we are essentially doing a `contract.Assertf` hard failure. The "state rollback` is really just a side effect of this hard failure. When the deletion fails Pulumi still has the old state on all the resources, which includes the old Terraform state pre-delete. So next time we try and delete Tofu will try the entire delete again, even for resources that were already deleted (which should be a no-op)
author_association: MEMBER
user: login: t0yv0
created_at: 2025-04-07T14:46:08Z
body: > Our problem is that our source of truth for the opentofu state is the ModuleState resource and Pulumi can't update the state of a resource during a Delete operation.

Neat observation. Yes so Pulumi as of today cannot do this but we can add this to the reqs list. Could you add a backlog ticket? Much appreciated.



author_association: CONTRIBUTOR
user: login: t0yv0
created_at: 2025-04-09T20:54:41Z
body: This is great but going to hold it for a week to see if we get any better architecture options before proceeding with merging. That is a lot of complexity to support for trying to get deletes right. 
author_association: CONTRIBUTOR
user: login: t0yv0
created_at: 2025-04-09T20:55:39Z
body: Alternatively if you think we should err on the side of merging + possibly reverting later as needed, can do that.
author_association: CONTRIBUTOR
user: login: t0yv0
created_at: 2025-04-09T21:09:35Z
body: Couple of options here. Thinking out loud. 

There is a special built-in invoke that Justin pointed me to before for something unrelated.

`pulumi:pulumi:getResource`

This allows to lookup the state of another resource.

If we know the URN of the moduleState resource, perhaps from the inputs, when handling Delete() we can look up the ModuleState resource record to get the actual TF state.  This will work cross-process as well. There are two issues here:

1. can we tell from ModuleState.state that delete actually failed, possibly yes.

2. will the invoke be timed right, that is will it get the TF state operation after or before the ModuleState update happens? If c depends on ms, then c.Delete() happens before ms.Delete() which is not right; if ms depends on c then the ordering is better but I'm afraid we can't tie the knot in such a way that ms depends on c, at construction time ms is provisioned before all the children c. So we'd need some IPC wait handle to block until such time as we believe the ms operations finished. Seems error prone.

The second consideration around replaces, currently users cannot tell the difference in UI from Create-Delete or Delete-Create so we could cheat and only emit one of them if it suits us better, for now.


author_association: CONTRIBUTOR
user: login: corymhall
created_at: 2025-04-10T12:25:36Z
body: > This is great but going to hold it for a week to see if we get any better architecture options before proceeding with merging. That is a lot of complexity to support for trying to get deletes right.

Yeah that is fine. I think handling partial deletes are a lot less important than handling partial applies. 

Alternatively I could create a PR to only handle partial deletes in the `moduleStateHandler.Delete` case and leave the delete as part of update cases to later.
author_association: MEMBER
user: login: t0yv0
created_at: 2025-04-11T17:50:30Z
body: I think we should ship this now. It got a bit clearer.
author_association: CONTRIBUTOR
user: login: pulumi-bot
created_at: 2025-04-15T18:36:26Z
body: This PR has been shipped in [release v0.1.0](https://github.com/pulumi/pulumi-terraform-module/releases/tag/v0.1.0).
author_association: CONTRIBUTOR