Repository: kubernetes-sigs/external-dns

Issue:
First creation of record only generates 1 A record, second creation generates 1 A record + 1 TXT record

**What happened**:
After upgrading to v0.19.0, when a new record is created, it only creates 1 A record. This is not the expected behavior as it should be creating 1 A record and 1 TXT record so it can manage that new record.
After manually deleting the A record, then it creates the expected result.
```
# Creates new record (1 record A)
time="2025-10-07T09:56:54Z" level=info msg="Applying provider record filter for domains: [xxxxx.xxx. .xxxxx.xxx.]"
time="2025-10-07T09:56:54Z" level=info msg="Desired change: CREATE xxxxxxx.xxxxx.xxx A" profile=default zoneID=/hostedzone/XXXXXXXXXXXXX zoneName=xxxxx.xxx.
time="2025-10-07T09:56:54Z" level=info msg="1 record(s) were successfully updated" profile=default zoneID=/hostedzone/XXXXXXXXXXXXX zoneName=xxxxx.xxx.

# Manually deleted the first created record A (I waited to see if it created the TXT record afterwards)
time="2025-10-07T09:58:55Z" level=info msg="Applying provider record filter for domains: [xxxxx.xxx. .xxxxx.xxx.]"
time="2025-10-07T09:58:55Z" level=info msg="All records are already up to date"

# Now it creates 1 TXT record and 1 A record
time="2025-10-07T09:59:56Z" level=info msg="Applying provider record filter for domains: [xxxxx.xxx. .xxxxx.xxx.]"
time="2025-10-07T09:59:56Z" level=info msg="Desired change: CREATE cname-xxxxxxx.xxxxx.xxx TXT" profile=default zoneID=/hostedzone/XXXXXXXXXXXXX zoneName=xxxxx.xxx.
time="2025-10-07T09:59:56Z" level=info msg="Desired change: CREATE xxxxxxx.xxxxx.xxx A" profile=default zoneID=/hostedzone/XXXXXXXXXXXXX zoneName=xxxxx.xxx.
time="2025-10-07T09:59:56Z" level=info msg="2 record(s) were successfully updated" profile=default zoneID=/hostedzone/XXXXXXXXXXXXX zoneName=xxxxx.xxx.
```

**What you expected to happen**: 
The first time, 1 A record and 1 TXT record should have been created.

**How to reproduce it (as minimally and precisely as possible)**: 
Allow external-dns to detect a new record (AWS, deployed in EKS). It should create a single A record. Delete that record. Then it should create 1 A record and 1 TXT record.
<!--
Please provide as much detail as possible, including Kubernetes manifests with spec.status, ExternalDNS arguments, and logs. A bug that cannot be reproduced won't be fixed.
-->

**Anything else we need to know?**:

**Environment**:

- External-DNS version (use `external-dns --version`): v0.19.0
- DNS provider: AWS 
- Others:
```
        - --source=service
        - --source=ingress
        - --domain-filter=xxxxx.xxx
        - --provider=aws
        - --policy=sync 
        - --aws-zone-type=public
        - --registry=txt
        - --txt-owner-id=XXXXXXXXXXXX
        - --exclude-record-types=AAAA
```


Discussion:
@u-kai: @jluriot 

Thanks for reporting this!
Could you please help clarify a few points? 🙇 

- Does this behavior always reproduce, or did it happen only once?
- In a previous similar case, the issue occurred when the A record was an apex record, but from your example, it doesn’t look like that’s the case — is that correct?
- Just to confirm my understanding: when only the A record was created, you manually deleted it, and on the next interval, both the A and TXT records were then created — is that right? (e.g., api.example.com A record is created, then deleted manually, and afterward api.example.com + cname-api.example.com are created)
- Since v0.19.0, ExternalDNS skips creating a TXT record if one already exists for the same target record. I assume this is unlikely, but could it be that a TXT record for that A record already existed before the A record was created?@jluriot: Hi @u-kai, thanks you for answering. Here are the answers to your questions:

* Does this behavior always reproduce, or did it happen only once?
* Since v0.19.0, ExternalDNS skips creating a TXT record if one already exists for the same target record. I assume this is unlikely, but could it be that a TXT record for that A record already existed before the A record was created?

While investigating it from my side, and also related to your last question, I will clarify the specific case where it happens, because at first I thought it was happening everytime. Probably the issue is caused by this, I will explain:

