Skip to content

Commit d6f29c4

Browse files
authored
Merge pull request #5639 from Particular/john/raven_bugs
Fix RavenDB ingestion state handling
2 parents ebb59e1 + f9bc7c2 commit d6f29c4

4 files changed

Lines changed: 148 additions & 7 deletions

File tree

src/ServiceControl.Persistence.RavenDB/UnitOfWork/RavenMonitoringIngestionUnitOfWork.cs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,23 @@ static PatchCommandData CreateKnownEndpointsPutCommand(KnownEndpoint endpoint)
2828

2929
var docId = RavenMonitoringDataStore.MakeDocumentId(endpoint.EndpointDetails.GetDeterministicId());
3030

31-
var request = new PatchRequest
31+
// Ingestion always observes endpoints with Monitored = false, so patching an
32+
// already-known endpoint must not stomp a user-set Monitored = true flag back to false.
33+
var existingDocPatch = new PatchRequest
34+
{
35+
Script = @$"
36+
var insert = {document};
37+
38+
for(var key in insert) {{
39+
if(insert.hasOwnProperty(key) && key !== '{nameof(KnownEndpoint.Monitored)}') {{
40+
this[key] = insert[key];
41+
}}
42+
}}"
43+
};
44+
45+
// A newly discovered endpoint has no existing Monitored value to preserve, so it
46+
// still gets the full document, including Monitored = false.
47+
var patchIfMissing = new PatchRequest
3248
{
3349
Script = @$"
3450
var insert = {document};
@@ -40,7 +56,7 @@ static PatchCommandData CreateKnownEndpointsPutCommand(KnownEndpoint endpoint)
4056
}}"
4157
};
4258

43-
return new PatchCommandData(docId, null, request, request);
59+
return new PatchCommandData(docId, null, existingDocPatch, patchIfMissing);
4460
}
4561

4662
static RavenMonitoringIngestionUnitOfWork()

