Rate Policy

This commit is contained in:
2026-07-13 17:15:00 +03:00
parent 31bda58460
commit 4f6d2d0801
46 changed files with 3142 additions and 468 deletions
+159
View File
@@ -0,0 +1,159 @@
using Esiur.Core;
using Esiur.Data.Types;
using System.Runtime.CompilerServices;
namespace Esiur.Tests.Unit;
public class AsyncStreamReplyTests
{
[Fact]
public async Task PullStream_RequestsOneItemPerMoveNext()
{
var pulls = 0;
var stream = CreateStream<int>(StreamMode.Pull, pull: () =>
{
pulls++;
return CompletedReply();
});
stream.TriggerStreamStarted();
var enumerator = stream.GetAsyncEnumerator();
var firstMove = enumerator.MoveNextAsync().AsTask();
Assert.Equal(1, pulls);
Assert.False(firstMove.IsCompleted);
stream.TriggerChunk(17);
Assert.True(await firstMove);
Assert.Equal(17, enumerator.Current);
var secondMove = enumerator.MoveNextAsync().AsTask();
Assert.Equal(2, pulls);
stream.TriggerStreamCompleted();
Assert.False(await secondMove);
await enumerator.DisposeAsync();
}
[Fact]
public async Task DisposingStream_TerminatesRemoteExecutionOnce()
{
var terminations = 0;
var stream = CreateStream<int>(StreamMode.Pull, terminate: () =>
{
terminations++;
return CompletedReply();
});
stream.TriggerStreamStarted();
var enumerator = stream.GetAsyncEnumerator();
await enumerator.DisposeAsync();
await enumerator.DisposeAsync();
Assert.Equal(1, terminations);
Assert.True(stream.Completed);
}
[Fact]
public void LifecycleControls_AreSentThroughStreamReply()
{
var halts = 0;
var resumes = 0;
var stream = CreateStream<int>(
StreamMode.Push,
halt: () =>
{
halts++;
return CompletedReply();
},
resume: () =>
{
resumes++;
return CompletedReply();
});
stream.TriggerStreamStarted();
stream.Halt();
stream.Resume();
Assert.Equal(1, halts);
Assert.Equal(1, resumes);
Assert.Throws<InvalidOperationException>(() => stream.Pull());
}
[Fact]
public async Task InvocationContext_PullsGenericAsyncEnumerableAndCancelsIt()
{
var disposed = false;
var context = new InvocationContext(null!, 1);
context.InitializeStream(StreamMode.Pull, pausable: false);
Assert.True(context.SetAsyncEnumerable(Values(() => disposed = true)));
var first = await context.PullAsync();
var second = await context.PullAsync();
Assert.True(first.HasValue);
Assert.Equal(1, first.Value);
Assert.True(second.HasValue);
Assert.Equal(2, second.Value);
await context.TerminateAsync();
Assert.True(context.CancellationToken.IsCancellationRequested);
Assert.True(disposed);
}
[Fact]
public async Task InvocationContext_HaltAndResumeGatePushEnumeration()
{
var context = new InvocationContext(null!, 1);
context.InitializeStream(StreamMode.Push, pausable: true);
context.SetEnumerable(new[] { 3, 4 });
Assert.True(context.Halt());
var move = context.MoveNextAsync();
Assert.False(move.IsCompleted);
Assert.True(context.Resume());
var item = await move;
Assert.True(item.HasValue);
Assert.Equal(3, item.Value);
await context.EndAsync();
}
static AsyncStreamReply<T> CreateStream<T>(
StreamMode mode,
Func<AsyncReply>? pull = null,
Func<AsyncReply>? terminate = null,
Func<AsyncReply>? halt = null,
Func<AsyncReply>? resume = null)
=> new(
mode,
pull ?? CompletedReply,
terminate ?? CompletedReply,
halt ?? CompletedReply,
resume ?? CompletedReply);
static AsyncReply CompletedReply() => new((object)null!);
static async IAsyncEnumerable<int> Values(
Action onDisposed,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
try
{
yield return 1;
await Task.Yield();
cancellationToken.ThrowIfCancellationRequested();
yield return 2;
await Task.Delay(Timeout.Infinite, cancellationToken);
}
finally
{
onDisposed();
}
}
}
@@ -0,0 +1,80 @@
using Esiur.Core;
using Esiur.Data;
using Esiur.Protocol;
namespace Esiur.Tests.Unit.Integration;
[Collection("Integration")]
public class AsyncStreamIntegrationTests
{
[Fact]
public async Task AsyncEnumerable_IsPulledAcrossProtocol()
{
await using var cluster = await StartCluster().WaitAsync(TimeSpan.FromSeconds(10));
var remote = await Task.Run(async () =>
(EpResource)await cluster.Connection.Get("sys/stream"))
.WaitAsync(TimeSpan.FromSeconds(10));
var function = remote.Instance.Definition.GetFunctionDefByName(nameof(StreamResource.Numbers));
var stream = remote._InvokeStream<int>(
function.Index,
new Map<byte, object> { [0] = 4 });
var values = new List<int>();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await foreach (var value in stream.WithCancellation(timeout.Token))
values.Add(value);
Assert.Equal(new[] { 0, 1, 2, 3 }, values);
Assert.True(stream.Completed);
}
[Fact]
public async Task DisposingAsyncEnumerable_TerminatesRemoteExecution()
{
await using var cluster = await StartCluster().WaitAsync(TimeSpan.FromSeconds(10));
var remote = await Task.Run(async () =>
(EpResource)await cluster.Connection.Get("sys/stream"))
.WaitAsync(TimeSpan.FromSeconds(10));
var function = remote.Instance.Definition.GetFunctionDefByName(nameof(StreamResource.Infinite));
var stream = remote._InvokeStream<int>(function.Index, Array.Empty<object>());
var enumerator = stream.GetAsyncEnumerator();
Assert.True(await enumerator.MoveNextAsync());
Assert.Equal(0, enumerator.Current);
await enumerator.DisposeAsync();
Assert.True(stream.Completed);
}
[Fact]
public async Task PausablePushStream_HaltsAndResumesAcrossProtocol()
{
await using var cluster = await StartCluster().WaitAsync(TimeSpan.FromSeconds(10));
var remote = await Task.Run(async () =>
(EpResource)await cluster.Connection.Get("sys/stream"))
.WaitAsync(TimeSpan.FromSeconds(10));
var function = remote.Instance.Definition.GetFunctionDefByName(nameof(StreamResource.Pausable));
var stream = remote._InvokeStream<int>(function.Index, Array.Empty<object>());
var enumerator = stream.GetAsyncEnumerator();
Assert.True(await enumerator.MoveNextAsync());
Assert.Equal(0, enumerator.Current);
await stream.Halt();
var haltedMove = enumerator.MoveNextAsync().AsTask();
await Task.Delay(200);
Assert.False(haltedMove.IsCompleted);
await stream.Resume();
Assert.True(await haltedMove.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.Equal(1, enumerator.Current);
await enumerator.DisposeAsync();
}
static Task<IntegrationCluster> StartCluster()
=> IntegrationCluster.StartAsync(async warehouse =>
{
await warehouse.Put("sys/stream", new StreamResource());
});
}
@@ -0,0 +1,141 @@
using Esiur.Core;
using Esiur.Data;
using Esiur.Data.Types;
using Esiur.Protocol;
using Esiur.Resource;
using Esiur.Security.Authority;
using Esiur.Security.Permissions;
using Esiur.Security.RateLimiting;
using System.Diagnostics;
namespace Esiur.Tests.Unit.Integration;
[Collection("Integration")]
public class RateControlIntegrationTests
{
[Fact]
public async Task FunctionCalls_AreDeniedWhenPolicyIsExhausted()
{
await using var cluster = await StartCluster().WaitAsync(TimeSpan.FromSeconds(10));
var remote = await GetRemote(cluster);
var function = remote.Instance.Definition.GetFunctionDefByName(nameof(RateLimitedResource.Call));
var first = await remote._Invoke(function.Index, Array.Empty<object>());
var exception = await Assert.ThrowsAsync<AsyncException>(async () =>
await remote._Invoke(function.Index, Array.Empty<object>()));
Assert.Equal(1, Convert.ToInt32(first));
Assert.Equal(ExceptionCode.RateLimitExceeded, exception.Code);
}
[Fact]
public async Task PropertySets_AreDeniedWhenPolicyIsExhausted()
{
await using var cluster = await StartCluster().WaitAsync(TimeSpan.FromSeconds(10));
var remote = await GetRemote(cluster);
var property = remote.Instance.Definition.GetPropertyDefByName(nameof(RateLimitedResource.Value));
await remote.SetResourcePropertyAsync(property.Index, 10);
var exception = await Assert.ThrowsAsync<AsyncException>(async () =>
await remote.SetResourcePropertyAsync(property.Index, 20));
Assert.Equal(ExceptionCode.RateLimitExceeded, exception.Code);
}
[Fact]
public async Task RepeatedDenials_BlockTheConnection()
{
await using var cluster = await StartCluster(denialsBeforeBlock: 1)
.WaitAsync(TimeSpan.FromSeconds(10));
cluster.Connection.AutoReconnect = false;
var remote = await GetRemote(cluster);
var function = remote.Instance.Definition.GetFunctionDefByName(nameof(RateLimitedResource.Call));
await remote._Invoke(function.Index, Array.Empty<object>());
var exception = await Assert.ThrowsAsync<AsyncException>(async () =>
await remote._Invoke(function.Index, Array.Empty<object>()));
Assert.Equal(ExceptionCode.RateLimitExceeded, exception.Code);
await WaitUntilAsync(() => !cluster.Connection.IsConnected, TimeSpan.FromSeconds(3));
Assert.False(cluster.Connection.IsConnected);
}
[Fact]
public async Task QueuedCalls_AreDelayedAndQueueOverflowIsDenied()
{
await using var cluster = await StartCluster(
queueLimit: 1,
period: TimeSpan.FromMilliseconds(250)).WaitAsync(TimeSpan.FromSeconds(10));
var remote = await GetRemote(cluster);
var function = remote.Instance.Definition.GetFunctionDefByName(nameof(RateLimitedResource.Call));
await remote._Invoke(function.Index, Array.Empty<object>());
var stopwatch = Stopwatch.StartNew();
var queued = remote._Invoke(function.Index, Array.Empty<object>());
var exception = await Assert.ThrowsAsync<AsyncException>(async () =>
await remote._Invoke(function.Index, Array.Empty<object>()));
var result = await queued;
Assert.Equal(ExceptionCode.RateLimitExceeded, exception.Code);
Assert.Equal(2, Convert.ToInt32(result));
Assert.True(stopwatch.Elapsed >= TimeSpan.FromMilliseconds(150));
}
static Task<IntegrationCluster> StartCluster(
int denialsBeforeBlock = 10,
int queueLimit = 0,
TimeSpan? period = null)
=> IntegrationCluster.StartAsync(async warehouse =>
{
warehouse.Configuration.RateControl.DenialsBeforeConnectionBlock = denialsBeforeBlock;
warehouse.Configuration.RateControl.ConnectionBlockDelay = TimeSpan.FromMilliseconds(100);
warehouse.AddRatePolicy(new BurstRatePolicy("standard-call")
{
PermitLimit = 1,
Period = period ?? TimeSpan.FromMinutes(1),
QueueLimit = queueLimit,
});
warehouse.AddRatePolicy(new BurstRatePolicy("standard-set")
{
PermitLimit = 1,
Period = period ?? TimeSpan.FromMinutes(1),
QueueLimit = queueLimit,
});
var resource = await warehouse.Put("sys/rate", new RateLimitedResource());
resource.Instance!.Managers.Add(new AllowPropertySetPermissions());
});
static async Task<EpResource> GetRemote(IntegrationCluster cluster)
=> await Task.Run(async () =>
(EpResource)await cluster.Connection.Get("sys/rate"))
.WaitAsync(TimeSpan.FromSeconds(10));
static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition())
{
if (DateTime.UtcNow >= deadline)
throw new TimeoutException("The expected condition was not reached.");
await Task.Delay(20);
}
}
sealed class AllowPropertySetPermissions : IPermissionsManager
{
public Map<string, object> Settings { get; } = new();
public Ruling Applicable(
IResource resource,
Session session,
ActionType action,
MemberDef member,
object inquirer = null!)
=> action == ActionType.SetProperty ? Ruling.Allowed : Ruling.DontCare;
public bool Initialize(Map<string, object> settings, IResource resource) => true;
}
}
@@ -0,0 +1,23 @@
using Esiur.Resource;
using System.Threading;
namespace Esiur.Tests.Unit.Integration;
[Resource]
public partial class RateLimitedResource
{
int _callCount;
int _value;
[Export]
[RateControl("standard-call")]
public int Call() => Interlocked.Increment(ref _callCount);
[Export]
[RateControl("standard-set")]
public int Value
{
get => _value;
set => _value = value;
}
}
+53
View File
@@ -0,0 +1,53 @@
using Esiur.Core;
using Esiur.Data.Types;
using Esiur.Resource;
namespace Esiur.Tests.Unit.Integration;
[Resource]
public partial class StreamResource
{
[Export]
public async IAsyncEnumerable<int> Numbers(int count, InvocationContext context)
{
for (var i = 0; i < count; i++)
{
await Task.Delay(5, context.CancellationToken);
yield return i;
}
}
[Export]
public async IAsyncEnumerable<int> Infinite(InvocationContext context)
{
var value = 0;
while (true)
{
await Task.Delay(5, context.CancellationToken);
yield return value++;
}
}
[Export]
[Stream(StreamMode.Push, Pausable = true)]
public AsyncReply<int> Pausable(InvocationContext context)
{
var reply = new AsyncReply<int>();
Task.Run(async () =>
{
await Task.Delay(100);
for (var i = 0; i < 3; i++)
{
await context.WaitWhileHaltedAsync();
reply.TriggerChunk(i);
await Task.Delay(100);
}
reply.Trigger(0);
});
return reply;
}
}
+115
View File
@@ -0,0 +1,115 @@
using Esiur.Data.Types;
using Esiur.Protocol;
using Esiur.Resource;
using Esiur.Security.Authority;
using Esiur.Security.Permissions;
using Esiur.Security.RateLimiting;
using System.Reflection;
namespace Esiur.Tests.Unit;
public class RatePolicyTests
{
[Fact]
public void RateControlAttribute_IsCapturedForFunctionsAndProperties()
{
var warehouse = new Warehouse();
var method = typeof(RateFixture).GetMethod(nameof(RateFixture.Call))!;
var property = typeof(RateFixture).GetProperty(nameof(RateFixture.Value))!;
var functionDefinition = FunctionDef.MakeFunctionDef(
warehouse, typeof(RateFixture), method, 0, method.Name, new TypeDef());
var propertyDefinition = PropertyDef.MakePropertyDef(
warehouse, typeof(RateFixture), property, property.Name, 0, new TypeDef());
Assert.Equal("standard-call", functionDefinition.RatePolicyName);
Assert.Equal("standard-set", propertyDefinition.RatePolicyName);
}
[Fact]
public void Warehouse_RegistersPoliciesByTheirConfiguredName()
{
var warehouse = new Warehouse();
var policy = new DenyPolicy { Name = "deny" };
warehouse.AddRatePolicy(policy);
Assert.Same(policy, warehouse.TryGetRatePolicy("deny"));
Assert.Throws<InvalidOperationException>(() => warehouse.AddRatePolicy(policy));
Assert.True(warehouse.RemoveRatePolicy("deny"));
Assert.Null(warehouse.TryGetRatePolicy("deny"));
}
[Fact]
public void ContextFreePolicies_AreSupported()
{
var policy = new DenyPolicy();
Assert.Equal(Ruling.Denied, policy.Applicable(CreateContext()));
}
[Fact]
public void BurstPolicy_AllowsBurstQueuesOverflowAndThenDenies()
{
var policy = new BurstRatePolicy("standard-call")
{
PermitLimit = 1,
Period = TimeSpan.FromSeconds(1),
BurstLimit = 1,
QueueLimit = 1,
};
var immediateOne = CreateContext();
var immediateTwo = CreateContext(immediateOne.Connection);
var queued = CreateContext(immediateOne.Connection);
var denied = CreateContext(immediateOne.Connection);
Assert.Equal(Ruling.Allowed, policy.Applicable(immediateOne));
Assert.Equal(Ruling.Allowed, policy.Applicable(immediateTwo));
Assert.Equal(Ruling.Allowed, policy.Applicable(queued));
Assert.True(queued.Delay > TimeSpan.Zero);
Assert.Equal(Ruling.Denied, policy.Applicable(denied));
}
[Fact]
public void WarehouseConfiguration_IsIsolatedPerWarehouse()
{
var configured = new Warehouse(new WarehouseConfiguration
{
RateControl = new RateControlConfiguration
{
DenialsBeforeConnectionBlock = 2,
DenialWindow = TimeSpan.FromSeconds(10),
ConnectionBlockDelay = TimeSpan.Zero,
},
});
var defaults = new Warehouse();
Assert.Equal(2, configured.Configuration.RateControl.DenialsBeforeConnectionBlock);
Assert.Equal(5, defaults.Configuration.RateControl.DenialsBeforeConnectionBlock);
}
static RateControlContext CreateContext(EpConnection? connection = null)
=> new(
new Warehouse(),
connection ?? new EpConnection(),
new Session(),
null,
new FunctionDef { Name = "Call" },
ActionType.Execute);
sealed class DenyPolicy : RatePolicy
{
public override Ruling Applicable() => Ruling.Denied;
}
sealed class RateFixture
{
[RateControl("standard-call")]
public void Call()
{
}
[RateControl("standard-set")]
public int Value { get; set; }
}
}
+177
View File
@@ -0,0 +1,177 @@
using Esiur.Core;
using Esiur.Data;
using Esiur.Data.Types;
using Esiur.Resource;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
namespace Esiur.Tests.Unit;
public class TypeDefAttributeTests
{
[Fact]
public void FunctionStreamMode_IsInferredOrReadFromAttribute()
{
var pull = MakeFunction(nameof(StreamFixture.Pull));
var push = MakeFunction(nameof(StreamFixture.Push));
var explicitStream = MakeFunction(nameof(StreamFixture.Explicit));
Assert.Equal(StreamMode.Pull, pull.StreamMode);
Assert.Equal(TruIdentifier.Int32, pull.ReturnType.Identifier);
Assert.Equal(StreamMode.Push, push.StreamMode);
Assert.Equal(TruIdentifier.Int32, push.ReturnType.Identifier);
Assert.Equal(StreamMode.Push, explicitStream.StreamMode);
Assert.True(explicitStream.Pausable);
}
[Theory]
[InlineData(nameof(StreamFixture.InvalidReturn))]
[InlineData(nameof(StreamFixture.InvalidPausablePull))]
[InlineData(nameof(StreamFixture.ConflictingMode))]
public void FunctionStreamMode_RejectsInvalidDeclarations(string methodName)
{
var exception = Assert.Throws<Exception>(() => MakeFunction(methodName));
Assert.Contains("stream", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Attributes_AreIncludedInSerializedTypeDef()
{
var warehouse = new Warehouse();
var definition = new LocalTypeDef(typeof(AttributedResource), warehouse);
var bytes = Codec.Compose(TypeDefInfo.FromTypeDef(definition), warehouse, null);
var (_, info) = Codec.ParseIndexedType<TypeDefInfo>(bytes, 0, warehouse);
Assert.Equal("type usage", info.Usage);
Assert.Equal("type description", info.Description);
Assert.Equal("type example", info.Example);
Assert.Equal("tests", info.Category);
Assert.Equal("3.1", info.Since);
Assert.Equal("type", info.Annotations!["scope"]);
var property = Assert.Single(info.Properties!, x => x.Name == "Value");
var propertyFlags = (PropertyDefFlags)property.Flags;
Assert.True(propertyFlags.HasFlag(PropertyDefFlags.ReadOnly));
Assert.True(propertyFlags.HasFlag(PropertyDefFlags.Volatile));
Assert.True(propertyFlags.HasFlag(PropertyDefFlags.Historical));
Assert.True(propertyFlags.HasFlag(PropertyDefFlags.Deprecated));
Assert.Equal(OrderingControl.LatestOnly, property.OrderingControl);
Assert.Equal(5, Convert.ToInt32(property.DefaultValue));
Assert.Equal("property description", property.Description);
Assert.Equal("property usage", property.Usage);
Assert.Equal(2, property.Examples!.Count);
Assert.Equal(new[] { "state", "sample" }, property.Tags);
Assert.Equal("units", property.Unit);
Assert.Equal(0, Convert.ToInt32(property.Minimum));
Assert.Equal(10, Convert.ToInt32(property.Maximum));
Assert.Equal(new[] { 1, 5 }, property.AllowedValues!.Select(Convert.ToInt32));
Assert.Equal("^[0-9]+$", property.Pattern);
Assert.Equal("integer", property.Format);
Assert.Equal("property warning", Assert.Single(property.Warnings!));
Assert.Equal((byte)7, Assert.Single(property.RelatedMembers!));
Assert.Equal("use NewValue", property.DeprecationMessage);
Assert.Equal("property", property.Annotations!["scope"]);
var function = Assert.Single(info.Functions!, x => x.Name == nameof(AttributedResource.Watch));
var functionFlags = (FunctionDefFlags)function.Flags;
Assert.True(functionFlags.HasFlag(FunctionDefFlags.ReadOnly));
Assert.True(functionFlags.HasFlag(FunctionDefFlags.Idempotent));
Assert.True(functionFlags.HasFlag(FunctionDefFlags.Cancellable));
Assert.True(functionFlags.HasFlag(FunctionDefFlags.Pausable));
Assert.Equal(StreamMode.Push, function.StreamMode);
Assert.Equal("ready", Assert.Single(function.Preconditions!));
Assert.Equal("complete", Assert.Single(function.Postconditions!));
Assert.Equal(OperationEffects.EmitsEvents, function.Effects);
var eventInfo = Assert.Single(info.Events!, x => x.Name == nameof(AttributedResource.Changed));
var eventFlags = (EventDefFlags)eventInfo.Flags;
Assert.True(eventFlags.HasFlag(EventDefFlags.AutoDelivered));
Assert.True(eventFlags.HasFlag(EventDefFlags.Historical));
Assert.Equal(OrderingControl.Relaxed, eventInfo.OrderingControl);
var constant = Assert.Single(info.Constants!, x => x.Name == nameof(AttributedResource.Limit));
Assert.Equal("constant description", constant.Description);
Assert.Equal("constant usage", constant.Usage);
Assert.Equal(10, Convert.ToInt32(constant.Value));
}
private static FunctionDef MakeFunction(string name)
{
var method = typeof(StreamFixture).GetMethod(name, BindingFlags.Public | BindingFlags.Instance)!;
return FunctionDef.MakeFunctionDef(
Warehouse.Default, typeof(StreamFixture), method, 0, name, new TypeDef());
}
private sealed class StreamFixture
{
public IAsyncEnumerable<int> Pull() => throw new NotSupportedException();
public IEnumerable<int> Push() => Array.Empty<int>();
[Stream(StreamMode.Push, Pausable = true)]
public AsyncReply<int> Explicit() => new(1);
[Stream]
public int InvalidReturn() => 1;
[Stream(StreamMode.Pull, Pausable = true)]
public AsyncReply<int> InvalidPausablePull() => new(1);
[Stream(StreamMode.Push)]
public IAsyncEnumerable<int> ConflictingMode() => throw new NotSupportedException();
}
[Export]
[Usage("type usage")]
[Description("type description")]
[Example("type example")]
[Category("tests")]
[Since("3.1")]
[Annotation("scope", "type")]
private sealed class AttributedResource : Esiur.Resource.Resource
{
[Description("constant description")]
[Usage("constant usage")]
[Example(10)]
[Annotation("scope", "constant")]
public const int Limit = 10;
[ReadOnly]
[Volatile]
[Historical]
[Ordering(OrderingControl.LatestOnly)]
[System.ComponentModel.DefaultValue(5)]
[Description("property description")]
[Usage("property usage")]
[Example(1)]
[Example(5)]
[Tags("state", "sample")]
[Unit("units")]
[Minimum(0)]
[Maximum(10)]
[AllowedValue(1)]
[AllowedValue(5)]
[Pattern("^[0-9]+$")]
[Format("integer")]
[Warning("property warning")]
[RelatedMembers(7)]
[Obsolete("use NewValue")]
[Annotation("scope", "property")]
public int Value { get; set; }
[Stream(StreamMode.Push, Pausable = true)]
[ReadOnly]
[Idempotent]
[Cancellable]
[Precondition("ready")]
[Postcondition("complete")]
[Effects(OperationEffects.EmitsEvents)]
public AsyncReply<int> Watch() => new(1);
[AutoDelivery]
[Historical]
[Ordering(OrderingControl.Relaxed)]
public event ResourceEventHandler<int>? Changed;
}
}