I had two clusters using the same TXT owner ID for both, using the upsert-only mode. I wanted to separate them. 

So I did different things in each of my clusters. In the first one, I deleted all the records (A + TXT) and then updated the external-dns with the new TXT owner ID. The issue happened.

In the second one, I manually updated all TXT records with the new TXT owner ID. For testing purposes I deleted one of those records (A + TXT). The same issue happened.

I reported the issue that way because after these problems, I created another test record (which I think was new) and the same issue happened. But it might have happened that this test record was created in the past and that TXT record already existed - or it was cached somehow, I don't know how it really works.

After these cases, could it be that somehow the external-dns had these TXT record "cached" and that was the reason why they were not being created? All the records were definitely deleted in both cases. In the first case, they were deleted with plenty of time, in case syncing could be an issue.

* In a previous similar case, the issue occurred when the A record was an apex record, but from your example, it doesn’t look like that’s the case — is that correct?

That's correct. this is not the case.
* Just to confirm my understanding: when only the A record was created, you manually deleted it, and on the next interval, both the A and TXT records were then created — is that right? (e.g., api.example.com A record is created, then deleted manually, and afterward api.example.com + cname-api.example.com are created)

That's right.

@chrw: I ran into the same issue with the following config. We're using `upsert-only` instead of `sync` for the `policy`.

```
        - --log-level=info
        - --log-format=json
        - --interval=5m
        - --events
        - --source=service
        - --source=ingress
        - --policy=upsert-only
        - --registry=txt
        - --provider=aws
        - --aws-zone-type=public
        - --aws-zones-cache-duration=1h
        - --aws-api-retries=3
        - --aws-batch-change-size=1000
```

After deleting the A and AAAA records, all 4 records (including the aaaa- and cname- TXT record) were written.

Thanks
@u-kai: @jluriot 

> I had two clusters using the same TXT owner ID for both, using the upsert-only mode. I wanted to separate them.  
> So I did different things in each of my clusters.

Thanks for the detailed explanation!  

- Were both clusters managing the same records while using the same `txt-owner-id`?  

> In the first one, I deleted all the records (A + TXT) and then updated the external-dns with the new TXT owner ID. The issue happened.

In this first cluster, during the time you deleted all records, were both ExternalDNS instances still running?  
Also, when you mention that *“the issue happened”*, does that mean only the **A record** was created after updating the `txt-owner-id`?  
And after manually deleting that A record, both **A + TXT** records were then correctly created — is that right?

> In the second one, I manually updated all TXT records with the new TXT owner ID. For testing purposes I deleted one of those records (A + TXT). The same issue happened.

In this second case, were both clusters still running ExternalDNS while you were updating the owner ID?  
And were they still managing the same records during that period?

Lastly, regarding your question about caching:  
ExternalDNS does have a cache, but you would need to explicitly configure it using `--cache-interval`, but in your example I don’t see that flag.  
Also, ownership (TXT) records are **not cached**, so in this case caching shouldn’t be related.  
`zones-cache` only stores zone metadata, not record-level data.  
@u-kai: @chrw 

Thanks for sharing this!  
Just to clarify — were you (or have you ever been) running multiple clusters at the same time with ExternalDNS?  
Also, do you know if the TXT records were in a completely clean state before this happened,  
or is it possible that some previous TXT records still existed from earlier runs?
@chrw: No. It's just one cluster with one pod running external-dns. All records were deleted manually by me and were gone. The pod and EKS is running in the same account as the Route53 Hosted Zone.

But what about TTL of the TXT records though? It's set to 300s by default. Is this a problem? I took me more than this 300s to delete the A and AAA records and fix the issue.@chrw: I double checked and it's getting more complicated. I created a new ingress resource with a hostname we never used. So `echoserver-test` was new. After applying the following happened.