src/ServiceControl.Persistence.RavenDB/UnitOfWork/RavenRecoverabilityIngestionUnitOfWork.cs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,11 @@ ICommandData CreateFailedMessagesPatchCommand(string uniqueMessageId, FailedMess
9393
const string AttemptedAt = nameof(FailedMessage.ProcessingAttempt.AttemptedAt);
9494

9595
//HINT: RavenDB 4.2 removed Lodash utility functions, but supports ECMAScript 5.1 and some 6.0 features like arrow functions and array primitive functions
96-
return new PatchCommandData(documentId, null, new PatchRequest
96+
var existingDocPatch = new PatchRequest
9797
{
9898
Script = $@"this.{nameof(FailedMessage.Status)} = args.status;
9999
this.{nameof(FailedMessage.FailureGroups)} = args.failureGroups;
100-
100+
101101
var newAttempts = this.{nameof(FailedMessage.ProcessingAttempts)};
102102
103103
//De-duplicate attempts by AttemptedAt value
@@ -109,7 +109,7 @@ ICommandData CreateFailedMessagesPatchCommand(string uniqueMessageId, FailedMess
109109
110110
//Trim to the latest MaxProcessingAttempts
111111
newAttempts.sort((a, b) => a.{AttemptedAt} > b.{AttemptedAt} ? 1 : -1);
112-
112+
113113
if(newAttempts.length > {MaxProcessingAttempts})
114114
{{
115115
newAttempts = newAttempts.slice(newAttempts.length - {MaxProcessingAttempts}, newAttempts.length);
@@ -123,7 +123,13 @@ ICommandData CreateFailedMessagesPatchCommand(string uniqueMessageId, FailedMess
123123
{"failureGroups", groups},
124124
{"attempt", processingAttempt}
125125
},
126-
},
126+
};
127+
128+
// A message re-failing must not keep an @expires stamp set by an earlier
129+
// resolve/archive/retry, otherwise it can be silently deleted while still Unresolved.
130+
expirationManager.CancelExpiration(existingDocPatch);
131+
132+
return new PatchCommandData(documentId, null, existingDocPatch,
127133
patchIfMissing: new PatchRequest
128134
{
129135
Script = $@"this.{nameof(FailedMessage.Status)} = args.status;

src/ServiceControl.Persistence.Tests.RavenDB/Expiration/MessageExpiryTests.cs

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,56 @@ public async Task SingleMessageMarkedAsResolvedShouldExpire()
141141
await WaitUntil(async () => (await GetAllMessages()).Results.Count == 0, "Archived message should be removed after archiving.");
142142
}
143143

144+
[Test]
145+
public async Task MessageFailingAgainAfterRetryShouldNotKeepExpiration()
146+
{
147+
var (context, attempt) = CreateMessageContext();
148+
var uniqueMessageId = context.Headers.UniqueId();
149+
150+
await DisableExpiration();
151+
152+
await using (var uow = await IngestionUnitOfWorkFactory.StartNew())
153+
{
154+
await uow.Recoverability.RecordFailedProcessingAttempt(context, attempt, []);
155+
156+
await uow.Complete(TestContext.CurrentContext.CancellationToken);
157+
}
158+
159+
await CompleteDatabaseOperation();
160+
161+
// Successful retry stamps @expires on the FailedMessage document.
162+
await using (var uow = await IngestionUnitOfWorkFactory.StartNew())
163+
{
164+
await uow.Recoverability.RecordSuccessfulRetry(uniqueMessageId);
165+
166+
await uow.Complete(TestContext.CurrentContext.CancellationToken);
167+
}
168+
169+
await CompleteDatabaseOperation();
170+
171+
// The same logical message fails again before the retention period elapses.
172+
var (context2, attempt2) = CreateMessageContext(uniqueMessageId);
173+
174+
await using (var uow = await IngestionUnitOfWorkFactory.StartNew())
175+
{
176+
await uow.Recoverability.RecordFailedProcessingAttempt(context2, attempt2, []);
177+
178+
await uow.Complete(TestContext.CurrentContext.CancellationToken);
179+
}
180+
181+
await CompleteDatabaseOperation();
182+
183+
var documentId = FailedMessageIdGenerator.MakeDocumentId(uniqueMessageId);
184+
185+
using var session = DocumentStore.OpenAsyncSession();
186+
var failedMessage = await session.LoadAsync<FailedMessage>(documentId);
187+
var metadata = session.Advanced.GetMetadataFor(failedMessage);
188+
189+
Assert.That(failedMessage.Status, Is.EqualTo(FailedMessageStatus.Unresolved));
190+
Assert.That(metadata.ContainsKey(Raven.Client.Constants.Documents.Metadata.Expires), Is.False,
191+
"A message that fails again after being resolved should not retain its previous @expires stamp.");
192+
}
193+
144194
[Test]
145195
public async Task RetryConfirmationProcessingShouldTriggerExpiration()
146196
{
@@ -169,14 +219,21 @@ public async Task RetryConfirmationProcessingShouldTriggerExpiration()
169219
await WaitUntil(async () => (await GetAllMessages()).Results.Count == 0, "Retry confirmation should cause message removal.");
170220
}
171221

172-
static (MessageContext, FailedMessage.ProcessingAttempt) CreateMessageContext()
222+
static (MessageContext, FailedMessage.ProcessingAttempt) CreateMessageContext(string forceUniqueMessageId = null)
173223
{
174224
var headers = new Dictionary<string, string>
175225
{
176226
{Headers.ProcessingEndpoint, "SomeEndpoint"},
177227
{Headers.MessageId, Guid.NewGuid().ToString() }
178228
};
179229

230+
// Forces context.Headers.UniqueId() to resolve to a specific, already-known
231+
// UniqueMessageId, so a test can simulate the same logical message failing again.
232+
if (forceUniqueMessageId != null)
233+
{
234+
headers["ServiceControl.Retry.UniqueMessageId"] = forceUniqueMessageId;
235+
}
236+
180237
var attempt = FailedMessageBuilder.Minimal().ProcessingAttempts.First();
181238

182239
var message = new MessageContext(Guid.NewGuid().ToString(), headers, ReadOnlyMemory<byte>.Empty, new TransportTransaction(), "receiveAddress", new ContextBag());

src/ServiceControl.Persistence.Tests/MonitoringDataStoreTests.cs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,5 +227,67 @@ public async Task Unit_of_work_detects_endpoint()
227227
Assert.That(fromStorage.Monitored, Is.EqualTo(knownEndpoint.Monitored), "Monitored should match");
228228
}
229229
}
230+
231+
[Test]
232+
public async Task Ingesting_a_known_endpoint_does_not_reset_monitored_flag()
233+
{
234+
var endpointDetails = new EndpointDetails { Host = "Host1", HostId = Guid.NewGuid(), Name = "Endpoint" };
235+
236+
await MonitoringDataStore.CreateIfNotExists(endpointDetails);
237+
await MonitoringDataStore.UpdateEndpointMonitoring(endpointDetails, true);
238+
239+
await CompleteDatabaseOperation();
240+
241+
// Error ingestion always records endpoints it observes with Monitored = false
242+
// (ErrorProcessor.RecordKnownEndpoints), regardless of the endpoint's current
243+
// monitored state. Recording it again must not stomp a user-set Monitored = true.
244+
var observedAgain = new KnownEndpoint
245+
{
246+
HostDisplayName = endpointDetails.Host,
247+
EndpointDetails = endpointDetails,
248+
Monitored = false
249+
};
250+
251+
await using (var unitOfWork = await UnitOfWorkFactory.StartNew())
252+
{
253+
await unitOfWork.Monitoring.RecordKnownEndpoint(observedAgain);
254+
255+
await unitOfWork.Complete(TestContext.CurrentContext.CancellationToken);
256+
}
257+
258+
await CompleteDatabaseOperation();
259+
260+
var knownEndpoints = await MonitoringDataStore.GetAllKnownEndpoints();
261+
var fromStorage = knownEndpoints.Single(e => e.EndpointDetails.HostId == endpointDetails.HostId);
262+
263+
Assert.That(fromStorage.Monitored, Is.True, "Ingestion must not reset an existing endpoint's Monitored flag back to false");
264+
}
265+
266+
[Test]
267+
public async Task Ingesting_a_previously_unknown_endpoint_creates_it_unmonitored()
268+
{
269+
var endpointDetails = new EndpointDetails { Host = "Host1", HostId = Guid.NewGuid(), Name = "Endpoint" };
270+
271+
var observed = new KnownEndpoint
272+
{
273+
HostDisplayName = endpointDetails.Host,
274+
EndpointDetails = endpointDetails,
275+
Monitored = false
276+
};
277+
278+
await using (var unitOfWork = await UnitOfWorkFactory.StartNew())
279+
{
280+
await unitOfWork.Monitoring.RecordKnownEndpoint(observed);
281+
282+
await unitOfWork.Complete(TestContext.CurrentContext.CancellationToken);
283+
}
284+
285+
await CompleteDatabaseOperation();
286+
287+
var knownEndpoints = await MonitoringDataStore.GetAllKnownEndpoints();
288+
var fromStorage = knownEndpoints.Single(e => e.EndpointDetails.HostId == endpointDetails.HostId);
289+
290+
Assert.That(fromStorage.Monitored, Is.False, "A newly discovered endpoint should be created unmonitored");
291+
}
230292
}
231293
}

0 commit comments

Comments
 (0)