Skip to content

Commit a1e4e80

Browse files
committed
fix(durable-messaging): enforce activation safety
1 parent 4d9d209 commit a1e4e80

13 files changed

Lines changed: 251 additions & 33 deletions

docs/site/src/content/docs/grains/durable-messaging.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,13 @@ Configure Durable Jobs storage and Journaling storage before enabling Durable
107107
Messaging. Grains which use Durable Messaging derive from
108108
<xref:Orleans.Journaling.DurableGrain>; its activation lifecycle initializes the
109109
journaled state manager and materializes the inbox and outbox participants before
110-
message recovery begins. The Journaling implementation must provide
110+
message recovery begins. Durable Messaging selects the built-in `orleans-binary`
111+
journal format so opaque envelope bodies and request-context slices recover exactly.
112+
Durable Messaging grains use non-reentrant execution: they don't apply `Reentrant`,
113+
`MayInterleave`, `AlwaysInterleave`, or `StatelessWorker`. A single non-interleaving
114+
activation owns each grain journal and pump, so infrastructure writes cannot commit
115+
provisional application state or compete with another activation for the same ownership.
116+
The Journaling implementation must provide
111117
<xref:Orleans.Journaling.IJournaledStateManager.RevertPendingChangesAsync*> and accept
112118
<xref:Orleans.Journaling.IJournaledStateManager.RegisterObserver*> so Durable Messaging
113119
receives commit and recovery notifications. Activation reports a

src/Orleans.DurableMessaging/DurableInboxExtension.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,8 +1022,12 @@ internal async Task ResumeProcessingAsync(bool replaceExisting, CancellationToke
10221022
}
10231023
}
10241024

1025-
public Task OnStart(CancellationToken cancellationToken) =>
1026-
ResumeProcessingAsync(replaceExisting: true, cancellationToken);
1025+
public Task OnStart(CancellationToken cancellationToken)
1026+
{
1027+
cancellationToken.ThrowIfCancellationRequested();
1028+
DurableMessagingActivationValidator.Validate(_grainContext);
1029+
return ResumeProcessingAsync(replaceExisting: true, cancellationToken);
1030+
}
10271031