```
{"level":"info","msg":"Desired change: CREATE aaaa-echoserver-test.dev.example.com TXT","profile":"default","time":"2025-10-08T14:33:18Z","zoneID":"/hostedzone/XXX","zoneName":"dev.example.com."}                                                     │
{"level":"info","msg":"Desired change: CREATE cname-echoserver-test.dev.example.com TXT","profile":"default","time":"2025-10-08T14:33:18Z","zoneID":"/hostedzone/XXX","zoneName":"dev.example.com."}                                                    │
{"level":"info","msg":"Desired change: CREATE echoserver-test.dev.example.com A","profile":"default","time":"2025-10-08T14:33:18Z","zoneID":"/hostedzone/XXX","zoneName":"dev.example.com."}                                                            │
{"level":"info","msg":"Desired change: CREATE echoserver-test.dev.example.com AAAA","profile":"default","time":"2025-10-08T14:33:18Z","zoneID":"/hostedzone/XXX","zoneName":"dev.example.com."}                                                         │
{"level":"info","msg":"4 record(s) were successfully updated","profile":"default","time":"2025-10-08T14:33:18Z","zoneID":"/hostedzone/XXX","zoneName":"dev.example.com."}      
```

So this is the behaviour I expected. I deleted all 4 records and this happened.

```
{"level":"info","msg":"Applying provider record filter for domains: [dev.example.com. .dev.example.com.]","time":"2025-10-08T14:39:07Z"}                                                                                                                                  │
{"level":"info","msg":"Desired change: CREATE echoserver-test.dev.example.com A","profile":"default","time":"2025-10-08T14:39:07Z","zoneID":"/hostedzone/XXX","zoneName":"dev.example.com."}                                                            │
{"level":"info","msg":"Desired change: CREATE echoserver-test.dev.example.com AAAA","profile":"default","time":"2025-10-08T14:39:07Z","zoneID":"/hostedzone/XXX","zoneName":"dev.example.com."}                                                         │
{"level":"info","msg":"2 record(s) were successfully updated","profile":"default","time":"2025-10-08T14:39:07Z","zoneID":"/hostedzone/XXX","zoneName":"dev.example.com."}       
```

No TXT records. I deleted the two records right away and it fixed the problem

```
{"level":"info","msg":"Applying provider record filter for domains: [dev.example.com. .dev.example.com.]","time":"2025-10-08T14:39:29Z"}                                                                                                                                  │
{"level":"info","msg":"Desired change: CREATE aaaa-echoserver-test.dev.example.com TXT","profile":"default","time":"2025-10-08T14:39:29Z","zoneID":"/hostedzone/XXX","zoneName":"dev.example.com."}                                                     │
{"level":"info","msg":"Desired change: CREATE cname-echoserver-test.dev.example.com TXT","profile":"default","time":"2025-10-08T14:39:29Z","zoneID":"/hostedzone/XXX","zoneName":"dev.example.com."}                                                    │
{"level":"info","msg":"Desired change: CREATE echoserver-test.dev.example.com A","profile":"default","time":"2025-10-08T14:39:29Z","zoneID":"/hostedzone/XXX","zoneName":"dev.example.com."}                                                            │
{"level":"info","msg":"Desired change: CREATE echoserver-test.dev.example.com AAAA","profile":"default","time":"2025-10-08T14:39:29Z","zoneID":"/hostedzone/XXX","zoneName":"dev.example.com."}                                                         │
{"level":"info","msg":"4 record(s) were successfully updated","profile":"default","time":"2025-10-08T14:39:29Z","zoneID":"/hostedzone/XXX","zoneName":"dev.example.com."}
```

I can repeat this pattern several times. If I delete 4 records external-dns does not create the TXT records. I the 2 records external-dns creates 4 records. I have the feeling that the TXT records TTL might be responsible for this effect.@u-kai: @jluriot @chrw 

Thanks a lot for providing such detailed information multiple times — thanks to that, I was able to reproduce the issue in my environment and identify the root cause.   🙇 

I'm currently working on a fix. The implementation (except for the unit test) is already done, and it’s working as expected in my local environment. Please give me a bit more time to finalize it.

Here’s the cause of the problem:  

Since v0.19.0, ExternalDNS has been designed **not to create a TXT record if it already exists**, because previously it could lead to errors when trying to create an ownership TXT record that was already present.  
However, there was a bug in this logic. This logic writes the existing TXT records into an internal state, which is supposed to be reset after each record update operation.  
But the update process (`ApplyChanges`) is **not called when there are no changes detected** (i.e., when the desired and current states match).  
As a result, the internal state holding existing TXT records was not reset.  
So when the A + TXT records were deleted, ExternalDNS mistakenly assumed that the TXT record still existed internally, and therefore skipped creating it again — which led to the behavior you observed.
@jluriot: @u-kai Sorry for not answering in time to the other comment and thanks for the quick solution! That makes total sense with what's happening on our environments.

Glad you could reproduce it and fix it! Thanks again.

