Skip to content

Commit f249ee3

Browse files
committed
Derive failed error import id from the message, not a random Guid
Failed error imports were keyed by Guid.NewGuid(), so each failed import attempt produced a distinct document (and log file), and nothing tied a document back to its FailedImports/Error/{id}.txt log. Add FailedErrorImport.DeriveKey(headers, nativeMessageId), which uses the message's UniqueId() when it can be derived and falls back to a deterministic id built from the native transport id when it cannot. UniqueId() throws when a message carries no processing endpoint header, and malformed or header-less messages are a leading cause of import failure, so the fallback is required rather than optional. ErrorIngestionFaultPolicy now keys the stored failure by this derived id. Repeated failures of the same message collapse onto one document, the latest attempt's details win, and the log file name is recoverable from the document key. This is a shared change: the derivation lives in ServiceControl.Persistence and is reused by the upcoming EF persister so the two cannot drift. Existing RavenDB data is unaffected and needs no migration. The read path is index-based and deletes by document id, so legacy random-id documents are still found, replayed, and removed. The store session uses no optimistic concurrency, so writing a derived id that already exists is a plain upsert.
1 parent 784e566 commit f249ee3

4 files changed

Lines changed: 147 additions & 1 deletion

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
namespace ServiceControl.Persistence.Tests.RavenDB.Operations
2+
{
3+
using System.Collections.Generic;
4+
using System.Threading.Tasks;
5+
using NServiceBus;
6+
using NUnit.Framework;
7+
using Raven.Client.Documents;
8+
using ServiceControl.Operations;
9+
10+
[TestFixture]
11+
class FailedErrorImportDedupeTests : RavenPersistenceTestBase
12+
{
13+
[Test]
14+
public async Task Repeated_failure_of_the_same_message_stores_one_document()
15+
{
16+
var headers = new Dictionary<string, string>
17+
{
18+
{ Headers.MessageId, "message-1" },
19+
{ Headers.ProcessingEndpoint, "Sales" }
20+
};
21+
22+
await StoreFailure(headers, "the first failure");
23+
await StoreFailure(headers, "the second failure");
24+
25+
DocumentStore.WaitForIndexing();
26+
27+
using var session = DocumentStore.OpenAsyncSession();
28+
var documents = await session.Query<FailedErrorImport>().ToListAsync();
29+
30+
using (Assert.EnterMultipleScope())
31+
{
32+
Assert.That(documents, Has.Count.EqualTo(1));
33+
Assert.That(documents[0].ExceptionInfo, Is.EqualTo("the second failure"));
34+
}
35+
}
36+
37+
Task StoreFailure(IReadOnlyDictionary<string, string> headers, string exceptionInfo) =>
38+
ErrorStore.StoreFailedErrorImport(new FailedErrorImport
39+
{
40+
Id = FailedErrorImport.MakeDocumentId(FailedErrorImport.DeriveKey(headers, "native-1")),
41+
Message = new FailedTransportMessage
42+
{
43+
Id = "native-1",
44+
Headers = new Dictionary<string, string>(headers),
45+
Body = []
46+
},
47+
ExceptionInfo = exceptionInfo
48+
});
49+
}
50+
}

src/ServiceControl.Persistence/FailedErrorImport.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
namespace ServiceControl.Operations
22
{
33
using System;
4+
using System.Collections.Generic;
5+
using ServiceControl.Persistence.Infrastructure;
46

57
public class FailedErrorImport
68
{
@@ -9,5 +11,24 @@ public class FailedErrorImport
911
public string ExceptionInfo { get; set; }
1012

1113
public static string MakeDocumentId(Guid id) => $"FailedErrorImports/{id}";
14+
15+
public static Guid DeriveKey(IReadOnlyDictionary<string, string> headers, string nativeMessageId)
16+
{
17+
try
18+
{
19+
if (Guid.TryParse(headers.UniqueId(), out var uniqueMessageId))
20+
{
21+
return uniqueMessageId;
22+
}
23+
}
24+
catch (Exception)
25+
{
26+
// UniqueId() derives the processing endpoint, which throws when the failed message
27+
// carries no endpoint header. Malformed messages are a leading cause of import
28+
// failure, so fall back to a key derived from the id the transport always supplies.
29+
}
30+
31+
return DeterministicGuid.MakeId(nativeMessageId);
32+
}
1233
}
1334
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
namespace ServiceControl.UnitTests.Operations;
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using NServiceBus;
6+
using NUnit.Framework;
7+
using ServiceControl.Operations;
8+
using ServiceControl.Persistence.Infrastructure;
9+
10+
[TestFixture]
11+
public class When_deriving_a_failed_error_import_key
12+
{
13+
[Test]
14+
public void Uses_the_unique_message_id_when_headers_are_well_formed()
15+
{
16+
var headers = new Dictionary<string, string>
17+
{
18+
{ Headers.MessageId, "message-1" },
19+
{ Headers.ProcessingEndpoint, "Sales" }
20+
};
21+
22+
var key = FailedErrorImport.DeriveKey(headers, "native-1");
23+
24+
Assert.That(key, Is.EqualTo(DeterministicGuid.MakeId("message-1", "Sales")));
25+
}
26+
27+
[Test]
28+
public void Prefers_an_existing_retry_unique_message_id()
29+
{
30+
var uniqueMessageId = Guid.NewGuid();
31+
var headers = new Dictionary<string, string>
32+
{
33+
{ "ServiceControl.Retry.UniqueMessageId", uniqueMessageId.ToString() }
34+
};
35+
36+
var key = FailedErrorImport.DeriveKey(headers, "native-1");
37+
38+
Assert.That(key, Is.EqualTo(uniqueMessageId));
39+
}
40+
41+
[Test]
42+
public void Falls_back_to_the_native_id_when_no_processing_endpoint_can_be_derived()
43+
{
44+
var headers = new Dictionary<string, string>
45+
{
46+
{ Headers.MessageId, "message-1" }
47+
};
48+
49+
var key = FailedErrorImport.DeriveKey(headers, "native-1");
50+
51+
Assert.That(key, Is.EqualTo(DeterministicGuid.MakeId("native-1")));
52+
}
53+
54+
[Test]
55+
public void Falls_back_when_the_retry_unique_message_id_is_not_a_guid()
56+
{
57+
var headers = new Dictionary<string, string>
58+
{
59+
{ "ServiceControl.Retry.UniqueMessageId", "not-a-guid" }
60+
};
61+
62+
var key = FailedErrorImport.DeriveKey(headers, "native-1");
63+
64+
Assert.That(key, Is.EqualTo(DeterministicGuid.MakeId("native-1")));
65+
}
66+
67+
[Test]
68+
public void Is_stable_across_repeated_failures_of_the_same_malformed_message()
69+
{
70+
var first = FailedErrorImport.DeriveKey(new Dictionary<string, string>(), "native-1");
71+
var second = FailedErrorImport.DeriveKey(new Dictionary<string, string>(), "native-1");
72+
73+
Assert.That(second, Is.EqualTo(first));
74+
}
75+
}

src/ServiceControl/Operations/ErrorIngestionFaultPolicy.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ async Task Handle(ErrorContext errorContext, CancellationToken cancellationToken
5757
Body = errorContext.Body.ToArray()
5858
},
5959
ExceptionInfo = errorContext.Exception.ToFriendlyString(),
60-
Id = FailedErrorImport.MakeDocumentId(Guid.NewGuid())
60+
Id = FailedErrorImport.MakeDocumentId(FailedErrorImport.DeriveKey(errorContext.Headers, errorContext.MessageId))
6161
};
6262

6363
try

0 commit comments

Comments
 (0)