10281032
public Task OnStop(CancellationToken cancellationToken)
10291033
{
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using Orleans.Concurrency;
2+
3+
namespace Orleans.DurableMessaging;
4+
5+
internal static class DurableMessagingActivationValidator
6+
{
7+
public static void Validate(IGrainContext grainContext)
8+
{
9+
var grain = grainContext.GrainInstance
10+
?? throw new InvalidOperationException("Durable Messaging activation requires an initialized grain instance.");
11+
var grainType = grain.GetType();
12+
if (grainType.IsDefined(typeof(StatelessWorkerAttribute), inherit: true))
13+
{
14+
throw new InvalidOperationException(
15+
$"Durable Messaging requires one activation per grain identity, but grain type '{grainType}' is a stateless worker.");
16+
}
17+
18+
if (grainType.IsDefined(typeof(ReentrantAttribute), inherit: true)
19+
|| grainType.IsDefined(typeof(MayInterleaveAttribute), inherit: true))
20+
{
21+
throw new InvalidOperationException(
22+
$"Durable Messaging requires non-reentrant grain execution, but grain type '{grainType}' enables interleaving.");
23+
}
24+
25+
var grainInterfaces = grainType
26+
.GetInterfaces()
27+
.Where(static type => typeof(IGrain).IsAssignableFrom(type))
28+
.ToArray();
29+
var interleavableMethod = grainInterfaces
30+
.SelectMany(static type => type.GetInterfaces().Append(type))
31+
.Distinct()
32+
.SelectMany(static type => type.GetMethods())
33+
.FirstOrDefault(static method => method.IsDefined(typeof(AlwaysInterleaveAttribute), inherit: true));
34+
if (interleavableMethod is not null)
35+
{
36+
throw new InvalidOperationException(
37+
$"Durable Messaging grain type '{grainType}' implements interleavable method "
38+
+ $"'{interleavableMethod.DeclaringType}.{interleavableMethod.Name}'.");
39+
}
40+
}
41+
}

src/Orleans.DurableMessaging/DurableOutbox.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,7 @@ private void DeadLetterExpiredMessage(
653653
public Task OnStart(CancellationToken cancellationToken = default)
654654
{
655655
cancellationToken.ThrowIfCancellationRequested();
656+
DurableMessagingActivationValidator.Validate(_grainContext);
656657
EnsureMetricsActive();
657658
if (Count > 0)
658659
{

src/Orleans.DurableMessaging/Hosting/DurableMessagingExtensions.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ namespace Orleans.Hosting;
1919
/// </summary>
2020
public static class DurableMessagingExtensions
2121
{
22+
private const string DurableMessagingJournalFormatKey = "orleans-binary";
23+
2224
/// <summary>
2325
/// Adds durable inbox and outbox messaging support to the silo.
2426
/// </summary>
@@ -35,6 +37,8 @@ public static IServiceCollection AddDurableMessaging(this IServiceCollection ser
3537
{
3638
services.AddDurableJobs();
3739
services.TryAddSingleton(TimeProvider.System);
40+
services.PostConfigure<JournaledStateManagerOptions>(
41+
options => options.JournalFormatKey = DurableMessagingJournalFormatKey);
3842

3943
var optionsBuilder = services.AddOptions<DurableInboxOptions>();
4044
if (configureOptions is not null)

test/Orleans.DurableMessaging.Tests/Contracts/DeliveryAndOptionsContractTests.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Reflection;
22
using System.Runtime.CompilerServices;
3+
using NSubstitute;
34
using Orleans.DurableMessaging.Configuration;
45
using Orleans.Runtime;
56
using Xunit;
@@ -142,6 +143,10 @@ public async Task InboxLifecycleStart_CancellationInterruptsBlockedResume()
142143
"Orleans.DurableMessaging.DurableInboxExtension",
143144
throwOnError: true)!;
144145
var extension = (ILifecycleObserver)RuntimeHelpers.GetUninitializedObject(extensionType);
146+
var grainContext = Substitute.For<IGrainContext>();
147+
grainContext.GrainInstance.Returns(new object());
148+
extensionType.GetField("_grainContext", BindingFlags.Instance | BindingFlags.NonPublic)!
149+
.SetValue(extension, grainContext);
145150
extensionType.GetField("_gate", BindingFlags.Instance | BindingFlags.NonPublic)!
146151
.SetValue(extension, new SemaphoreSlim(0, 1));
147152
extensionType.GetField("_metricsActive", BindingFlags.Instance | BindingFlags.NonPublic)!

test/Orleans.DurableMessaging.Tests/Contracts/DurableOutboxDeliveryBatchTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,7 @@ public OutboxFixture(
576576
grainFactory.GetGrain<IDurableInboxExtension>(Arg.Any<GrainId>()).Returns(inbox);
577577
var grainContext = Substitute.For<IGrainContext>();
578578
grainContext.GrainId.Returns(SenderId);
579+
grainContext.GrainInstance.Returns(new object());
579580
grainContext.ObservableLifecycle.Returns(Substitute.For<IGrainLifecycle>());
580581

581582
TimerRegistry = timerRegistry ?? Substitute.For<ITimerRegistry>();

test/Orleans.DurableMessaging.Tests/Functional/MultiSiloDurableMessagingFailoverTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ await sender.SendAsync(
2828
"messages/failover",
2929
new DurableTestMessage(logicalId, 81, "failover"));
3030
await barrier.WaitUntilEnteredAsync();
31-
var before = await receiver.GetSnapshotAsync();
31+
var before = fixture.GetSnapshot(receiver);
3232
var owner = fixture.Cluster.Silos.Single(
3333
silo => silo.SiloAddress.ToParsableString() == before.SiloAddress);
3434

test/Orleans.DurableMessaging.Tests/Functional/PublicDurableMessagingBehaviorTests.cs

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ public sealed class PublicDurableMessagingBehaviorTests : IAsyncLifetime
2525
public ValueTask InitializeAsync() => fixture.InitializeAsync();
2626
public ValueTask DisposeAsync() => fixture.DisposeAsync();
2727

28+
[Fact]
29+
public void DefaultHosting_UsesBinaryJournalFormat()
30+
{
31+
Assert.Equal("orleans-binary", fixture.Storage.JournalFormatKey);
32+
}
33+
2834
[Fact]
2935
public async Task ApplicationJournaledStateNamesDoNotCollideWithMessagingState()
3036
{
@@ -49,7 +55,7 @@ public async Task Deliver_AcceptedOnlyAfterInboxAndStableJobOwnershipAreDurable(
4955
await barrier.WaitUntilEnteredAsync();
5056

5157
Assert.False(delivery.IsCompleted);
52-
var staged = await receiver.GetSnapshotAsync();
58+
var staged = fixture.GetSnapshot(receiver);
5359
Assert.Equal(1, staged.InboxCount);
5460
Assert.Empty(staged.Effects);
5561

@@ -115,7 +121,7 @@ public async Task ConcurrentWriteCannotCaptureInboxAcceptanceBeforeScheduling()
115121
await schedule.WaitUntilEnteredAsync();
116122

117123
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
118-
() => receiver.RetryWriteStateAsync());
124+
() => fixture.WriteStateAsync(receiver).AsTask());
119125
Assert.Contains("waiting for job scheduling", exception.Message, StringComparison.Ordinal);
120126

121127
schedule.Continue();
@@ -133,12 +139,12 @@ public async Task RecoveryDuringInboxScheduling_PreventsFalseAcceptance()
133139

134140
var delivery = DeliverAsync(receiver, envelope.Value);
135141
await schedule.WaitUntilEnteredAsync();
136-
await receiver.RevertStateAsync();
142+
await fixture.RevertStateAsync(receiver);
137143
schedule.Continue();
138144

139145
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => delivery);
140146
Assert.Contains("interrupted by state recovery", exception.Message, StringComparison.Ordinal);
141-
Assert.Equal(0, (await receiver.GetSnapshotAsync()).InboxCount);
147+
Assert.Equal(0, fixture.GetSnapshot(receiver).InboxCount);
142148
}
143149

144150
[Fact]
@@ -348,7 +354,7 @@ public async Task RecoveryDuringHandler_LeavesMessageRetryable()
348354

349355
Assert.Equal(DeliveryStatus.Accepted, (await DeliverAsync(receiver, envelope.Value)).Status);
350356
await handler.WaitUntilEnteredAsync();
351-
await receiver.RevertStateAsync();
357+
await fixture.RevertStateAsync(receiver);
352358
handler.Release();
353359

354360
var completed = await fixture.WaitForEffectCountAsync(receiver, 1);
@@ -368,7 +374,7 @@ public async Task RecoveryDuringHandlerFailure_DiscardsStaleFailureAccounting()
368374

369375
Assert.Equal(DeliveryStatus.Accepted, (await DeliverAsync(receiver, envelope.Value)).Status);
370376
await handler.WaitUntilEnteredAsync();
371-
await receiver.RevertStateAsync();
377+
await fixture.RevertStateAsync(receiver);
372378
handler.Release();
373379

374380
var completed = await fixture.WaitForEffectCountAsync(receiver, 1);
@@ -390,7 +396,7 @@ public async Task ConcurrentDuplicateDeliveries_ConvergeToOneEffectWithinRetenti
390396

391397
Assert.Equal(DeliveryStatus.Accepted, first.Status);
392398
Assert.False(second.IsCompleted);
393-
Assert.Equal(1, (await receiver.GetSnapshotAsync()).InboxCount);
399+
Assert.Equal(1, fixture.GetSnapshot(receiver).InboxCount);
394400

395401
barrier.Release();
396402
Assert.Equal(DeliveryStatus.Duplicate, (await second).Status);
@@ -539,7 +545,7 @@ await sender.SendAndDeactivateAsync(
539545
"messages/outbox-crash-window",
540546
NewMessage(52, "durable-wakeup"));
541547
await receiverWrite.WaitUntilEnteredAsync();
542-
var committed = await sender.GetSnapshotAsync();
548+
var committed = fixture.GetSnapshot(sender);
543549

544550
Assert.Equal(1, committed.OutboxCount);
545551
Assert.False(string.IsNullOrEmpty(committed.OutboxJobId));
@@ -733,7 +739,7 @@ public async Task InboxJobClearWriteFailure_RevertsThenRecoversAfterActivationLo
733739
Assert.Equal(DeliveryStatus.Accepted, (await DeliverAsync(receiver, envelope.Value)).Status);
734740
await handler.WaitUntilEnteredAsync();
735741
fixture.Storage.FailWrite(JournalId.FromGrainId(receiver.GetGrainId()), matchingWrite: 2);
736-
await receiver.DeactivateOnNextRecoveryAsync();
742+
fixture.DeactivateOnNextRecovery(receiver);
737743
handler.Release();
738744

739745
var recovered = await fixture.SnapshotProbe.WaitAsync(
@@ -760,7 +766,7 @@ public async Task DeliveryIntoEmptyInbox_ReplacesStalePersistedJobOwnership()
760766

761767
Assert.Equal(DeliveryStatus.Accepted, (await DeliverAsync(receiver, envelope.Value)).Status);
762768
await handler.WaitUntilEnteredAsync();
763-
var accepted = await receiver.GetSnapshotAsync();
769+
var accepted = fixture.GetSnapshot(receiver);
764770

765771
Assert.NotNull(accepted.InboxJobId);
766772
Assert.NotEqual(staleJobId, accepted.InboxJobId);
@@ -780,7 +786,7 @@ public async Task Inbox_StaleGenerationCompletesWithoutClearingNewerOwner()
780786

781787
Assert.Equal(DeliveryStatus.Accepted, (await DeliverAsync(receiver, envelope.Value)).Status);
782788
await handler.WaitUntilEnteredAsync();
783-
var owned = await receiver.GetSnapshotAsync();
789+
var owned = fixture.GetSnapshot(receiver);
784790
Assert.False(string.IsNullOrEmpty(owned.InboxJobId));
785791
var completionBaseline = fixture.Metrics.GetCount("orleans-durablejobs-jobs-completed");
786792
var manager = fixture.Cluster.Silos[0].ServiceProvider.GetRequiredService<ILocalDurableJobManager>();
@@ -801,7 +807,7 @@ await fixture.Metrics.WaitForCountAsync(
801807
"orleans-durablejobs-jobs-completed",
802808
completionBaseline + 1);
803809

804-
Assert.Equal(owned.InboxJobId, (await receiver.GetSnapshotAsync()).InboxJobId);
810+
Assert.Equal(owned.InboxJobId, fixture.GetSnapshot(receiver).InboxJobId);
805811
handler.Release();
806812
Assert.Equal("newer-inbox-owner", Assert.Single((await fixture.WaitForEffectCountAsync(receiver, 1)).Effects).Value);
807813
}
@@ -820,7 +826,7 @@ await sender.SendAsync(
820826
"messages/stale-outbox-generation",
821827
NewMessage(61, "newer-outbox-owner"));
822828
await receiverWrite.WaitUntilEnteredAsync();
823-
var owned = await sender.GetSnapshotAsync();
829+
var owned = fixture.GetSnapshot(sender);
824830
Assert.False(string.IsNullOrEmpty(owned.OutboxJobId));
825831
var completionBaseline = fixture.Metrics.GetCount("orleans-durablejobs-jobs-completed");
826832
var manager = fixture.Cluster.Silos[0].ServiceProvider.GetRequiredService<ILocalDurableJobManager>();
@@ -843,7 +849,7 @@ await fixture.Metrics.WaitForCountAsync(
843849
"orleans-durablejobs-jobs-completed",
844850
completionBaseline + 1);
845851

846-
Assert.Equal(owned.OutboxJobId, (await sender.GetSnapshotAsync()).OutboxJobId);
852+
Assert.Equal(owned.OutboxJobId, fixture.GetSnapshot(sender).OutboxJobId);
847853
}
848854
finally
849855
{
@@ -868,10 +874,10 @@ await sender.SendAsync(
868874
"messages/recovery-visibility",
869875
NewMessage(62, "recovery-visibility"));
870876
await receiverWrite.WaitUntilEnteredAsync();
871-
var owned = await sender.GetSnapshotAsync();
877+
var owned = fixture.GetSnapshot(sender);
872878
var ownershipId = Assert.IsType<string>(owned.OutboxJobId);
873879
var recoveryRead = fixture.Storage.BlockRead(JournalId.FromGrainId(sender.GetGrainId()));
874-
var recovery = sender.RevertStateAsync();
880+
var recovery = fixture.RevertStateAsync(sender).AsTask();
875881
await recoveryRead.WaitUntilEnteredAsync();
876882
var handlerBaseline = fixture.Metrics.GetCount("orleans-durablejobs-handler-executions-started");
877883
var completionBaseline = fixture.Metrics.GetCount("orleans-durablejobs-jobs-completed");
@@ -995,7 +1001,7 @@ await independentSender.SendAsync(
9951001
var independent = await fixture.WaitForEffectCountAsync(independentReceiver, 1);
9961002

9971003
Assert.Equal("independent", Assert.Single(independent.Effects).Value);
998-
Assert.Empty((await blocked.GetSnapshotAsync()).Effects);
1004+
Assert.Empty(fixture.GetSnapshot(blocked).Effects);
9991005
barrier.Release();
10001006
Assert.Equal("blocked", Assert.Single((await fixture.WaitForEffectCountAsync(blocked, 1)).Effects).Value);
10011007
}

0 commit comments

Comments
 (0)