Solution patch:
diff --git a/controller/execute.go b/controller/execute.go
index 002868e3..7393a619 100644
--- a/controller/execute.go
+++ b/controller/execute.go
@@ -278,17 +278,17 @@ func buildProvider(
 		if cfg.OCIAuthInstancePrincipal {
 			if len(cfg.OCICompartmentOCID) == 0 {
 				err = fmt.Errorf("instance principal authentication requested, but no compartment OCID provided")
-			} else {
-				authConfig := oci.OCIAuthConfig{UseInstancePrincipal: true}
-				config = &oci.OCIConfig{Auth: authConfig, CompartmentID: cfg.OCICompartmentOCID}
+				break
 			}
+			authConfig := oci.OCIAuthConfig{UseInstancePrincipal: true}
+			config = &oci.OCIConfig{Auth: authConfig, CompartmentID: cfg.OCICompartmentOCID}
 		} else {
-			config, err = oci.LoadOCIConfig(cfg.OCIConfigFile)
+			if config, err = oci.LoadOCIConfig(cfg.OCIConfigFile); err != nil {
+				break
+			}
 		}
 		config.ZoneCacheDuration = cfg.OCIZoneCacheDuration
-		if err == nil {
-			p, err = oci.NewOCIProvider(*config, domainFilter, zoneIDFilter, cfg.OCIZoneScope, cfg.DryRun)
-		}
+		p, err = oci.NewOCIProvider(*config, domainFilter, zoneIDFilter, cfg.OCIZoneScope, cfg.DryRun)
 	case "rfc2136":
 		tlsConfig := rfc2136.TLSConfig{
 			UseTLS:                cfg.RFC2136UseTLS,
diff --git a/provider/cloudflare/cloudflare.go b/provider/cloudflare/cloudflare.go
index ceda2239..3d0c821b 100644
--- a/provider/cloudflare/cloudflare.go
+++ b/provider/cloudflare/cloudflare.go
@@ -721,7 +721,7 @@ func (p *CloudFlareProvider) submitChanges(ctx context.Context, changes []*cloud
 	}
 
 	if len(failedZones) > 0 {
-		return fmt.Errorf("failed to submit all changes for the following zones: %q", failedZones)
+		return provider.NewSoftErrorf("failed to submit all changes for the following zones: %q", failedZones)
 	}
 
 	return nil
diff --git a/registry/txt.go b/registry/txt.go
index fef387e2..7802367c 100644
--- a/registry/txt.go
+++ b/registry/txt.go
@@ -173,6 +173,12 @@ func (im *TXTRegistry) OwnerID() string {
 // If TXT records was created previously to indicate ownership its corresponding value
 // will be added to the endpoints Labels map
 func (im *TXTRegistry) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {
+	// existingTXTs must always hold the latest TXT records, so it needs to be reset every time.
+	// Previously, it was reset with a defer after ApplyChanges, but ApplyChanges is not called
+	// when plan.HasChanges() is false (i.e., when there are no changes to apply).
+	// In that case, stale TXT record information could remain, so we reset it here instead.
+	im.existingTXTs.reset()
+
 	// If we have the zones cached AND we have refreshed the cache since the
 	// last given interval, then just use the cached results.
 	if im.recordsCache != nil && time.Since(im.recordsCacheRefreshTime) < im.cacheInterval {
@@ -321,8 +327,6 @@ func (im *TXTRegistry) generateTXTRecordWithFilter(r *endpoint.Endpoint, filter
 // ApplyChanges updates dns provider with the changes
 // for each created/deleted record it will also take into account TXT records for creation/deletion
 func (im *TXTRegistry) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
-	defer im.existingTXTs.reset() // reset existing TXTs for the next reconciliation loop
-
 	filteredChanges := &plan.Changes{
 		Create:    changes.Create,
 		UpdateNew: endpoint.FilterEndpointsByOwnerID(im.ownerID, changes.UpdateNew),



Test patch:
diff --git a/controller/execute_test.go b/controller/execute_test.go
index 96f27a83..a3e5b461 100644
--- a/controller/execute_test.go
+++ b/controller/execute_test.go
@@ -264,6 +264,23 @@ func TestBuildProvider(t *testing.T) {
 			},
 			expectedType: "*provider.CachedProvider",
 		},
+		{
+			name: "oci provider instance principal without compartment OCID",
+			cfg: &externaldns.Config{
+				Provider:                 "oci",
+				OCIAuthInstancePrincipal: true,
+				OCICompartmentOCID:       "",
+			},
+			expectedError: "instance principal authentication requested, but no compartment OCID provided",
+		},
+		{
+			name: "oci provider without config file",
+			cfg: &externaldns.Config{
+				Provider:      "oci",
+				OCIConfigFile: "",
+			},
+			expectedError: "reading OCI config file",
+		},
 		{
 			name: "coredns provider",
 			cfg: &externaldns.Config{
diff --git a/registry/txt_test.go b/registry/txt_test.go
index ed387647..b7bb3673 100644
--- a/registry/txt_test.go
+++ b/registry/txt_test.go
@@ -2109,3 +2109,82 @@ func TestTXTRecordMigration(t *testing.T) {
 	assert.Equal(t, updatedTXTRecord[0].Targets, expectedFinalTXT[0].Targets)
 
 }
+
+// TestRecreateRecordAfterDeletion ensures that when A and TXT records are deleted,
+// both are correctly recreated in subsequent reconciliation loops.
+// This prevents regression of the issue where stale TXT record state
+// caused ExternalDNS to skip recreating TXT records after deletion.
+func TestRecreateRecordAfterDeletion(t *testing.T) {
+	ownerID := "foo"
+	ctx := context.Background()
+	p := inmemory.NewInMemoryProvider()
+	p.CreateZone(testZone)
+
+	r, _ := NewTXTRegistry(p, "%{record_type}-", "", "foo", 0, "", []string{endpoint.RecordTypeA}, []string{}, false, nil, "")
+
+	createdRecords := newEndpointWithOwnerAndLabels("bar.test-zone.example.org", "1.2.3.4", endpoint.RecordTypeA, ownerID, nil)
+	txtRecord := r.generateTXTRecord(createdRecords)
+
+	// 1. Create initial A and TXT records.
+	creates := append([]*endpoint.Endpoint{createdRecords}, txtRecord...)
+	err := p.ApplyChanges(ctx, &plan.Changes{
+		Create: creates,
+	})
+	assert.NoError(t, err)
+
+	// 2. Simulate a "no change" reconciliation (ApplyChanges won't be called).
+	desired := []*endpoint.Endpoint{
+		{
+			DNSName: "bar.test-zone.example.org",
+			Targets: endpoint.Targets{
+				"1.2.3.4",
+			},
+			RecordType: endpoint.RecordTypeA,
+		},
+	}
+
+	records, err := r.Records(ctx)
+	assert.NoError(t, err)
+
+	calculated := &plan.Plan{
+		Policies:       []plan.Policy{&plan.SyncPolicy{}},
+		ManagedRecords: []string{endpoint.RecordTypeA},
+		Current:        records,
+		Desired:        desired,
+		OwnerID:        ownerID,
+	}
+	calculated = calculated.Calculate()
+	// ApplyChanges is not called to simulate no changes.
+	assert.False(t, calculated.Changes.HasChanges(), "There should be no changes")
+
+	// 3. Delete both A and TXT records (simulate manual deletion)
+	deletes := append([]*endpoint.Endpoint{createdRecords}, txtRecord...)
+	err = p.ApplyChanges(ctx, &plan.Changes{
+		Delete: deletes,
+	})
+	assert.NoError(t, err)
+
+	// 4. Run reconciliation again — both A and TXT should be recreated.
+	records, err = r.Records(ctx)
+	assert.NoError(t, err)
+
+	calculated = &plan.Plan{
+		Policies:       []plan.Policy{&plan.SyncPolicy{}},
+		ManagedRecords: []string{endpoint.RecordTypeA},
+		Current:        records,
+		Desired:        desired,
+		OwnerID:        ownerID,
+	}
+	calculated = calculated.Calculate()
+	if !calculated.Changes.HasChanges() {
+		assert.Fail(t, "There should be changes")
+	}
+
+	err = r.ApplyChanges(ctx, calculated.Changes)
+	assert.NoError(t, err)
+
+	// 5. Verify that both A and TXT records are recreated successfully.
+	records, err = p.Records(ctx)
+	assert.NoError(t, err)
+	assert.True(t, testutils.SameEndpoints(records, append(desired, txtRecord...)), "Expected records after reconciliation: %v, but got: %v", append(desired, txtRecord...), records)
+}