Revisions

This commit is contained in:
2026-08-09 03:54:28 +03:00
parent 2028db671f
commit 3b55d8d2f8
37 changed files with 1743 additions and 230 deletions
+1 -4
View File
@@ -14,15 +14,12 @@ internal class Program
private static async Task Main(string[] args) private static async Task Main(string[] args)
{ {
var epPort = ushort.Parse(
Environment.GetEnvironmentVariable("ESIUR_PORT")
?? throw new InvalidOperationException("Set ESIUR_PORT before starting the example."));
var wh = new Warehouse(); var wh = new Warehouse();
// Create a store to keep objects. // Create a store to keep objects.
var system = await wh.Put("sys", new MemoryStore()); var system = await wh.Put("sys", new MemoryStore());
// Create a distibuted server // Create a distibuted server
var esiurServer = await wh.Put("sys/server", new EpServer() { Port = epPort }); var esiurServer = await wh.Put("sys/server", new EpServer());
// Add your object to the store // Add your object to the store
var service = await wh.Put("sys/demo", new Demo()); var service = await wh.Put("sys/demo", new Demo());
+2 -1
View File
@@ -51,5 +51,6 @@ public enum ExceptionCode : ushort
ParserLimitExceeded, ParserLimitExceeded,
AttachmentLimitExceeded, AttachmentLimitExceeded,
AlreadyAttached, AlreadyAttached,
ConnectionLimitExceeded ConnectionLimitExceeded,
CursorExpired
} }
+2 -2
View File
@@ -228,13 +228,13 @@ public static class Codec
asyncReply.Then(value => asyncReply.Then(value =>
{ {
rt.Trigger(new ParseResult<object>(value, (uint)tdu.TotalLength)); rt.Trigger(new ParseResult<object>(value, (uint)tdu.TotalLength));
}); }).Error(rt.TriggerError);
} }
else else
{ {
rt.Trigger(new ParseResult<object>(result, (uint)tdu.TotalLength)); rt.Trigger(new ParseResult<object>(result, (uint)tdu.TotalLength));
} }
}); }).Error(rt.TriggerError);
return rt; return rt;
+25
View File
@@ -1,5 +1,6 @@
using Esiur.Core; using Esiur.Core;
using Esiur.Data.Types; using Esiur.Data.Types;
using Esiur.Protocol;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Text; using System.Text;
@@ -17,4 +18,28 @@ namespace Esiur.Data
public TypeDef ResourceDefinition { get; } public TypeDef ResourceDefinition { get; }
} }
/// <summary>
/// Optional server-side invocation hook for local resources whose type
/// definition is assembled at runtime. Remote <see cref="EpResource"/>
/// proxies remain unchanged; this hook gives their local counterpart the
/// same dynamic function semantics.
/// </summary>
public interface IDynamicResourceFunctionHandler
{
public AsyncReply InvokeResourceFunctionAsync(
byte index,
object arguments,
InvocationContext context);
}
public delegate void DynamicResourceEventHandler(byte index, object value);
/// <summary>
/// Optional event bridge for a local runtime-defined resource.
/// </summary>
public interface IDynamicResourceEventSource
{
public event DynamicResourceEventHandler ResourceEventOccurred;
}
} }
+39
View File
@@ -5,6 +5,7 @@ using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
namespace Esiur.Data; namespace Esiur.Data;
@@ -55,6 +56,9 @@ public static class RuntimeCaster
Func<object?, RuntimeCastOptions, object?>> _elemConvCache = Func<object?, RuntimeCastOptions, object?>> _elemConvCache =
new ConcurrentDictionary<(Type, Type), Func<object?, RuntimeCastOptions, object?>>(); new ConcurrentDictionary<(Type, Type), Func<object?, RuntimeCastOptions, object?>>();
private static readonly ConcurrentDictionary<Type, PropertyInfo[]> _recordPropertyCache =
new ConcurrentDictionary<Type, PropertyInfo[]>();
// --------- Zero-allocation convenience overloads --------- // --------- Zero-allocation convenience overloads ---------
public static object? Cast(object? value, Type toType) public static object? Cast(object? value, Type toType)
=> Cast(value, toType, RuntimeCastOptions.Default); => Cast(value, toType, RuntimeCastOptions.Default);
@@ -149,6 +153,13 @@ public static class RuntimeCaster
var toUnderlying = Nullable.GetUnderlyingType(toType) ?? toType; var toUnderlying = Nullable.GetUnderlyingType(toType) ?? toType;
var fromUnderlying = Nullable.GetUnderlyingType(fromType) ?? fromType; var fromUnderlying = Nullable.GetUnderlyingType(fromType) ?? fromType;
// A peer without a pre-generated CLR proxy represents a remote typed
// record as Record. Materialize it into the function's declared local
// IRecord type so dynamic resources retain their strongly typed
// invocation contract across the wire.
if (value is Record record && typeof(IRecord).IsAssignableFrom(toUnderlying))
return ConvertRecord(record, toUnderlying, opts);
// Collections early // Collections early
{ {
bool handled; bool handled;
@@ -233,6 +244,34 @@ public static class RuntimeCaster
} }
} }
private static object ConvertRecord(Record record, Type targetType, RuntimeCastOptions opts)
{
if (targetType.IsAbstract || targetType.IsInterface)
throw new InvalidCastException("Cannot materialize a record as " + targetType + ".");
var target = Activator.CreateInstance(targetType)
?? throw new InvalidCastException("Cannot create " + targetType + ".");
var properties = _recordPropertyCache.GetOrAdd(
targetType,
static type => type.GetProperties(BindingFlags.Instance | BindingFlags.Public)
.Where(property =>
property.CanWrite &&
property.SetMethod?.IsPublic == true &&
property.GetIndexParameters().Length == 0)
.ToArray());
foreach (var property in properties)
{
if (!record.ContainsKey(property.Name))
continue;
var converted = Cast(record[property.Name], property.PropertyType, opts);
property.SetValue(target, converted);
}
return target;
}
private static object? ConvertCollectionsIfAny(object value, Type fromType, Type toType, RuntimeCastOptions opts, out bool handled) private static object? ConvertCollectionsIfAny(object value, Type fromType, Type toType, RuntimeCastOptions opts, out bool handled)
{ {
handled = false; handled = false;
+6 -1
View File
@@ -189,7 +189,12 @@ public class RemoteTypeDef:TypeDef
definition._content = data.Clip(offset, contentLength); definition._content = data.Clip(offset, contentLength);
definition._typeId = info.Id; definition._typeId = info.Id;
definition._parentTypeId = info.Parent; definition._parentTypeId = info.Parent;
definition._typeName = string.IsNullOrEmpty(info.Namespace) // Older TypeDefInfo producers put the fully-qualified name in Name
// while also filling Namespace. Avoid duplicating the namespace, but
// continue accepting producers that send a simple type name.
definition._typeName = string.IsNullOrEmpty(info.Namespace) ||
info.Name.StartsWith(info.Namespace + ".", StringComparison.Ordinal) ||
info.Name.StartsWith(info.Namespace + "+", StringComparison.Ordinal)
? info.Name ? info.Name
: $"{info.Namespace}.{info.Name}"; : $"{info.Namespace}.{info.Name}";
definition._typeDefKind = info.Kind; definition._typeDefKind = info.Kind;
+1 -1
View File
@@ -5,7 +5,7 @@
<Copyright>Ahmed Kh. Zamil</Copyright> <Copyright>Ahmed Kh. Zamil</Copyright>
<PackageProjectUrl>https://www.esiur.com</PackageProjectUrl> <PackageProjectUrl>https://www.esiur.com</PackageProjectUrl>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild> <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<Version>3.0.1</Version> <Version>3.1.0</Version>
<RepositoryUrl>https://github.com/esiur/esiur-dotnet</RepositoryUrl> <RepositoryUrl>https://github.com/esiur/esiur-dotnet</RepositoryUrl>
<Authors>Ahmed Kh. Zamil</Authors> <Authors>Ahmed Kh. Zamil</Authors>
<AssemblyVersion></AssemblyVersion> <AssemblyVersion></AssemblyVersion>
@@ -11,6 +11,7 @@ namespace Esiur.Net.Packets
SetProperty = 0x1, SetProperty = 0x1,
Subscribe = 0x2, Subscribe = 0x2,
Unsubscribe = 0x3, Unsubscribe = 0x3,
QueryResourceJournal = 0x4,
// Request Inquire // Request Inquire
TypeDefIdsByNames = 0x8, TypeDefIdsByNames = 0x8,
+38 -11
View File
@@ -135,7 +135,7 @@ public partial class EpConnection : NetworkConnection, IStore
long _authenticationAttemptGeneration; long _authenticationAttemptGeneration;
string _hostname; string _hostname;
ushort _port; ushort _port = EpProtocol.DefaultPort;
bool _initialPacket = true; bool _initialPacket = true;
AuthenticationDirection _authDirection = AuthenticationDirection.Responder; AuthenticationDirection _authDirection = AuthenticationDirection.Responder;
@@ -456,6 +456,12 @@ public partial class EpConnection : NetworkConnection, IStore
var headers = _session.LocalHeaders.Copy(); var headers = _session.LocalHeaders.Copy();
// Anonymous sessions still exchange typed records. They therefore
// need a non-empty schema namespace just like authenticated sessions;
// otherwise the responder cannot cache a fetched RemoteTypeDef.
if (string.IsNullOrWhiteSpace(headers.Domain))
headers.Domain = _remoteDomain ?? _hostname ?? "anonymous";
if (_session.AuthenticationMode != AuthenticationMode.None) if (_session.AuthenticationMode != AuthenticationMode.None)
{ {
if (_session.AuthenticationHandler == null) if (_session.AuthenticationHandler == null)
@@ -1215,9 +1221,9 @@ public partial class EpConnection : NetworkConnection, IStore
_queue.Then((x) => _queue.Then((x) =>
{ {
if (x.Type == EpResourceQueueItem.DistributedResourceQueueItemType.Event) if (x.Type == EpResourceQueueItem.DistributedResourceQueueItemType.Event)
x.Resource._EmitEventByIndex(x.Index, x.Value); x.Resource._EmitEventByIndex(x.Index, x.Value, x.Cursor, x.RecordedAt);
else else
x.Resource._UpdatePropertyByIndex(x.Index, x.Value); x.Resource._UpdatePropertyByIndex(x.Index, x.Value, x.Cursor, x.RecordedAt);
}).Error(e => }).Error(e =>
{ {
// do nothing // do nothing
@@ -1377,6 +1383,9 @@ public partial class EpConnection : NetworkConnection, IStore
case EpPacketRequest.Unsubscribe: case EpPacketRequest.Unsubscribe:
EpRequestUnsubscribe(_packet.CallbackId, dt); EpRequestUnsubscribe(_packet.CallbackId, dt);
break; break;
case EpPacketRequest.QueryResourceJournal:
EpRequestQueryResourceJournal(_packet.CallbackId, dt);
break;
// Inquire // Inquire
case EpPacketRequest.TypeDefIdsByNames: case EpPacketRequest.TypeDefIdsByNames:
EpRequestTypeDefIdsByNames(_packet.CallbackId, dt); EpRequestTypeDefIdsByNames(_packet.CallbackId, dt);
@@ -1635,6 +1644,14 @@ public partial class EpConnection : NetworkConnection, IStore
} }
_session.RemoteHeaders = remoteHeaders; _session.RemoteHeaders = remoteHeaders;
// Responder-side connections do not pass through the
// outbound EpConnectionContext path that initializes
// _remoteDomain. Keep the peer schema namespace from the
// authentication headers so typed records sent from the
// initiator can register and resolve their TypeDefs.
_remoteDomain = string.IsNullOrWhiteSpace(remoteHeaders.Domain)
? $"anonymous:{RemoteEndPoint?.Address}"
: remoteHeaders.Domain;
_session.AuthenticationMode = _authPacket.AuthMode; _session.AuthenticationMode = _authPacket.AuthMode;
var localHeaders = _session.LocalHeaders.Copy(); var localHeaders = _session.LocalHeaders.Copy();
@@ -3144,15 +3161,11 @@ public partial class EpConnection : NetworkConnection, IStore
if (!Uri.TryCreate($"ep://{Instance.Name}", UriKind.Absolute, out var endpoint) if (!Uri.TryCreate($"ep://{Instance.Name}", UriKind.Absolute, out var endpoint)
|| string.IsNullOrWhiteSpace(endpoint.Host) || string.IsNullOrWhiteSpace(endpoint.Host))
|| endpoint.Port <= 0 throw new FormatException("The EP endpoint is invalid.");
|| endpoint.Port > ushort.MaxValue)
throw new FormatException(
"EP endpoints must include an explicit port (for example, ep://host:port)."
);
var address = endpoint.Host; var address = endpoint.Host;
var port = checked((ushort)endpoint.Port); var port = ResolveEndpointPort(endpoint);
// assign domain from hostname if not provided // assign domain from hostname if not provided
if (context is EpConnectionContext epContext) if (context is EpConnectionContext epContext)
@@ -3198,6 +3211,20 @@ public partial class EpConnection : NetworkConnection, IStore
return new AsyncReply<bool>(true); return new AsyncReply<bool>(true);
} }
internal static ushort ResolveEndpointPort(Uri endpoint)
{
if (endpoint == null)
throw new ArgumentNullException(nameof(endpoint));
if (endpoint.Port < 0)
return EpProtocol.DefaultPort;
if (endpoint.Port == 0 || endpoint.Port > ushort.MaxValue)
throw new FormatException("The EP endpoint port must be from 1 through 65535.");
return checked((ushort)endpoint.Port);
}
@@ -3568,7 +3595,7 @@ public partial class EpConnection : NetworkConnection, IStore
// Reattach using the last-known age so only properties modified while // Reattach using the last-known age so only properties modified while
// disconnected are transferred and merged, instead of re-fetching all. // disconnected are transferred and merged, instead of re-fetching all.
await Reattach(r.ResourceLink, r.Instance.Age, r); await Reattach(r.ResourceLink, r.Instance.Cursor, r);
Global.Log("EpConnection", LogType.Debug, "Restored " + r.ResourceInstanceId); Global.Log("EpConnection", LogType.Debug, "Restored " + r.ResourceInstanceId);
+384 -61
View File
@@ -530,7 +530,17 @@ partial class EpConnection
internal AsyncReply SendSubscribeRequest(uint instanceId, byte index) internal AsyncReply SendSubscribeRequest(uint instanceId, byte index)
{ {
return SendRequest(EpPacketRequest.Subscribe, instanceId, index); return SendSubscribeRequest(instanceId, index, new ResourceCursor(Guid.Empty, 0));
}
internal AsyncReply SendSubscribeRequest(uint instanceId, byte index, ResourceCursor after)
{
return SendRequest(
EpPacketRequest.Subscribe,
instanceId,
index,
after.Generation.ToByteArray(),
after.Revision);
} }
internal AsyncReply SendUnsubscribeRequest(uint instanceId, byte index) internal AsyncReply SendUnsubscribeRequest(uint instanceId, byte index)
@@ -877,12 +887,14 @@ partial class EpConnection
void EpNotificationPropertyModified(PlainTdu tdu) void EpNotificationPropertyModified(PlainTdu tdu)
{ {
// resourceId, index, value // resourceId, generation, revision, recordedAt, index, value
var (valueOffset, valueSize, args) = var (valueOffset, valueSize, args) =
DataDeserializer.LimitedCountListParser(tdu.Data, tdu.PayloadOffset, tdu.PayloadLength, Instance.Warehouse, 2); DataDeserializer.LimitedCountListParser(tdu.Data, tdu.PayloadOffset, tdu.PayloadLength, Instance.Warehouse, 5);
var rid = Convert.ToUInt32(args[0]); var rid = Convert.ToUInt32(args[0]);
var index = (byte)args[1]; var cursor = new ResourceCursor(new Guid((byte[])args[1]), Convert.ToUInt64(args[2]));
var recordedAt = ((DateTime)args[3]).ToUniversalTime();
var index = (byte)args[4];
FetchResource(rid, null).Then(r => FetchResource(rid, null).Then(r =>
{ {
@@ -902,14 +914,14 @@ partial class EpConnection
{ {
item.Trigger(new EpResourceQueueItem((EpResource)r, item.Trigger(new EpResourceQueueItem((EpResource)r,
EpResourceQueueItem.DistributedResourceQueueItemType.Propery, EpResourceQueueItem.DistributedResourceQueueItemType.Propery,
result, index)); result, index, cursor, recordedAt));
}); });
} }
else else
{ {
_queue.Add(new AsyncReply<EpResourceQueueItem>(new EpResourceQueueItem((EpResource)r, _queue.Add(new AsyncReply<EpResourceQueueItem>(new EpResourceQueueItem((EpResource)r,
EpResourceQueueItem.DistributedResourceQueueItemType.Propery, EpResourceQueueItem.DistributedResourceQueueItemType.Propery,
value, index)), hasResource: false); value, index, cursor, recordedAt)), hasResource: false);
} }
} }
@@ -937,13 +949,15 @@ partial class EpConnection
void EpNotificationEventOccurred(PlainTdu tdu) void EpNotificationEventOccurred(PlainTdu tdu)
{ {
// resourceId, index, value // resourceId, generation, revision, recordedAt, index, value
var (valueOffset, valueSize, args) = var (valueOffset, valueSize, args) =
DataDeserializer.LimitedCountListParser(tdu.Data, tdu.PayloadOffset, DataDeserializer.LimitedCountListParser(tdu.Data, tdu.PayloadOffset,
tdu.PayloadLength, Instance.Warehouse, 2); tdu.PayloadLength, Instance.Warehouse, 5);
var resourceId = Convert.ToUInt32(args[0]); var resourceId = Convert.ToUInt32(args[0]);
var index = (byte)args[1]; var cursor = new ResourceCursor(new Guid((byte[])args[1]), Convert.ToUInt64(args[2]));
var recordedAt = ((DateTime)args[3]).ToUniversalTime();
var index = (byte)args[4];
FetchResource(resourceId, null).Then(r => FetchResource(resourceId, null).Then(r =>
{ {
@@ -964,13 +978,15 @@ partial class EpConnection
asyncReply.Then((result) => asyncReply.Then((result) =>
{ {
item.Trigger(new EpResourceQueueItem((EpResource)r, item.Trigger(new EpResourceQueueItem((EpResource)r,
EpResourceQueueItem.DistributedResourceQueueItemType.Event, result, index)); EpResourceQueueItem.DistributedResourceQueueItemType.Event,
result, index, cursor, recordedAt));
}); });
} }
else else
{ {
item.Trigger(new EpResourceQueueItem((EpResource)r, item.Trigger(new EpResourceQueueItem((EpResource)r,
EpResourceQueueItem.DistributedResourceQueueItemType.Event, pr.Value, index)); EpResourceQueueItem.DistributedResourceQueueItemType.Event,
pr.Value, index, cursor, recordedAt));
} }
}).Error((ex) => throw ex); }).Error((ex) => throw ex);
@@ -1019,19 +1035,22 @@ partial class EpConnection
var r = res as IResource; var r = res as IResource;
// unsubscribe r.Instance.SynchronizeJournal(head =>
{
// Register live delivery before capturing/sending the
// snapshot. Emission shares the journal lock, so no
// revision can fall between snapshot and subscription.
Unsubscribe(r); Unsubscribe(r);
Subscribe(r);
// reply ok
SendReply(EpPacketReply.Completed, callback, SendReply(EpPacketReply.Completed, callback,
r.Instance.Definition.Id, r.Instance.Definition.Id,
r.Instance.Age, head.Generation.ToByteArray(),
head.Revision,
r.Instance.Link, r.Instance.Link,
r.Instance.Hops, r.Instance.Hops,
r.Instance.Serialize()); r.Instance.Serialize());
return true;
// subscribe });
Subscribe(r);
} }
else else
{ {
@@ -1056,12 +1075,14 @@ partial class EpConnection
// resourceLinkOrId (string link, or uint id — a link is resolved by // resourceLinkOrId (string link, or uint id — a link is resolved by
// query since a remote node's instance id is not permanent: the // query since a remote node's instance id is not permanent: the
// resource may have been cleared from memory and recreated under a // resource may have been cleared from memory and recreated under a
// different id while the peer was disconnected), age // different id while the peer was disconnected), generation, revision
var (valueOffset, valueSize, args) = var (valueOffset, valueSize, args) =
DataDeserializer.LimitedCountListParser(tdu.Data, tdu.PayloadOffset, DataDeserializer.LimitedCountListParser(tdu.Data, tdu.PayloadOffset,
tdu.PayloadLength, Instance.Warehouse, 2); tdu.PayloadLength, Instance.Warehouse, 3);
var age = Convert.ToUInt64(args[1]); var requestedCursor = new ResourceCursor(
new Guid((byte[])args[1]),
Convert.ToUInt64(args[2]));
void Resolved(IResource res) void Resolved(IResource res)
{ {
@@ -1094,22 +1115,28 @@ partial class EpConnection
var r = res; var r = res;
// unsubscribe r.Instance.SynchronizeJournal(head =>
{
var reset = requestedCursor.Generation != head.Generation;
Unsubscribe(r); Unsubscribe(r);
Subscribe(r);
// reply ok — the resolved id comes first so the caller can // The resolved id comes first so the caller can detect and
// detect and apply an id change (link-based reattach, or the // apply an id change. A generation change returns a full
// remote node recreated the resource with a new id). // property snapshot because the old cursor is unrelated.
SendReply(EpPacketReply.Completed, callback, SendReply(EpPacketReply.Completed, callback,
r.Instance.Id, r.Instance.Id,
r.Instance.Definition.Id, r.Instance.Definition.Id,
r.Instance.Age, head.Generation.ToByteArray(),
head.Revision,
r.Instance.Link, r.Instance.Link,
r.Instance.Hops, r.Instance.Hops,
r.Instance.SerializeAfter(age)); reset,
reset
// subscribe ? r.Instance.SerializeMap()
Subscribe(r); : r.Instance.SerializeAfter(requestedCursor.Revision));
return true;
});
} }
finally finally
{ {
@@ -1427,7 +1454,10 @@ partial class EpConnection
var value = Codec.ParseSync(tdu, Instance.Warehouse); var value = Codec.ParseSync(tdu, Instance.Warehouse);
var typeId = Convert.ToUInt32(value); // TypeDef identifiers are 64-bit stable hashes. Generated definitions
// often happened to fit in UInt32, which hid this truncation until a
// dynamic resource published a full-width identifier.
var typeId = Convert.ToUInt64(value);
var t = Instance.Warehouse.GetLocalTypeDefById(typeId); var t = Instance.Warehouse.GetLocalTypeDefById(typeId);
@@ -1665,7 +1695,7 @@ partial class EpConnection
var (offset, length, args) = DataDeserializer.LimitedCountListParser(tdu.Data, tdu.PayloadOffset, var (offset, length, args) = DataDeserializer.LimitedCountListParser(tdu.Data, tdu.PayloadOffset,
tdu.PayloadLength, Instance.Warehouse, 2); tdu.PayloadLength, Instance.Warehouse, 2);
var typeId = Convert.ToUInt32(args[0]); var typeId = Convert.ToUInt64(args[0]);
var index = (byte)args[1]; var index = (byte)args[1];
var typeDef = Instance.Warehouse.GetLocalTypeDefById(typeId); var typeDef = Instance.Warehouse.GetLocalTypeDefById(typeId);
@@ -1814,6 +1844,10 @@ partial class EpConnection
} }
}); });
} }
else if (r is IDynamicResourceFunctionHandler dynamicHandler)
{
InvokeDynamicFunction(dynamicHandler, r, ft, callback, result, managerDelay);
}
else else
{ {
InvokeFunction(ft, callback, result, EpPacketRequest.InvokeFunction, managerDelay, r); InvokeFunction(ft, callback, result, EpPacketRequest.InvokeFunction, managerDelay, r);
@@ -1846,13 +1880,61 @@ partial class EpConnection
} }
}); });
} }
else if (r is IDynamicResourceFunctionHandler dynamicHandler)
{
InvokeDynamicFunction(dynamicHandler, r, ft, callback, pr.Value, managerDelay);
}
else else
{ {
InvokeFunction(ft, callback, pr.Value, EpPacketRequest.InvokeFunction, managerDelay, r); InvokeFunction(ft, callback, pr.Value, EpPacketRequest.InvokeFunction, managerDelay, r);
} }
} }
}).Error(x => SendError(ErrorType.Management, callback, (ushort)ExceptionCode.ParseError)); ; }).Error(x =>
}).Error(x => SendError(ErrorType.Management, callback, (ushort)ExceptionCode.ParseError)); ; {
var summary = SummerizeException(x);
SendError(ErrorType.Management, callback, (ushort)ExceptionCode.ParseError, summary.Item2);
});
}).Error(x =>
{
var summary = SummerizeException(x);
SendError(ErrorType.Management, callback, (ushort)ExceptionCode.ParseError, summary.Item2);
});
}
void InvokeDynamicFunction(
IDynamicResourceFunctionHandler handler,
IResource resource,
FunctionDef function,
uint callback,
object arguments,
TimeSpan managerDelay)
{
ExecuteRateControlled(callback, managerDelay, () =>
{
var context = new InvocationContext(this, callback);
context.BindOperation(resource, function);
try
{
var reply = handler.InvokeResourceFunctionAsync(function.Index, arguments, context);
if (reply == null)
{
SendError(ErrorType.Management, callback, (ushort)ExceptionCode.MethodNotFound);
return;
}
reply.Then(result => SendReply(EpPacketReply.Completed, callback, result))
.Error(exception =>
{
var (code, message) = SummerizeException(exception);
SendError(ErrorType.Exception, callback, code, message);
});
}
catch (Exception exception)
{
var (code, message) = SummerizeException(exception);
SendError(ErrorType.Exception, callback, code, message);
}
});
} }
@@ -2389,6 +2471,9 @@ partial class EpConnection
var resourceId = Convert.ToUInt32(args[0]); var resourceId = Convert.ToUInt32(args[0]);
var index = (byte)args[1]; var index = (byte)args[1];
var requestedCursor = args.Length >= 4
? new ResourceCursor(new Guid((byte[])args[2]), Convert.ToUInt64(args[3]))
: new ResourceCursor(Guid.Empty, 0);
Instance.Warehouse.GetById(resourceId).Then((r) => Instance.Warehouse.GetById(resourceId).Then((r) =>
{ {
@@ -2418,14 +2503,13 @@ partial class EpConnection
out _)) out _))
return; return;
if (r is EpResource) if (!IsOperationAllowed(r, et, ActionType.ReceiveEvent))
{ {
(r as EpResource).Subscribe(et).Then(x => SendError(ErrorType.Management, callback, (ushort)ExceptionCode.NotAllowed);
{ return;
SendReply(EpPacketReply.Completed, callback);
}).Error(x => SendError(ErrorType.Exception, callback, (ushort)ExceptionCode.GeneralFailure));
} }
else
if (r is EpResource)
{ {
lock (_subscriptionsLock) lock (_subscriptionsLock)
{ {
@@ -2434,17 +2518,97 @@ partial class EpConnection
SendError(ErrorType.Management, callback, (ushort)ExceptionCode.NotAttached); SendError(ErrorType.Management, callback, (ushort)ExceptionCode.NotAttached);
return; return;
} }
if (_subscriptions[r].Contains(index)) if (_subscriptions[r].Contains(index))
{ {
SendError(ErrorType.Management, callback, (ushort)ExceptionCode.AlreadyListened); SendError(ErrorType.Management, callback, (ushort)ExceptionCode.AlreadyListened);
return; return;
} }
_subscriptions[r].Add(index);
}
(r as EpResource).Subscribe(et, requestedCursor).Then(x =>
{
var head = r.Instance.Cursor;
SendReply(EpPacketReply.Completed, callback,
head.Generation.ToByteArray(), head.Revision);
}).Error(x =>
{
lock (_subscriptionsLock) _subscriptions[r].Remove(index);
SendError(ErrorType.Exception, callback, (ushort)ExceptionCode.GeneralFailure);
});
}
else
{
r.Instance.SynchronizeJournal(head =>
{
lock (_subscriptionsLock)
{
if (!_subscriptions.ContainsKey(r))
{
SendError(ErrorType.Management, callback, (ushort)ExceptionCode.NotAttached);
return false;
}
if (_subscriptions[r].Contains(index))
{
SendError(ErrorType.Management, callback, (ushort)ExceptionCode.AlreadyListened);
return false;
}
_subscriptions[r].Add(index); _subscriptions[r].Add(index);
SendReply(EpPacketReply.Completed, callback);
} }
var after = requestedCursor.Generation == Guid.Empty
? head
: requestedCursor;
if (et.Historical)
{
var replayAfter = after;
while (true)
{
var page = r.Instance.QueryJournal(new ResourceJournalQuery
{
After = replayAfter,
ThroughRevision = head.Revision,
Kind = ResourceJournalEntryKind.EventOccurred,
MemberIndex = index,
Limit = 10000,
});
if (page.CursorExpired)
{
lock (_subscriptionsLock) _subscriptions[r].Remove(index);
SendError(
ErrorType.Management,
callback,
(ushort)ExceptionCode.CursorExpired,
$"The requested cursor is older than {page.OldestAvailable}.");
return false;
}
foreach (var entry in page.Entries)
SendNotification(EpPacketNotification.EventOccurred,
r.Instance.Id,
entry.Cursor.Generation.ToByteArray(),
entry.Cursor.Revision,
entry.RecordedAt,
entry.MemberIndex,
entry.Value);
if (!page.HasMore)
break;
if (page.Next == replayAfter)
throw new InvalidOperationException(
"The resource journal did not advance while replaying a page.");
replayAfter = page.Next;
}
}
SendReply(EpPacketReply.Completed, callback,
head.Generation.ToByteArray(), head.Revision);
return true;
});
} }
}); });
@@ -2491,6 +2655,9 @@ partial class EpConnection
{ {
(r as EpResource).Unsubscribe(et).Then(x => (r as EpResource).Unsubscribe(et).Then(x =>
{ {
lock (_subscriptionsLock)
if (_subscriptions.ContainsKey(r))
_subscriptions[r].Remove(index);
SendReply(EpPacketReply.Completed, callback); SendReply(EpPacketReply.Completed, callback);
}).Error(x => SendError(ErrorType.Exception, callback, (ushort)ExceptionCode.GeneralFailure)); }).Error(x => SendError(ErrorType.Exception, callback, (ushort)ExceptionCode.GeneralFailure));
} }
@@ -2518,6 +2685,141 @@ partial class EpConnection
}); });
} }
void EpRequestQueryResourceJournal(uint callback, PlainTdu tdu)
{
// resourceId, generation, afterRevision, throughRevision?, fromTime?,
// toTime?, kind?, memberIndex?, limit
var (_, _, args) = DataDeserializer.LimitedCountListParser(
tdu.Data,
tdu.PayloadOffset,
tdu.PayloadLength,
Instance.Warehouse,
9);
var resourceId = Convert.ToUInt32(args[0]);
var query = new ResourceJournalQuery
{
After = new ResourceCursor(new Guid((byte[])args[1]), Convert.ToUInt64(args[2])),
ThroughRevision = args[3] == null ? null : Convert.ToUInt64(args[3]),
FromTime = args[4] as DateTime?,
ToTime = args[5] as DateTime?,
Kind = args[6] == null
? null
: (ResourceJournalEntryKind?)Convert.ToByte(args[6]),
MemberIndex = args[7] == null ? null : (byte?)Convert.ToByte(args[7]),
Limit = Convert.ToInt32(args[8]),
};
Instance.Warehouse.GetById(resourceId).Then(resource =>
{
if (resource == null)
{
SendError(ErrorType.Management, callback, (ushort)ExceptionCode.ResourceNotFound);
return;
}
lock (_subscriptionsLock)
{
if (!_subscriptions.ContainsKey(resource))
{
SendError(ErrorType.Management, callback, (ushort)ExceptionCode.NotAttached);
return;
}
}
var page = resource.Instance.QueryJournal(query);
var permitted = page.Entries.Where(entry =>
{
MemberDef member;
ActionType action;
if (entry.Kind == ResourceJournalEntryKind.PropertyModified)
{
member = resource.Instance.Definition.GetPropertyDefByIndex(entry.MemberIndex);
action = ActionType.GetProperty;
}
else
{
member = resource.Instance.Definition.GetEventDefByIndex(entry.MemberIndex);
action = ActionType.ReceiveEvent;
}
return member != null && IsOperationAllowed(resource, member, action);
}).Select(entry => (object)new object[]
{
entry.Cursor.Generation.ToByteArray(),
entry.Cursor.Revision,
entry.RecordedAt,
(byte)entry.Kind,
entry.MemberIndex,
entry.Value,
}).ToArray();
SendReply(EpPacketReply.Completed, callback,
page.OldestAvailable.Generation.ToByteArray(),
page.OldestAvailable.Revision,
page.HighWatermark.Generation.ToByteArray(),
page.HighWatermark.Revision,
page.Next.Generation.ToByteArray(),
page.Next.Revision,
page.CursorExpired,
page.HasMore,
permitted);
}).Error(exception =>
SendError(ErrorType.Exception, callback, (ushort)ExceptionCode.GeneralFailure, exception.Message));
}
/// <summary>Queries retained resource changes without changing live subscriptions.</summary>
public AsyncReply<ResourceJournalPage> QueryResourceJournal(
uint resourceId,
ResourceJournalQuery query)
{
query ??= new ResourceJournalQuery();
var reply = new AsyncReply<ResourceJournalPage>();
SendRequest(
EpPacketRequest.QueryResourceJournal,
resourceId,
query.After.Generation.ToByteArray(),
query.After.Revision,
query.ThroughRevision,
query.FromTime,
query.ToTime,
query.Kind.HasValue ? (object)(byte)query.Kind.Value : null,
query.MemberIndex,
query.Limit).Then(result =>
{
try
{
var values = (object[])result;
var entries = ((object[])values[8]).Select(raw =>
{
var item = (object[])raw;
return new ResourceJournalEntry(
new ResourceCursor(new Guid((byte[])item[0]), Convert.ToUInt64(item[1])),
((DateTime)item[2]).ToUniversalTime(),
(ResourceJournalEntryKind)Convert.ToByte(item[3]),
Convert.ToByte(item[4]),
item[5]);
}).ToArray();
reply.Trigger(new ResourceJournalPage(
new ResourceCursor(new Guid((byte[])values[0]), Convert.ToUInt64(values[1])),
new ResourceCursor(new Guid((byte[])values[2]), Convert.ToUInt64(values[3])),
new ResourceCursor(new Guid((byte[])values[4]), Convert.ToUInt64(values[5])),
Convert.ToBoolean(values[6]),
Convert.ToBoolean(values[7]),
entries));
}
catch (Exception exception)
{
reply.TriggerError(new AsyncException(
ErrorType.Management,
(ushort)ExceptionCode.ParseError,
exception.Message));
}
}).Error(reply.TriggerError);
return reply;
}
@@ -3151,13 +3453,15 @@ partial class EpConnection
return; return;
} }
// TypeId, Age, Link, Hops, PropertyValue[] // TypeId, Generation, Revision, Link, Hops, PropertyValue[]
var args = (object[])result; var args = (object[])result;
var typeId = Convert.ToUInt32(args[0]); var typeId = Convert.ToUInt64(args[0]);
var age = Convert.ToUInt64(args[1]); var cursor = new ResourceCursor(
var link = (string)args[2]; new Guid((byte[])args[1]),
var hops = (byte)args[3]; Convert.ToUInt64(args[2]));
var pvData = (byte[])args[4]; var link = (string)args[3];
var hops = (byte)args[4];
var pvData = (byte[])args[5];
var typeDef = resource != null ? var typeDef = resource != null ?
@@ -3176,7 +3480,7 @@ partial class EpConnection
{ {
var pvs = results as PropertyValue[]; var pvs = results as PropertyValue[];
dr._Attach(pvs); dr._Attach(pvs, cursor);
// Progress signal: a resource has fully attached. Used by tests to // Progress signal: a resource has fully attached. Used by tests to
// distinguish a true deadlock (no progress while requests pend) from // distinguish a true deadlock (no progress while requests pend) from
// merely slow processing (these counters keep advancing). // merely slow processing (these counters keep advancing).
@@ -3201,9 +3505,9 @@ partial class EpConnection
if (resource == null) if (resource == null)
{ {
if (td.ProxyType != null) if (td.ProxyType != null)
resource = Activator.CreateInstance(td.ProxyType, this, id, Convert.ToUInt64(args[1]), (string)args[2]) as EpResource; resource = Activator.CreateInstance(td.ProxyType, this, id, cursor.Revision, link) as EpResource;
else else
resource = new EpResource(this, id, Convert.ToUInt64(args[1]), (string)args[2]); resource = new EpResource(this, id, cursor.Revision, link);
resource.ResourceDefinition = td; resource.ResourceDefinition = td;
typeDef = td; typeDef = td;
@@ -3226,9 +3530,9 @@ partial class EpConnection
if (resource == null) if (resource == null)
{ {
if (typeDef.ProxyType != null) if (typeDef.ProxyType != null)
resource = Activator.CreateInstance(typeDef.ProxyType, this, id, Convert.ToUInt64(args[1]), (string)args[2]) as EpResource; resource = Activator.CreateInstance(typeDef.ProxyType, this, id, cursor.Revision, link) as EpResource;
else else
resource = new EpResource(this, id, Convert.ToUInt64(args[1]), (string)args[2]); resource = new EpResource(this, id, cursor.Revision, link);
resource.ResourceDefinition = typeDef; resource.ResourceDefinition = typeDef;
@@ -3266,8 +3570,8 @@ partial class EpConnection
/// <returns>DistributedResource</returns> /// <returns>DistributedResource</returns>
/// ///
/// <summary> /// <summary>
/// Re-attaches an already-known resource after reconnection using its last-known age. The peer /// Re-attaches an already-known resource after reconnection using its last-known cursor. The peer
/// returns only the properties modified after <paramref name="age"/> (the delta), which are /// returns only the properties modified after <paramref name="cursor"/> (the delta), which are
/// merged into the existing instance instead of re-fetching everything. Falls back to a full /// merged into the existing instance instead of re-fetching everything. Falls back to a full
/// <see cref="FetchResource"/> if there is no prior state to merge into. /// <see cref="FetchResource"/> if there is no prior state to merge into.
/// </summary> /// </summary>
@@ -3277,7 +3581,10 @@ partial class EpConnection
/// under a different id while disconnected), but the link is — passing it resolves the current /// under a different id while disconnected), but the link is — passing it resolves the current
/// id and reattaches in a single round trip instead of a separate GetResourceIdByLink first. /// id and reattaches in a single round trip instead of a separate GetResourceIdByLink first.
/// </param> /// </param>
public AsyncReply<EpResource> Reattach(object resourceLinkOrId, ulong age, EpResource resource) public AsyncReply<EpResource> Reattach(
object resourceLinkOrId,
ResourceCursor cursor,
EpResource resource)
{ {
// The already-attached / already-in-flight fast paths only apply when we // The already-attached / already-in-flight fast paths only apply when we
// already know a specific id — a link's current id isn't known until the // already know a specific id — a link's current id isn't known until the
@@ -3300,7 +3607,11 @@ partial class EpConnection
var reply = new AsyncReply<EpResource>(); var reply = new AsyncReply<EpResource>();
ResourceAttachRequestCount++; ResourceAttachRequestCount++;
SendRequest(EpPacketRequest.ReattachResource, resourceLinkOrId, age).Then(result => SendRequest(
EpPacketRequest.ReattachResource,
resourceLinkOrId,
cursor.Generation.ToByteArray(),
cursor.Revision).Then(result =>
{ {
if (result == null) if (result == null)
{ {
@@ -3309,10 +3620,13 @@ partial class EpConnection
return; return;
} }
// resolvedId, typeId, age, link, hops, delta(index -> PropertyValue) // resolvedId, typeId, generation, revision, link, hops, reset, delta
var args = (object[])result; var args = (object[])result;
var resolvedId = Convert.ToUInt32(args[0]); var resolvedId = Convert.ToUInt32(args[0]);
var deltaData = (byte[])args[5]; var remoteCursor = new ResourceCursor(
new Guid((byte[])args[2]),
Convert.ToUInt64(args[3]));
var deltaData = (byte[])args[7];
var sequence = new uint[] { resolvedId }; var sequence = new uint[] { resolvedId };
var oldId = resource.ResourceInstanceId; var oldId = resource.ResourceInstanceId;
@@ -3354,7 +3668,7 @@ partial class EpConnection
// request that happens to share the reused oldId. // request that happens to share the reused oldId.
} }
if (!resource._Reattach(delta)) if (!resource._Reattach(delta, remoteCursor))
{ {
// No prior state to merge into — perform a full attach instead. // No prior state to merge into — perform a full attach instead.
_resourceRequests.Remove(resolvedId); _resourceRequests.Remove(resolvedId);
@@ -3660,6 +3974,9 @@ partial class EpConnection
{ {
SendNotification(EpPacketNotification.PropertyModified, SendNotification(EpPacketNotification.PropertyModified,
info.Resource.Instance.Id, info.Resource.Instance.Id,
info.Cursor.Generation.ToByteArray(),
info.Cursor.Revision,
info.RecordedAt,
info.PropertyDef.Index, info.PropertyDef.Index,
info.Value); info.Value);
} }
@@ -3689,6 +4006,9 @@ partial class EpConnection
// compose the packet // compose the packet
SendNotification(EpPacketNotification.EventOccurred, SendNotification(EpPacketNotification.EventOccurred,
info.Resource.Instance.Id, info.Resource.Instance.Id,
info.Cursor.Generation.ToByteArray(),
info.Cursor.Revision,
info.RecordedAt,
info.EventDef.Index, info.EventDef.Index,
info.Value); info.Value);
} }
@@ -3714,6 +4034,9 @@ partial class EpConnection
// compose the packet // compose the packet
SendNotification(EpPacketNotification.EventOccurred, SendNotification(EpPacketNotification.EventOccurred,
info.Resource.Instance.Id, info.Resource.Instance.Id,
info.Cursor.Generation.ToByteArray(),
info.Cursor.Revision,
info.RecordedAt,
info.Definition.Index, info.Definition.Index,
info.Value); info.Value);
} }
+12
View File
@@ -0,0 +1,12 @@
namespace Esiur.Protocol;
/// <summary>
/// Shared defaults for the Esiur EP protocol.
/// </summary>
public static class EpProtocol
{
/// <summary>
/// Default native EP port used when a server or endpoint does not specify one.
/// </summary>
public const ushort DefaultPort = 51018;
}
+156 -18
View File
@@ -89,8 +89,16 @@ public class EpResource : DynamicObject, IResource, INotifyPropertyChanged, IDyn
// before sending a Subscribe/Unsubscribe request, since the server errors // before sending a Subscribe/Unsubscribe request, since the server errors
// (AlreadyListened/AlreadyUnsubscribed) on a redundant one. // (AlreadyListened/AlreadyUnsubscribed) on a redundant one.
readonly HashSet<byte> _subscribedEvents = new(); readonly HashSet<byte> _subscribedEvents = new();
// Last committed occurrence for each event. The sequence belongs to the
// resource-wide journal; separate checkpoints let subscriptions filter
// unrelated members without losing replay position.
readonly Dictionary<byte, ResourceCursor> _eventCursors = new();
// Event indices with a subscription-reconciliation loop currently running. // Event indices with a subscription-reconciliation loop currently running.
readonly HashSet<byte> _reconciling = new(); readonly HashSet<byte> _reconciling = new();
// Callers of OnAsync wait here until the shared reconciliation loop has
// confirmed the server-side subscription. This prevents an invocation
// from racing the first Subscribe request on a newly attached proxy.
readonly Dictionary<byte, List<AsyncReply>> _subscriptionWaiters = new();
@@ -219,7 +227,7 @@ public class EpResource : DynamicObject, IResource, INotifyPropertyChanged, IDyn
this._age = age; this._age = age;
} }
internal bool _Attach(PropertyValue[] properties) internal bool _Attach(PropertyValue[] properties, ResourceCursor cursor)
{ {
if (_status == ResourceStatus.Attached) if (_status == ResourceStatus.Attached)
return false; return false;
@@ -235,6 +243,11 @@ public class EpResource : DynamicObject, IResource, INotifyPropertyChanged, IDyn
this._properties[i] = properties[i].Value; this._properties[i] = properties[i].Value;
} }
Instance.ObserveRemoteCursor(cursor, DateTime.MinValue);
lock (_eventCursors)
foreach (var eventDef in Instance.Definition.Events)
_eventCursors[eventDef.Index] = cursor;
// trigger holded events/property updates. // trigger holded events/property updates.
//foreach (var r in afterAttachmentTriggers) //foreach (var r in afterAttachmentTriggers)
// r.Key.Trigger(r.Value); // r.Key.Trigger(r.Value);
@@ -255,11 +268,13 @@ public class EpResource : DynamicObject, IResource, INotifyPropertyChanged, IDyn
/// prior state to merge into), in which case the caller should perform a full attach. /// prior state to merge into), in which case the caller should perform a full attach.
/// </summary> /// </summary>
/// <param name="delta">Modified properties keyed by their property index.</param> /// <param name="delta">Modified properties keyed by their property index.</param>
internal bool _Reattach(Map<byte, PropertyValue> delta) internal bool _Reattach(Map<byte, PropertyValue> delta, ResourceCursor cursor)
{ {
if (_properties == null || _events == null) if (_properties == null || _events == null)
return false; // no prior state — caller should perform a full attach instead. return false; // no prior state — caller should perform a full attach instead.
var generationChanged = Instance.Generation != cursor.Generation;
foreach (var kv in delta) foreach (var kv in delta)
{ {
var index = kv.Key; var index = kv.Key;
@@ -271,6 +286,14 @@ public class EpResource : DynamicObject, IResource, INotifyPropertyChanged, IDyn
_properties[index] = kv.Value.Value; _properties[index] = kv.Value.Value;
} }
Instance.ObserveRemoteCursor(cursor, DateTime.MinValue);
if (generationChanged)
{
lock (_eventCursors)
foreach (var eventDef in Instance.Definition.Events)
_eventCursors[eventDef.Index] = cursor;
}
_status = Resource.ResourceStatus.Attached; _status = Resource.ResourceStatus.Attached;
// A reattach can follow an unexpected disconnect + automatic // A reattach can follow an unexpected disconnect + automatic
// reconnect: the server-side subscription state keyed to the old, // reconnect: the server-side subscription state keyed to the old,
@@ -282,11 +305,23 @@ public class EpResource : DynamicObject, IResource, INotifyPropertyChanged, IDyn
} }
protected internal virtual void _EmitEventByIndex(byte index, object args) protected internal virtual void _EmitEventByIndex(
byte index,
object args,
ResourceCursor cursor,
DateTime recordedAt)
{ {
var et = Instance.Definition.GetEventDefByIndex(index); var et = Instance.Definition.GetEventDefByIndex(index);
lock (_eventCursors)
{
if (_eventCursors.TryGetValue(index, out var previous) &&
previous.Generation == cursor.Generation &&
previous.Revision >= cursor.Revision)
return;
_eventCursors[index] = cursor;
}
_events[index]?.Invoke(this, args); _events[index]?.Invoke(this, args);
Instance.EmitResourceEvent(et, args); Instance.ApplyRemoteEvent(et, args, cursor, recordedAt);
DispatchListeners(_eventListeners, index, args); DispatchListeners(_eventListeners, index, args);
} }
@@ -346,14 +381,38 @@ public class EpResource : DynamicObject, IResource, INotifyPropertyChanged, IDyn
return rt; return rt;
} }
if (!et.Subscribable) if (!et.Subscribable && !et.Historical)
{ {
var rt = new AsyncReply(); var rt = new AsyncReply();
rt.TriggerError(new AsyncException(ErrorType.Management, (ushort)ExceptionCode.NotSubscribable, "")); rt.TriggerError(new AsyncException(ErrorType.Management, (ushort)ExceptionCode.NotSubscribable, ""));
return rt; return rt;
} }
return _connection.SendSubscribeRequest(_instanceId, et.Index); ResourceCursor cursor;
lock (_eventCursors)
cursor = _eventCursors.TryGetValue(et.Index, out var known)
? known
: Instance.Cursor;
return _connection.SendSubscribeRequest(_instanceId, et.Index, cursor);
}
internal AsyncReply Subscribe(EventDef et, ResourceCursor after)
{
if (et == null)
{
var reply = new AsyncReply();
reply.TriggerError(new AsyncException(
ErrorType.Management, (ushort)ExceptionCode.MethodNotFound, ""));
return reply;
}
if (!et.Subscribable && !et.Historical)
{
var reply = new AsyncReply();
reply.TriggerError(new AsyncException(
ErrorType.Management, (ushort)ExceptionCode.NotSubscribable, ""));
return reply;
}
return _connection.SendSubscribeRequest(_instanceId, et.Index, after);
} }
public AsyncReply Subscribe(string eventName) public AsyncReply Subscribe(string eventName)
@@ -373,7 +432,7 @@ public class EpResource : DynamicObject, IResource, INotifyPropertyChanged, IDyn
return rt; return rt;
} }
if (!et.Subscribable) if (!et.Subscribable && !et.Historical)
{ {
var rt = new AsyncReply(); var rt = new AsyncReply();
rt.TriggerError(new AsyncException(ErrorType.Management, (ushort)ExceptionCode.NotSubscribable, "")); rt.TriggerError(new AsyncException(ErrorType.Management, (ushort)ExceptionCode.NotSubscribable, ""));
@@ -390,6 +449,10 @@ public class EpResource : DynamicObject, IResource, INotifyPropertyChanged, IDyn
return Unsubscribe(et); return Unsubscribe(et);
} }
/// <summary>Queries retained property changes and event occurrences.</summary>
public AsyncReply<ResourceJournalPage> QueryJournal(ResourceJournalQuery query = null) =>
_connection.QueryResourceJournal(_instanceId, query ?? new ResourceJournalQuery());
/// <summary> /// <summary>
/// Listen for a property change (<c>On(":propName", cb)</c>) or an /// Listen for a property change (<c>On(":propName", cb)</c>) or an
/// exported event (<c>On("eventName", cb)</c>). For events where the /// exported event (<c>On("eventName", cb)</c>). For events where the
@@ -401,20 +464,54 @@ public class EpResource : DynamicObject, IResource, INotifyPropertyChanged, IDyn
/// </summary> /// </summary>
public EpResource On(string name, Action<object> callback) public EpResource On(string name, Action<object> callback)
{ {
OnAsync(name, callback);
return this;
}
/// <summary>
/// Register a listener and complete only after a subscribable event is
/// active on the remote endpoint. Multiple concurrent callers share the
/// same wire-level subscription reconciliation.
/// </summary>
public AsyncReply OnAsync(string name, Action<object> callback)
{
var reply = new AsyncReply();
if (name.StartsWith(":")) if (name.StartsWith(":"))
{ {
var propertyName = name.Substring(1); var propertyName = name.Substring(1);
var pt = Instance.Definition.GetPropertyDefByName(propertyName) var pt = Instance.Definition.GetPropertyDefByName(propertyName)
?? throw new Exception($"Unknown property \"{propertyName}\"."); ?? throw new Exception($"Unknown property \"{propertyName}\".");
AddListener(_propertyListeners, pt.Index, callback); AddListener(_propertyListeners, pt.Index, callback);
return this; reply.Trigger(this);
return reply;
} }
var et = Instance.Definition.GetEventDefByName(name) var et = Instance.Definition.GetEventDefByName(name)
?? throw new Exception($"Unknown event \"{name}\"."); ?? throw new Exception($"Unknown event \"{name}\".");
AddListener(_eventListeners, et.Index, callback); AddListener(_eventListeners, et.Index, callback);
if (et.Subscribable) ReconcileSubscription(et); if (et.Subscribable || et.Historical)
return this; ReconcileSubscription(et, reply);
else
reply.Trigger(this);
return reply;
}
/// <summary>
/// Registers an event listener and replays retained occurrences newer than
/// <paramref name="after"/> before continuing with live delivery.
/// </summary>
public AsyncReply OnFromAsync(string name, ResourceCursor after, Action<object> callback)
{
var et = Instance.Definition.GetEventDefByName(name)
?? throw new Exception($"Unknown event \"{name}\".");
if (!et.Historical)
throw new InvalidOperationException($"Event `{name}` is not historical.");
lock (_eventCursors) _eventCursors[et.Index] = after;
var reply = new AsyncReply();
AddListener(_eventListeners, et.Index, callback);
ReconcileSubscription(et, reply);
return reply;
} }
/// <summary>Remove a listener registered with <see cref="On"/>.</summary> /// <summary>Remove a listener registered with <see cref="On"/>.</summary>
@@ -430,7 +527,7 @@ public class EpResource : DynamicObject, IResource, INotifyPropertyChanged, IDyn
var et = Instance.Definition.GetEventDefByName(name); var et = Instance.Definition.GetEventDefByName(name);
if (et == null) return this; if (et == null) return this;
RemoveListener(_eventListeners, et.Index, callback); RemoveListener(_eventListeners, et.Index, callback);
if (et.Subscribable) ReconcileSubscription(et); if (et.Subscribable || et.Historical) ReconcileSubscription(et);
return this; return this;
} }
@@ -486,7 +583,7 @@ public class EpResource : DynamicObject, IResource, INotifyPropertyChanged, IDyn
foreach (var et in Instance.Definition.Events) foreach (var et in Instance.Definition.Events)
{ {
if (!et.Subscribable) continue; if (!et.Subscribable && !et.Historical) continue;
var hasListeners = ListenerCount(_eventListeners, et.Index) > 0 || _events[et.Index] != null; var hasListeners = ListenerCount(_eventListeners, et.Index) > 0 || _events[et.Index] != null;
if (hasListeners) ReconcileSubscription(et); if (hasListeners) ReconcileSubscription(et);
} }
@@ -500,8 +597,21 @@ public class EpResource : DynamicObject, IResource, INotifyPropertyChanged, IDyn
/// once the in-flight request settles, rather than replaying every /// once the in-flight request settles, rather than replaying every
/// transition. /// transition.
/// </summary> /// </summary>
void ReconcileSubscription(EventDef et) void ReconcileSubscription(EventDef et, AsyncReply waiter = null)
{ {
if (waiter != null)
{
lock (_subscriptionWaiters)
{
if (!_subscriptionWaiters.TryGetValue(et.Index, out var waiters))
{
waiters = new List<AsyncReply>();
_subscriptionWaiters[et.Index] = waiters;
}
waiters.Add(waiter);
}
}
lock (_reconciling) lock (_reconciling)
{ {
if (!_reconciling.Add(et.Index)) return; if (!_reconciling.Add(et.Index)) return;
@@ -524,7 +634,7 @@ public class EpResource : DynamicObject, IResource, INotifyPropertyChanged, IDyn
if (desired == actual) if (desired == actual)
{ {
lock (_reconciling) _reconciling.Remove(et.Index); CompleteSubscriptionReconciliation(et.Index, null);
return; return;
} }
@@ -539,15 +649,37 @@ public class EpResource : DynamicObject, IResource, INotifyPropertyChanged, IDyn
// Re-check: the desired state may have changed while this request // Re-check: the desired state may have changed while this request
// was in flight (another On()/Off() call came in meanwhile). // was in flight (another On()/Off() call came in meanwhile).
StepSubscription(et); StepSubscription(et);
}).Error((_) => }).Error((error) =>
{ {
// Leave `_subscribedEvents` as-is; the next On()/Off() call that // Leave `_subscribedEvents` as-is; the next On()/Off() call that
// changes the listener count re-triggers reconciliation, so a // changes the listener count re-triggers reconciliation, so a
// transient failure here just needs another transition to retry. // transient failure here just needs another transition to retry.
lock (_reconciling) _reconciling.Remove(et.Index); CompleteSubscriptionReconciliation(et.Index, error);
}); });
} }
void CompleteSubscriptionReconciliation(byte eventIndex, AsyncException error)
{
lock (_reconciling) _reconciling.Remove(eventIndex);
AsyncReply[] waiters = null;
lock (_subscriptionWaiters)
{
if (_subscriptionWaiters.TryGetValue(eventIndex, out var pending))
{
waiters = pending.ToArray();
_subscriptionWaiters.Remove(eventIndex);
}
}
if (waiters == null) return;
foreach (var waiter in waiters)
{
if (error == null) waiter.Trigger(this);
else waiter.TriggerError(error);
}
}
public override bool TryInvokeMember(InvokeMemberBinder binder, object[] args, out object result) public override bool TryInvokeMember(InvokeMemberBinder binder, object[] args, out object result)
{ {
@@ -664,11 +796,17 @@ public class EpResource : DynamicObject, IResource, INotifyPropertyChanged, IDyn
} }
internal void _UpdatePropertyByIndex(byte index, object value) internal void _UpdatePropertyByIndex(
byte index,
object value,
ResourceCursor cursor,
DateTime recordedAt)
{ {
var pt = Instance.Definition.GetPropertyDefByIndex(index); var pt = Instance.Definition.GetPropertyDefByIndex(index);
if (!Instance.IsNewerPropertyRevision(index, cursor))
return;
_properties[index] = value; _properties[index] = value;
Instance.EmitModification(pt, value); Instance.ApplyRemotePropertyModification(pt, value, cursor, recordedAt);
DispatchListeners(_propertyListeners, index, value); DispatchListeners(_propertyListeners, index, value);
} }
@@ -27,6 +27,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using Esiur.Resource;
namespace Esiur.Protocol; namespace Esiur.Protocol;
public class EpResourceQueueItem public class EpResourceQueueItem
@@ -41,13 +42,23 @@ public class EpResourceQueueItem
byte index; byte index;
object value; object value;
EpResource resource; EpResource resource;
ResourceCursor cursor;
DateTime recordedAt;
public EpResourceQueueItem(EpResource resource, DistributedResourceQueueItemType type, object value, byte index) public EpResourceQueueItem(
EpResource resource,
DistributedResourceQueueItemType type,
object value,
byte index,
ResourceCursor cursor,
DateTime recordedAt)
{ {
this.resource = resource; this.resource = resource;
this.index = index; this.index = index;
this.type = type; this.type = type;
this.value = value; this.value = value;
this.cursor = cursor;
this.recordedAt = recordedAt;
} }
public EpResource Resource public EpResource Resource
@@ -68,4 +79,7 @@ public class EpResourceQueueItem
{ {
get { return value; } get { return value; }
} }
public ResourceCursor Cursor => cursor;
public DateTime RecordedAt => recordedAt;
} }
+21 -4
View File
@@ -44,6 +44,18 @@ namespace Esiur.Protocol;
public class EpServer : NetworkServer<EpConnection>, IResource public class EpServer : NetworkServer<EpConnection>, IResource
{ {
/// <summary>
/// Raised after an accepted connection has completed authentication and is
/// ready for bidirectional resource access. Unlike <see cref="ClientConnected"/>,
/// this never exposes a half-completed handshake to applications.
/// </summary>
public event EpConnection.ReadyEvent ConnectionReady;
/// <summary>
/// Raised when an accepted connection is removed from the server.
/// </summary>
public event Action<EpConnection> ConnectionDisconnected;
sealed class PeerAttemptWindow sealed class PeerAttemptWindow
{ {
public DateTime StartedUtc; public DateTime StartedUtc;
@@ -132,13 +144,14 @@ public class EpServer : NetworkServer<EpConnection>, IResource
} }
/// <summary> /// <summary>
/// Application-supplied native TCP port. Zero requests an ephemeral port from the OS. /// Native TCP port. Defaults to <see cref="EpProtocol.DefaultPort"/>;
/// explicitly set zero to request an ephemeral port from the OS.
/// </summary> /// </summary>
public ushort Port public ushort Port
{ {
get; get;
set; set;
} } = EpProtocol.DefaultPort;
/// <summary> /// <summary>
/// Controls whether warehouse initialization opens Esiur's native TCP listener. /// Controls whether warehouse initialization opens Esiur's native TCP listener.
@@ -246,6 +259,7 @@ public class EpServer : NetworkServer<EpConnection>, IResource
connection.ExceptionLevel = ExceptionLevel; connection.ExceptionLevel = ExceptionLevel;
connection.AuthenticationTimeout = AuthenticationTimeout; connection.AuthenticationTimeout = AuthenticationTimeout;
connection.RestartAuthenticationDeadline(); connection.RestartAuthenticationDeadline();
connection.OnReady += AcceptedConnectionReady;
base.Add(connection); base.Add(connection);
return true; return true;
} }
@@ -261,6 +275,7 @@ public class EpServer : NetworkServer<EpConnection>, IResource
{ {
try try
{ {
connection.OnReady -= AcceptedConnectionReady;
base.Remove(connection); base.Remove(connection);
} }
finally finally
@@ -269,6 +284,9 @@ public class EpServer : NetworkServer<EpConnection>, IResource
} }
} }
private void AcceptedConnectionReady(EpConnection connection)
=> ConnectionReady?.Invoke(connection);
private bool TryAdmitConnection(EpConnection connection, out string rejectionReason) private bool TryAdmitConnection(EpConnection connection, out string rejectionReason)
{ {
rejectionReason = null; rejectionReason = null;
@@ -403,8 +421,7 @@ public class EpServer : NetworkServer<EpConnection>, IResource
protected override void ClientDisconnected(EpConnection connection) protected override void ClientDisconnected(EpConnection connection)
{ {
//connection.OnReady -= ConnectionReadyEventReceiver; ConnectionDisconnected?.Invoke(connection);
//Warehouse.Remove(connection);
} }
public KeyList<string, CallInfo?> Calls { get; } = new KeyList<string, CallInfo?>(); public KeyList<string, CallInfo?> Calls { get; } = new KeyList<string, CallInfo?>();
+4 -2
View File
@@ -533,7 +533,7 @@ public static class TypeDefGenerator
if (typeDef.Events.Length > 0) if (typeDef.Events.Length > 0)
{ {
rt.AppendLine("protected override void _EmitEventByIndex(byte index, object args) {"); rt.AppendLine("protected override void _EmitEventByIndex(byte index, object args, ResourceCursor cursor, DateTime recordedAt) {");
rt.AppendLine("switch (index) {"); rt.AppendLine("switch (index) {");
var eventsList = new StringBuilder(); var eventsList = new StringBuilder();
@@ -554,7 +554,9 @@ public static class TypeDefGenerator
eventsList.AppendLine($"[Export] public event ResourceEventHandler<{etTypeName}> {e.Name};"); eventsList.AppendLine($"[Export] public event ResourceEventHandler<{etTypeName}> {e.Name};");
} }
rt.AppendLine("}}"); rt.AppendLine("}");
rt.AppendLine("base._EmitEventByIndex(index, args, cursor, recordedAt);");
rt.AppendLine("}");
rt.AppendLine(eventsList.ToString()); rt.AppendLine(eventsList.ToString());
+10 -17
View File
@@ -89,17 +89,17 @@ Now we can add our resource to the memory store using `warehouse.Put`.
await warehouse.Put("sys/hello", new HelloResource()); await warehouse.Put("sys/hello", new HelloResource());
``` ```
Add an `EpServer` to expose the resource through the Esiur EP protocol. Anonymous access is enabled here only to make the development example easy to run; production applications should configure an authentication provider. Add an `EpServer` to expose the resource through the Esiur EP protocol. The
server and portless `ep://` client URLs use port `51018` by default; set
`EpServer.Port` or include a URL port only when an application needs an
override. Anonymous access is enabled here only to make the development
example easy to run; production applications should configure an
authentication provider.
```C# ```C#
var epPort = ushort.Parse(
Environment.GetEnvironmentVariable("ESIUR_PORT")
?? throw new InvalidOperationException("Set ESIUR_PORT."));
await warehouse.Put("sys/server", new EpServer await warehouse.Put("sys/server", new EpServer
{ {
Port = epPort,
AllowUnauthorizedAccess = true, // Development only. AllowUnauthorizedAccess = true, // Development only.
}); });
``` ```
@@ -118,15 +118,11 @@ To sum up
>using Esiur.Resource; >using Esiur.Resource;
>using Esiur.Stores; >using Esiur.Stores;
> >
>var epPort = ushort.Parse(
> Environment.GetEnvironmentVariable("ESIUR_PORT")
> ?? throw new InvalidOperationException("Set ESIUR_PORT."));
>var warehouse = new Warehouse(); >var warehouse = new Warehouse();
>await warehouse.Put("sys", new MemoryStore()); >await warehouse.Put("sys", new MemoryStore());
>await warehouse.Put("sys/hello", new HelloResource()); >await warehouse.Put("sys/hello", new HelloResource());
>await warehouse.Put("sys/server", new EpServer >await warehouse.Put("sys/server", new EpServer
>{ >{
> Port = epPort,
> AllowUnauthorizedAccess = true, // Development only. > AllowUnauthorizedAccess = true, // Development only.
>}); >});
>await warehouse.Open(); >await warehouse.Open();
@@ -138,8 +134,7 @@ To access our resource remotely, we need to use it's full path including the pro
```C# ```C#
var warehouse = new Warehouse(); var warehouse = new Warehouse();
var epPort = ushort.Parse(Environment.GetEnvironmentVariable("ESIUR_PORT")!); dynamic res = await warehouse.Get<IResource>("ep://localhost/sys/hello");
dynamic res = await warehouse.Get<IResource>($"ep://localhost:{epPort}/sys/hello");
``` ```
Now we can invoke the exported functions and read/write properties; Now we can invoke the exported functions and read/write properties;
@@ -157,8 +152,7 @@ Summing up
>using Esiur.Resource; >using Esiur.Resource;
> >
>var warehouse = new Warehouse(); >var warehouse = new Warehouse();
>var epPort = ushort.Parse(Environment.GetEnvironmentVariable("ESIUR_PORT")!); >dynamic res = await warehouse.Get<IResource>("ep://localhost/sys/hello");
>dynamic res = await warehouse.Get<IResource>($"ep://localhost:{epPort}/sys/hello");
> >
>var reply = await res.SayHi("Hi, I'm calling you from dotnet"); >var reply = await res.SayHi("Hi, I'm calling you from dotnet");
> >
@@ -176,7 +170,7 @@ Esiur has a self describing feature which comes with every language it supports,
After installing the Esiur NuGet package, a new command named ***Get-Types*** is added to the Visual Studio Package Manager Console. It generates client-side classes for robust static typing. After installing the Esiur NuGet package, a new command named ***Get-Types*** is added to the Visual Studio Package Manager Console. It generates client-side classes for robust static typing.
```ps ```ps
Get-Types "ep://localhost:$env:ESIUR_PORT/sys/hello" Get-Types "ep://localhost/sys/hello"
``` ```
This will generate and add wrappers for all types needed by our resource. This will generate and add wrappers for all types needed by our resource.
@@ -184,8 +178,7 @@ This will generate and add wrappers for all types needed by our resource.
Allowing us to use Allowing us to use
```C# ```C#
var warehouse = new Warehouse(); var warehouse = new Warehouse();
var epPort = ushort.Parse(Environment.GetEnvironmentVariable("ESIUR_PORT")!); var res = await warehouse.Get<MyResource>("ep://localhost/sys/hello");
var res = await warehouse.Get<MyResource>($"ep://localhost:{epPort}/sys/hello");
var reply = await res.SayHi("Static typing is better"); var reply = await res.SayHi("Static typing is better");
Console.WriteLine(reply); Console.WriteLine(reply);
``` ```
@@ -13,15 +13,27 @@ public class CustomEventOccurredInfo
public readonly object Value; public readonly object Value;
public readonly object Issuer; public readonly object Issuer;
public readonly Func<Session, bool> Receivers; public readonly Func<Session, bool> Receivers;
public readonly ResourceCursor Cursor;
public ulong Revision => Cursor.Revision;
public readonly DateTime RecordedAt;
public string Name => EventDef.Name; public string Name => EventDef.Name;
public CustomEventOccurredInfo(IResource resource, EventDef eventDef, Func<Session, bool> receivers, object issuer, object value) public CustomEventOccurredInfo(
IResource resource,
EventDef eventDef,
Func<Session, bool> receivers,
object issuer,
object value,
ResourceCursor cursor,
DateTime recordedAt)
{ {
Resource = resource; Resource = resource;
EventDef = eventDef; EventDef = eventDef;
Receivers = receivers; Receivers = receivers;
Issuer = issuer; Issuer = issuer;
Value = value; Value = value;
Cursor = cursor;
RecordedAt = recordedAt;
} }
} }
+16 -1
View File
@@ -15,12 +15,27 @@ namespace Esiur.Resource
public readonly IResource Resource; public readonly IResource Resource;
public readonly object Value; public readonly object Value;
public readonly ResourceCursor Cursor;
public ulong Revision => Cursor.Revision;
public readonly DateTime RecordedAt;
public EventOccurredInfo(IResource resource, EventDef eventDef, object value) public EventOccurredInfo(
IResource resource,
EventDef eventDef,
object value,
ResourceCursor cursor,
DateTime recordedAt)
{ {
Resource = resource; Resource = resource;
Value = value; Value = value;
Definition = eventDef; Definition = eventDef;
Cursor = cursor;
RecordedAt = recordedAt;
}
public EventOccurredInfo(IResource resource, EventDef eventDef, object value)
: this(resource, eventDef, value, new ResourceCursor(Guid.Empty, 0), DateTime.UtcNow)
{
} }
} }
} }
+225 -21
View File
@@ -49,6 +49,8 @@ public class Instance
List<ulong?> ages = new(); List<ulong?> ages = new();
List<DateTime?> modificationDates = new(); List<DateTime?> modificationDates = new();
private ulong instanceAge; private ulong instanceAge;
private Guid streamGeneration;
private readonly object journalSync = new();
private byte hops; private byte hops;
private DateTime instanceModificationDate; private DateTime instanceModificationDate;
@@ -331,12 +333,34 @@ public class Instance
} }
/// <summary> /// <summary>
/// Age of the instance, incremented by 1 in every modification. /// Legacy alias for the resource-wide revision. It advances for every
/// property modification and event occurrence.
/// </summary> /// </summary>
public ulong Age public ulong Age
{ {
get { return instanceAge; } get { lock (journalSync) return instanceAge; }
internal set { instanceAge = value; } internal set { lock (journalSync) instanceAge = value; }
}
/// <summary>
/// Current resource revision. Unlike the legacy property-only age, this
/// advances for every property modification and event occurrence.
/// </summary>
public ulong Revision
{
get { lock (journalSync) return instanceAge; }
}
/// <summary>Generation of the current resource change stream.</summary>
public Guid Generation
{
get { lock (journalSync) return streamGeneration; }
}
/// <summary>Current replay cursor for the resource.</summary>
public ResourceCursor Cursor
{
get { lock (journalSync) return new ResourceCursor(streamGeneration, instanceAge); }
} }
/// <summary> /// <summary>
@@ -454,6 +478,16 @@ public class Instance
return props; return props;
} }
/// <summary>Exports every property keyed by its member index.</summary>
public Map<byte, PropertyValue> SerializeMap()
{
var values = Serialize();
var map = new Map<byte, PropertyValue>();
for (byte index = 0; index < values.Length; index++)
map.Add(index, values[index]);
return map;
}
/// <summary> /// <summary>
/// If True, the instance can be stored to disk. /// If True, the instance can be stored to disk.
@@ -477,24 +511,25 @@ public class Instance
IResource res; IResource res;
if (this.resource.TryGetTarget(out res)) if (this.resource.TryGetTarget(out res))
{ {
instanceAge++; lock (journalSync)
{
var cursor = NextCursor();
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
ages[pt.Index] = instanceAge; ages[pt.Index] = cursor.Revision;
modificationDates[pt.Index] = now; modificationDates[pt.Index] = now;
instanceModificationDate = now;
//if (pt.HasHistory) store.Modify(res, pt, value, cursor.Revision, now);
//{ CommitJournal(res, new ResourceJournalEntry(
// store.Record(res, pt.Name, value, ages[pt.Index], now); cursor,
//} now,
//else //if (pt.Storage == StorageMode.Recordable) ResourceJournalEntryKind.PropertyModified,
//{ pt.Index,
store.Modify(res, pt, value, ages[pt.Index], now); value), pt.Historical);
//}
//ResourceModified?.Invoke(res, pt.Name, value); PropertyModified?.Invoke(new PropertyModificationInfo(res, pt, value, cursor, now));
}
PropertyModified?.Invoke(new PropertyModificationInfo(res, pt, value, instanceAge));
} }
} }
@@ -526,7 +561,21 @@ public class Instance
IResource res; IResource res;
if (this.resource.TryGetTarget(out res)) if (this.resource.TryGetTarget(out res))
{ {
CustomEventOccurred?.Invoke(new CustomEventOccurredInfo(res, eventDef, receivers, issuer, value)); lock (journalSync)
{
var cursor = NextCursor();
var now = DateTime.UtcNow;
instanceModificationDate = now;
// Receiver predicates are session-specific and cannot be replayed safely.
CommitJournal(res, new ResourceJournalEntry(
cursor,
now,
ResourceJournalEntryKind.EventOccurred,
eventDef.Index,
value), false);
CustomEventOccurred?.Invoke(new CustomEventOccurredInfo(
res, eventDef, receivers, issuer, value, cursor, now));
}
} }
} }
@@ -535,7 +584,7 @@ public class Instance
IResource res; IResource res;
if (this.resource.TryGetTarget(out res)) if (this.resource.TryGetTarget(out res))
{ {
EventOccurred?.Invoke(new EventOccurredInfo(res, eventDef, value)); EmitResourceEventCore(res, eventDef, value);
} }
} }
@@ -545,7 +594,7 @@ public class Instance
if (this.resource.TryGetTarget(out res)) if (this.resource.TryGetTarget(out res))
{ {
var eventDef = definition.GetEventDefByIndex(eventIndex); var eventDef = definition.GetEventDefByIndex(eventIndex);
EventOccurred?.Invoke(new EventOccurredInfo(res, eventDef, value)); EmitResourceEventCore(res, eventDef, value);
} }
} }
@@ -555,7 +604,141 @@ public class Instance
if (this.resource.TryGetTarget(out res)) if (this.resource.TryGetTarget(out res))
{ {
var eventDef = definition.GetEventDefByIndex(eventIndex); var eventDef = definition.GetEventDefByIndex(eventIndex);
CustomEventOccurred?.Invoke(new CustomEventOccurredInfo(res, eventDef, receivers, issuer, value)); EmitCustomResourceEvent(issuer, receivers, eventDef, value);
}
}
void EmitResourceEventCore(IResource res, EventDef eventDef, object value)
{
if (eventDef == null)
return;
lock (journalSync)
{
var cursor = NextCursor();
var now = DateTime.UtcNow;
instanceModificationDate = now;
CommitJournal(res, new ResourceJournalEntry(
cursor,
now,
ResourceJournalEntryKind.EventOccurred,
eventDef.Index,
value), eventDef.Historical);
EventOccurred?.Invoke(new EventOccurredInfo(res, eventDef, value, cursor, now));
}
}
ResourceCursor NextCursor()
{
instanceAge++;
return new ResourceCursor(streamGeneration, instanceAge);
}
void CommitJournal(IResource res, ResourceJournalEntry entry, bool retain)
{
if (store is IResourceJournalStore journal &&
!journal.AppendJournalEntry(res, entry, retain))
throw new InvalidOperationException(
$"The store rejected resource journal revision {entry.Cursor} for `{Link}`.");
}
/// <summary>Reads retained changes from this resource's owning store.</summary>
public ResourceJournalPage QueryJournal(ResourceJournalQuery query)
{
lock (journalSync)
{
if (resource.TryGetTarget(out var res) && store is IResourceJournalStore journal)
{
var page = journal.QueryJournal(res, query ?? new ResourceJournalQuery());
var cursor = new ResourceCursor(streamGeneration, instanceAge);
if (page.HighWatermark == cursor)
return page;
return new ResourceJournalPage(
page.OldestAvailable,
cursor,
page.Next,
page.CursorExpired,
page.HasMore,
page.Entries);
}
var currentCursor = new ResourceCursor(streamGeneration, instanceAge);
return new ResourceJournalPage(currentCursor, currentCursor, query?.After ?? currentCursor,
false, false, Array.Empty<ResourceJournalEntry>());
}
}
/// <summary>
/// Runs an attach/replay operation against an atomic resource high-watermark.
/// Emission uses the same lock, so notifications after the callback are live
/// changes strictly newer than the captured cursor.
/// </summary>
internal T SynchronizeJournal<T>(Func<ResourceCursor, T> action)
{
lock (journalSync)
return action(new ResourceCursor(streamGeneration, instanceAge));
}
internal void ObserveRemoteCursor(ResourceCursor cursor, DateTime recordedAt)
{
lock (journalSync)
{
if (streamGeneration != cursor.Generation)
{
streamGeneration = cursor.Generation;
instanceAge = cursor.Revision;
}
else if (cursor.Revision > instanceAge)
instanceAge = cursor.Revision;
if (recordedAt > instanceModificationDate)
instanceModificationDate = recordedAt;
}
}
internal bool IsNewerPropertyRevision(byte index, ResourceCursor cursor)
{
lock (journalSync)
{
if (streamGeneration != cursor.Generation)
return true;
return index >= ages.Count || !ages[index].HasValue || ages[index].Value < cursor.Revision;
}
}
internal void ApplyRemotePropertyModification(
PropertyDef propertyDef,
object value,
ResourceCursor cursor,
DateTime recordedAt)
{
if (!resource.TryGetTarget(out var res))
return;
lock (journalSync)
{
ObserveRemoteCursor(cursor, recordedAt);
ages[propertyDef.Index] = cursor.Revision;
modificationDates[propertyDef.Index] = recordedAt;
PropertyModified?.Invoke(new PropertyModificationInfo(
res, propertyDef, value, cursor, recordedAt));
}
}
internal void ApplyRemoteEvent(
EventDef eventDef,
object value,
ResourceCursor cursor,
DateTime recordedAt)
{
if (!resource.TryGetTarget(out var res))
return;
lock (journalSync)
{
ObserveRemoteCursor(cursor, recordedAt);
EventOccurred?.Invoke(new EventOccurredInfo(res, eventDef, value, cursor, recordedAt));
} }
} }
@@ -755,7 +938,14 @@ public class Instance
/// <param name="name">Name of the instance.</param> /// <param name="name">Name of the instance.</param>
/// <param name="resource">Resource to manage.</param> /// <param name="resource">Resource to manage.</param>
/// <param name="store">Store responsible for the resource.</param> /// <param name="store">Store responsible for the resource.</param>
public Instance(Warehouse warehouse, uint id, string name, IResource resource, IStore store, ulong age = 0) public Instance(
Warehouse warehouse,
uint id,
string name,
IResource resource,
IStore store,
ulong age = 0,
string resourceKey = null)
{ {
this.Warehouse = warehouse; this.Warehouse = warehouse;
this.store = store; this.store = store;
@@ -763,6 +953,17 @@ public class Instance
this.id = id; this.id = id;
this.name = name ?? ""; this.name = name ?? "";
this.instanceAge = age; this.instanceAge = age;
this.streamGeneration = Guid.NewGuid();
if (store is IResourceJournalStore journal)
{
var cursor = journal.OpenJournal(
resource,
resourceKey ?? name ?? string.Empty,
new ResourceCursor(streamGeneration, instanceAge));
streamGeneration = cursor.Generation;
instanceAge = Math.Max(instanceAge, cursor.Revision);
}
//this.attributes = new KeyList<string, object>(this); //this.attributes = new KeyList<string, object>(this);
//children = new AutoList<IResource, Instance>(this); //children = new AutoList<IResource, Instance>(this);
@@ -779,6 +980,7 @@ public class Instance
if (resource is IDynamicResource dynamicResource) if (resource is IDynamicResource dynamicResource)
{ {
this.definition = dynamicResource.ResourceDefinition; this.definition = dynamicResource.ResourceDefinition;
warehouse.RegisterDynamicTypeDef(this.definition);
} }
else else
{ {
@@ -796,6 +998,8 @@ public class Instance
// connect events // connect events
if (!(resource is EpResource)) if (!(resource is EpResource))
{ {
if (resource is IDynamicResourceEventSource dynamicEventSource)
dynamicEventSource.ResourceEventOccurred += EmitResourceEventByIndex;
Type t = ResourceProxy.GetBaseType(resource); Type t = ResourceProxy.GetBaseType(resource);
@@ -12,15 +12,30 @@ public struct PropertyModificationInfo
public readonly PropertyDef PropertyDef; public readonly PropertyDef PropertyDef;
public string Name => PropertyDef.Name; public string Name => PropertyDef.Name;
public readonly ulong Age; public readonly ulong Age;
public ulong Revision => Cursor.Revision;
public readonly ResourceCursor Cursor;
public readonly DateTime RecordedAt;
public object Value; public object Value;
public PropertyModificationInfo(IResource resource, PropertyDef propertyDef, object value, ulong age) public PropertyModificationInfo(
IResource resource,
PropertyDef propertyDef,
object value,
ResourceCursor cursor,
DateTime recordedAt)
{ {
Resource = resource; Resource = resource;
PropertyDef = propertyDef; PropertyDef = propertyDef;
Age = age; Cursor = cursor;
Age = cursor.Revision;
RecordedAt = recordedAt;
Value = value; Value = value;
} }
public PropertyModificationInfo(IResource resource, PropertyDef propertyDef, object value, ulong age)
: this(resource, propertyDef, value, new ResourceCursor(Guid.Empty, age), DateTime.UtcNow)
{
}
} }
@@ -0,0 +1,36 @@
using System;
namespace Esiur.Resource;
/// <summary>
/// Identifies an exact position in a resource's ordered change stream.
/// Generation changes when the stream is recreated; revision increases for
/// every observable property modification or event occurrence.
/// </summary>
public readonly struct ResourceCursor : IEquatable<ResourceCursor>
{
public Guid Generation { get; }
public ulong Revision { get; }
public ResourceCursor(Guid generation, ulong revision)
{
Generation = generation;
Revision = revision;
}
public bool IsEmpty => Generation == Guid.Empty && Revision == 0;
public bool Equals(ResourceCursor other) =>
Generation == other.Generation && Revision == other.Revision;
public override bool Equals(object obj) =>
obj is ResourceCursor other && Equals(other);
public override int GetHashCode() =>
(Generation.GetHashCode() * 397) ^ Revision.GetHashCode();
public override string ToString() => $"{Generation:N}:{Revision}";
public static bool operator ==(ResourceCursor left, ResourceCursor right) => left.Equals(right);
public static bool operator !=(ResourceCursor left, ResourceCursor right) => !left.Equals(right);
}
+240
View File
@@ -0,0 +1,240 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
namespace Esiur.Resource;
public enum ResourceJournalEntryKind : byte
{
PropertyModified = 0,
EventOccurred = 1,
}
/// <summary>An ordered, optionally retained resource change.</summary>
public sealed class ResourceJournalEntry
{
public ResourceCursor Cursor { get; }
public DateTime RecordedAt { get; }
public ResourceJournalEntryKind Kind { get; }
public byte MemberIndex { get; }
public object Value { get; }
public ResourceJournalEntry(
ResourceCursor cursor,
DateTime recordedAt,
ResourceJournalEntryKind kind,
byte memberIndex,
object value)
{
Cursor = cursor;
RecordedAt = recordedAt.Kind == DateTimeKind.Utc
? recordedAt
: recordedAt.ToUniversalTime();
Kind = kind;
MemberIndex = memberIndex;
Value = value;
}
}
/// <summary>Filters a bounded resource-journal query.</summary>
public sealed class ResourceJournalQuery
{
public ResourceCursor After { get; set; }
public ulong? ThroughRevision { get; set; }
public DateTime? FromTime { get; set; }
public DateTime? ToTime { get; set; }
public ResourceJournalEntryKind? Kind { get; set; }
public byte? MemberIndex { get; set; }
public int Limit { get; set; } = 1000;
}
/// <summary>A journal page and the stream boundaries used to read it.</summary>
public sealed class ResourceJournalPage
{
public ResourceCursor OldestAvailable { get; }
public ResourceCursor HighWatermark { get; }
public ResourceCursor Next { get; }
public bool CursorExpired { get; }
public bool HasMore { get; }
public ResourceJournalEntry[] Entries { get; }
public ResourceJournalPage(
ResourceCursor oldestAvailable,
ResourceCursor highWatermark,
ResourceCursor next,
bool cursorExpired,
bool hasMore,
ResourceJournalEntry[] entries)
{
OldestAvailable = oldestAvailable;
HighWatermark = highWatermark;
Next = next;
CursorExpired = cursorExpired;
HasMore = hasMore;
Entries = entries ?? Array.Empty<ResourceJournalEntry>();
}
}
/// <summary>
/// Optional store capability for persisting historical property changes and
/// event occurrences. The resource instance owns ordering; the store owns
/// retention and persistence.
/// </summary>
public interface IResourceJournalStore
{
ResourceCursor OpenJournal(
IResource resource,
string resourceKey,
ResourceCursor proposedCursor);
bool AppendJournalEntry(IResource resource, ResourceJournalEntry entry, bool retain);
ResourceJournalPage QueryJournal(IResource resource, ResourceJournalQuery query);
void RemoveJournal(IResource resource);
}
/// <summary>
/// Bounded in-memory implementation for volatile stores and tests. Persistent
/// stores can implement <see cref="IResourceJournalStore"/> with their own
/// transactional backend while preserving the same protocol contract.
/// </summary>
public sealed class ResourceJournalBuffer : IResourceJournalStore
{
sealed class State
{
public ResourceCursor Head;
public ulong DiscardedThroughRevision;
public readonly LinkedList<ResourceJournalEntry> Entries = new();
}
sealed class ResourceReferenceComparer : IEqualityComparer<IResource>
{
public bool Equals(IResource x, IResource y) => ReferenceEquals(x, y);
public int GetHashCode(IResource obj) => RuntimeHelpers.GetHashCode(obj);
}
readonly object sync = new();
readonly Dictionary<IResource, State> states =
new(new ResourceReferenceComparer());
readonly int maximumEntriesPerResource;
public ResourceJournalBuffer(int maximumEntriesPerResource = 10000)
{
if (maximumEntriesPerResource < 1)
throw new ArgumentOutOfRangeException(nameof(maximumEntriesPerResource));
this.maximumEntriesPerResource = maximumEntriesPerResource;
}
public ResourceCursor OpenJournal(
IResource resource,
string resourceKey,
ResourceCursor proposedCursor)
{
if (resource == null) throw new ArgumentNullException(nameof(resource));
lock (sync)
{
if (states.TryGetValue(resource, out var existing))
return existing.Head;
var generation = proposedCursor.Generation == Guid.Empty
? Guid.NewGuid()
: proposedCursor.Generation;
var cursor = new ResourceCursor(generation, proposedCursor.Revision);
states.Add(resource, new State { Head = cursor });
return cursor;
}
}
public bool AppendJournalEntry(IResource resource, ResourceJournalEntry entry, bool retain)
{
if (resource == null) throw new ArgumentNullException(nameof(resource));
if (entry == null) throw new ArgumentNullException(nameof(entry));
lock (sync)
{
if (!states.TryGetValue(resource, out var state))
{
state = new State { Head = new ResourceCursor(entry.Cursor.Generation, 0) };
states.Add(resource, state);
}
if (state.Head.Generation != entry.Cursor.Generation ||
entry.Cursor.Revision <= state.Head.Revision)
return false;
state.Head = entry.Cursor;
if (retain)
state.Entries.AddLast(entry);
while (state.Entries.Count > maximumEntriesPerResource)
{
state.DiscardedThroughRevision = Math.Max(
state.DiscardedThroughRevision,
state.Entries.First.Value.Cursor.Revision);
state.Entries.RemoveFirst();
}
return true;
}
}
public ResourceJournalPage QueryJournal(IResource resource, ResourceJournalQuery query)
{
if (resource == null) throw new ArgumentNullException(nameof(resource));
query ??= new ResourceJournalQuery();
var limit = Math.Max(1, Math.Min(query.Limit, 10000));
lock (sync)
{
var proposed = new ResourceCursor(Guid.NewGuid(), 0);
if (!states.TryGetValue(resource, out var state))
{
state = new State { Head = proposed };
states.Add(resource, state);
}
var after = query.After;
var generationMismatch = after.Generation != Guid.Empty &&
after.Generation != state.Head.Generation;
var expired = generationMismatch ||
(!generationMismatch &&
after.Revision < state.DiscardedThroughRevision);
if (generationMismatch)
after = new ResourceCursor(state.Head.Generation, 0);
IEnumerable<ResourceJournalEntry> filtered = state.Entries
.Where(entry => entry.Cursor.Revision > after.Revision);
if (query.ThroughRevision.HasValue)
filtered = filtered.Where(entry => entry.Cursor.Revision <= query.ThroughRevision.Value);
if (query.FromTime.HasValue)
filtered = filtered.Where(entry => entry.RecordedAt >= query.FromTime.Value.ToUniversalTime());
if (query.ToTime.HasValue)
filtered = filtered.Where(entry => entry.RecordedAt <= query.ToTime.Value.ToUniversalTime());
if (query.Kind.HasValue)
filtered = filtered.Where(entry => entry.Kind == query.Kind.Value);
if (query.MemberIndex.HasValue)
filtered = filtered.Where(entry => entry.MemberIndex == query.MemberIndex.Value);
var selected = filtered.Take(limit + 1).ToArray();
var hasMore = selected.Length > limit;
var entries = hasMore ? selected.Take(limit).ToArray() : selected;
var next = entries.Length == 0 ? after : entries[entries.Length - 1].Cursor;
var oldest = state.Entries.First?.Value.Cursor ?? state.Head;
return new ResourceJournalPage(
oldest,
state.Head,
next,
expired,
hasMore,
entries);
}
}
public void RemoveJournal(IResource resource)
{
if (resource == null) return;
lock (sync) states.Remove(resource);
}
}
+51 -1
View File
@@ -1036,7 +1036,14 @@ public class Warehouse
var resourceId = (uint)Interlocked.Increment(ref _resourceCounter); var resourceId = (uint)Interlocked.Increment(ref _resourceCounter);
resource.Instance = new Instance(this, resourceId, instanceName, resource, store, resourceContext?.Age ?? 0); resource.Instance = new Instance(
this,
resourceId,
instanceName,
resource,
store,
resourceContext?.Age ?? 0,
string.Join("/", location));
resource.Instance.Managers.AddRange(resourceManagers); resource.Instance.Managers.AddRange(resourceManagers);
@@ -1310,6 +1317,48 @@ public class Warehouse
} }
} }
/// <summary>
/// Registers a definition supplied by an <see cref="IDynamicResource"/>
/// without replacing its stable identifier. Dynamic definitions are not
/// backed by a CLR <see cref="LocalTypeDef"/>, so they cannot use the
/// incremental local registration path, but remote peers must still be
/// able to resolve them through TypeDefById.
/// </summary>
internal void RegisterDynamicTypeDef(TypeDef typeDef)
{
if (typeDef == null)
throw new ArgumentNullException(nameof(typeDef));
lock (_typeDefsLock)
{
// A remote definition keeps the producer's id for wire lookups, but
// TryRegisterRemoteTypeDef gives it a Warehouse-local id because remote
// resource/record/enum id spaces can overlap. Dynamic proxy instances
// must reuse that local registration instead of indexing by the wire id.
var registrationId = typeDef is RemoteTypeDef remoteTypeDef
&& remoteTypeDef.LocalTypeDefId != 0
? remoteTypeDef.LocalTypeDefId
: typeDef.Id;
var existing = _localTypeDefs[registrationId];
if (existing == null)
{
_localTypeDefs[registrationId] = typeDef;
return;
}
if (ReferenceEquals(existing, typeDef) ||
(existing.Kind == typeDef.Kind &&
existing.Version == typeDef.Version &&
string.Equals(existing.Name, typeDef.Name, StringComparison.Ordinal)))
{
return;
}
throw new InvalidOperationException(
$"Dynamic TypeDef id {registrationId} is already registered by '{existing.Name}', not '{typeDef.Name}'.");
}
}
internal KeyList<TypeDefKind, KeyList<string , Type>> GetProxyTypesByDomain(string domain) internal KeyList<TypeDefKind, KeyList<string , Type>> GetProxyTypesByDomain(string domain)
{ {
return _proxyTypeDefs[domain]; return _proxyTypeDefs[domain];
@@ -1317,6 +1366,7 @@ public class Warehouse
public bool TryRegisterRemoteTypeDef(string domain, RemoteTypeDef typeDef) public bool TryRegisterRemoteTypeDef(string domain, RemoteTypeDef typeDef)
{ {
domain = string.IsNullOrWhiteSpace(domain) ? "anonymous" : domain;
lock (_typeDefsLock) lock (_typeDefsLock)
{ {
if (!_remoteTypeDefs.ContainsKey(domain)) if (!_remoteTypeDefs.ContainsKey(domain))
+27 -1
View File
@@ -10,7 +10,7 @@ using Esiur.Data.Types;
namespace Esiur.Stores; namespace Esiur.Stores;
public class MemoryStore : IStore public class MemoryStore : IStore, IResourceJournalStore
{ {
public Instance Instance { get; set; } public Instance Instance { get; set; }
@@ -18,6 +18,31 @@ public class MemoryStore : IStore
KeyList<uint, IResource> resources = new KeyList<uint, IResource>(); KeyList<uint, IResource> resources = new KeyList<uint, IResource>();
readonly IResourceJournalStore journal;
public MemoryStore()
: this(new ResourceJournalBuffer())
{
}
public MemoryStore(IResourceJournalStore journal)
{
this.journal = journal ?? throw new ArgumentNullException(nameof(journal));
}
public ResourceCursor OpenJournal(
IResource resource,
string resourceKey,
ResourceCursor proposedCursor) =>
journal.OpenJournal(resource, resourceKey, proposedCursor);
public bool AppendJournalEntry(IResource resource, ResourceJournalEntry entry, bool retain) =>
journal.AppendJournalEntry(resource, entry, retain);
public ResourceJournalPage QueryJournal(IResource resource, ResourceJournalQuery query) =>
journal.QueryJournal(resource, query);
public void RemoveJournal(IResource resource) => journal.RemoveJournal(resource);
public void Destroy() public void Destroy()
{ {
@@ -161,6 +186,7 @@ public class MemoryStore : IStore
AsyncReply<bool> IStore.Remove(IResource resource) AsyncReply<bool> IStore.Remove(IResource resource)
{ {
resources.Remove(resource.Instance.Id); resources.Remove(resource.Instance.Id);
journal.RemoveJournal(resource);
return AsyncReply.FromResult(true); return AsyncReply.FromResult(true);
} }
+17 -1
View File
@@ -9,13 +9,28 @@ using Esiur.Data;
using Esiur.Data.Types; using Esiur.Data.Types;
namespace Esiur.Stores; namespace Esiur.Stores;
public class TemporaryStore : IStore public class TemporaryStore : IStore, IResourceJournalStore
{ {
public Instance Instance { get; set; } public Instance Instance { get; set; }
public event DestroyedEvent OnDestroy; public event DestroyedEvent OnDestroy;
Dictionary<uint, WeakReference> resources = new Dictionary<uint, WeakReference>(); Dictionary<uint, WeakReference> resources = new Dictionary<uint, WeakReference>();
readonly ResourceJournalBuffer journal = new();
public ResourceCursor OpenJournal(
IResource resource,
string resourceKey,
ResourceCursor proposedCursor) =>
journal.OpenJournal(resource, resourceKey, proposedCursor);
public bool AppendJournalEntry(IResource resource, ResourceJournalEntry entry, bool retain) =>
journal.AppendJournalEntry(resource, entry, retain);
public ResourceJournalPage QueryJournal(IResource resource, ResourceJournalQuery query) =>
journal.QueryJournal(resource, query);
public void RemoveJournal(IResource resource) => journal.RemoveJournal(resource);
public void Destroy() public void Destroy()
{ {
@@ -90,6 +105,7 @@ public class TemporaryStore : IStore
AsyncReply<bool> IStore.Remove(IResource resource) AsyncReply<bool> IStore.Remove(IResource resource)
{ {
resources.Remove(resource.Instance.Id); resources.Remove(resource.Instance.Id);
journal.RemoveJournal(resource);
return new AsyncReply<bool>(true); return new AsyncReply<bool>(true);
} }
+49 -12
View File
@@ -173,15 +173,12 @@ The `ep://` URL identifies the logical connection and resource path;
`WebSocketUri` identifies the transport endpoint. Esiur automatically requests `WebSocketUri` identifies the transport endpoint. Esiur automatically requests
the case-sensitive `EP` WebSocket subprotocol. the case-sensitive `EP` WebSocket subprotocol.
For native TCP, omit `WebSocketUri` and include the EP port in the logical URL: For native TCP, omit `WebSocketUri`. Esiur uses port `51018` when the logical
URL omits a port:
```csharp ```csharp
var epPort = ushort.Parse(
Environment.GetEnvironmentVariable("ESIUR_PORT")
?? throw new InvalidOperationException("Set ESIUR_PORT."));
dynamic counter = await client.Get<IResource>( dynamic counter = await client.Get<IResource>(
$"ep://localhost:{epPort}/sys/counter"); "ep://localhost/sys/counter");
``` ```
## Standalone hosting ## Standalone hosting
@@ -195,16 +192,12 @@ using Esiur.Protocol;
using Esiur.Resource; using Esiur.Resource;
using Esiur.Stores; using Esiur.Stores;
var epPort = ushort.Parse(
Environment.GetEnvironmentVariable("ESIUR_PORT")
?? throw new InvalidOperationException("Set ESIUR_PORT."));
var warehouse = new Warehouse(); var warehouse = new Warehouse();
await warehouse.Put("sys", new MemoryStore()); await warehouse.Put("sys", new MemoryStore());
await warehouse.Put("sys/counter", new CounterResource()); await warehouse.Put("sys/counter", new CounterResource());
await warehouse.Put("sys/server", new EpServer await warehouse.Put("sys/server", new EpServer
{ {
Port = epPort,
AllowUnauthorizedAccess = true, // Development only. AllowUnauthorizedAccess = true, // Development only.
}); });
@@ -224,6 +217,50 @@ Use a separate `Warehouse` for each isolated server or client runtime. Avoid
relying on the static default Warehouse in applications that host more than one relying on the static default Warehouse in applications that host more than one
Esiur environment. Esiur environment.
## Resource revisions and historical events
Esiur 3 uses one ordered cursor for every observable change made by a resource:
```text
(generation UUID, resource-wide revision)
```
Property modifications and event occurrences share this sequence. Notifications
also carry their original UTC recording time. A new generation means that the
producer cannot continue the previous sequence, so consumers must replace their
snapshot instead of comparing revisions from the two generations.
Mark an event with `[Historical]` when disconnected consumers must be able to
replay it. Ordinary events remain live-only and do not grow the retained journal.
```csharp
[Export, Historical]
public event ResourceEventHandler<ReadingRecord>? Reading;
var checkpoint = remote.Instance.Cursor;
await remote.OnFromAsync("Reading", checkpoint, value =>
{
var reading = (ReadingRecord)value;
// Persist the reading, then save the cursor received through
// remote.Instance.EventOccurred.
});
```
`EpResource.QueryJournal(...)` supports bounded queries by cursor, time, entry
kind, and member. `OnFromAsync(...)` atomically replays retained occurrences and
then continues with live delivery. Each consumer owns its checkpoint; the source
does not delete an event merely because one client received it. Reconnect uses
the per-event checkpoint automatically, and returns `CursorExpired` if retention
can no longer satisfy the requested position.
The resource's owning store controls retention through `IResourceJournalStore`.
The bundled memory, temporary, and current EntityCore adapters use a bounded
10,000-entry process-local journal per resource. A deployment requiring replay
across producer restarts must use a durable implementation of that interface.
This changes the EP v3 attach, reattach, subscribe, notification, and journal
request shapes. All communicating v3 peers must be upgraded together.
## Authentication and encryption ## Authentication and encryption
Anonymous access is opt-in. Production services should omit `AllowAnonymous`, Anonymous access is opt-in. Production services should omit `AllowAnonymous`,
@@ -350,8 +387,8 @@ EP is self-describing, so clients can work dynamically or generate strongly
typed models. Install the v3 CLI as a .NET tool: typed models. Install the v3 CLI as a .NET tool:
```shell ```shell
dotnet tool install --global Esiur.CLI --version 3.0.0 dotnet tool install --global Esiur.CLI --version 3.1.0
esiur get-template "ep://localhost:${ESIUR_PORT}/sys/counter" --dir Generated esiur get-template "ep://localhost/sys/counter" --dir Generated
``` ```
Use `--async-setters` to generate asynchronous property setters. The CLI also Use `--async-setters` to generate asynchronous property setters. The CLI also
+17 -1
View File
@@ -37,7 +37,7 @@ using System.Collections;
using Esiur.Data.Types; using Esiur.Data.Types;
namespace Esiur.Stores.EntityCore; namespace Esiur.Stores.EntityCore;
public class EntityStore : IStore public class EntityStore : IStore, IResourceJournalStore
{ {
public Instance Instance { get; set; } public Instance Instance { get; set; }
@@ -50,6 +50,21 @@ public class EntityStore : IStore
Dictionary<Type, Dictionary<object, WeakReference>> DB = new Dictionary<Type, Dictionary<object, WeakReference>>(); Dictionary<Type, Dictionary<object, WeakReference>> DB = new Dictionary<Type, Dictionary<object, WeakReference>>();
object DBLock = new object(); object DBLock = new object();
readonly ResourceJournalBuffer journal = new();
public ResourceCursor OpenJournal(
IResource resource,
string resourceKey,
ResourceCursor proposedCursor) =>
journal.OpenJournal(resource, resourceKey, proposedCursor);
public bool AppendJournalEntry(IResource resource, ResourceJournalEntry entry, bool retain) =>
journal.AppendJournalEntry(resource, entry, retain);
public ResourceJournalPage QueryJournal(IResource resource, ResourceJournalQuery query) =>
journal.QueryJournal(resource, query);
public void RemoveJournal(IResource resource) => journal.RemoveJournal(resource);
Dictionary<string, EntityTypeInfo> TypesByName = new Dictionary<string, EntityTypeInfo>(); Dictionary<string, EntityTypeInfo> TypesByName = new Dictionary<string, EntityTypeInfo>();
internal Dictionary<Type, EntityTypeInfo> TypesByType = new Dictionary<Type, EntityTypeInfo>(); internal Dictionary<Type, EntityTypeInfo> TypesByType = new Dictionary<Type, EntityTypeInfo>();
@@ -293,6 +308,7 @@ public class EntityStore : IStore
if (DB[type].ContainsKey(eid)) if (DB[type].ContainsKey(eid))
{ {
DB[type].Remove(eid); DB[type].Remove(eid);
journal.RemoveJournal(resource);
return new AsyncReply<bool>(true); return new AsyncReply<bool>(true);
} }
} }
+2 -1
View File
@@ -8,6 +8,7 @@ namespace Esiur.CLI.Tests;
public sealed class ConfigurationTests public sealed class ConfigurationTests
{ {
[Theory] [Theory]
[InlineData("ep://localhost", "ep://localhost")]
[InlineData("ep://localhost:65535", "ep://localhost:65535")] [InlineData("ep://localhost:65535", "ep://localhost:65535")]
[InlineData("ep://example.test:9000/sys/service", "ep://example.test:9000")] [InlineData("ep://example.test:9000/sys/service", "ep://example.test:9000")]
public void EndpointParserExtractsConnectionEndpoint(string value, string expected) => public void EndpointParserExtractsConnectionEndpoint(string value, string expected) =>
@@ -15,7 +16,7 @@ public sealed class ConfigurationTests
[Theory] [Theory]
[InlineData("http://localhost")] [InlineData("http://localhost")]
[InlineData("ep://localhost")] [InlineData("ep://localhost:0")]
[InlineData("ep:///missing-host")] [InlineData("ep:///missing-host")]
[InlineData("not-an-endpoint")] [InlineData("not-an-endpoint")]
public void EndpointParserRejectsInvalidEndpoints(string value) => public void EndpointParserRejectsInvalidEndpoints(string value) =>
@@ -183,12 +183,13 @@ namespace Esiur.Tests.RPC.EsiurServer
get => (object)_properties[1]; get => (object)_properties[1];
set => SetResourceProperty(1, value); set => SetResourceProperty(1, value);
} }
protected override void _EmitEventByIndex(byte index, object args) protected override void _EmitEventByIndex(byte index, object args, ResourceCursor cursor, DateTime recordedAt)
{ {
switch (index) switch (index)
{ {
case 0: MessageUpdated?.Invoke((byte[])args); break; case 0: MessageUpdated?.Invoke((byte[])args); break;
} }
base._EmitEventByIndex(index, args, cursor, recordedAt);
} }
[Export] public event ResourceEventHandler<byte[]> MessageUpdated; [Export] public event ResourceEventHandler<byte[]> MessageUpdated;
+11 -9
View File
@@ -6,17 +6,19 @@ namespace Esiur.Tests.Unit;
public sealed class EpPortTests public sealed class EpPortTests
{ {
[Fact] [Fact]
public void ServerDoesNotAssignAProtocolPort() public void ServerUsesTheProtocolDefaultPort()
=> Assert.Equal(0, new EpServer().Port); => Assert.Equal(EpProtocol.DefaultPort, new EpServer().Port);
[Fact] [Fact]
public async Task ClientEndpointWithoutPortIsRejected() public void ClientEndpointWithoutPortUsesTheProtocolDefault()
{ {
var warehouse = new Warehouse(); var endpoint = new Uri("ep://localhost/sys/resource");
Assert.Equal(EpProtocol.DefaultPort, EpConnection.ResolveEndpointPort(endpoint));
var exception = await Assert.ThrowsAsync<FormatException>(async () =>
await warehouse.Get<EpConnection>("ep://localhost/sys/resource"));
Assert.Contains("explicit port", exception.Message, StringComparison.OrdinalIgnoreCase);
} }
[Fact]
public void ExplicitClientPortStillOverridesTheProtocolDefault()
=> Assert.Equal(
12345,
EpConnection.ResolveEndpointPort(new Uri("ep://localhost:12345/sys/resource")));
} }
+3 -1
View File
@@ -9,7 +9,9 @@ public partial class BeaconResource
// No [AutoDelivery]: subscribable by default — requires an explicit // No [AutoDelivery]: subscribable by default — requires an explicit
// Subscribe request before occurrences flow to a given connection. // Subscribe request before occurrences flow to a given connection.
[Export] public event ResourceEventHandler<string>? Ping; [Export]
[Historical]
public event ResourceEventHandler<string>? Ping;
// Opts out via [AutoDelivery]: flows to every attached connection // Opts out via [AutoDelivery]: flows to every attached connection
// unconditionally. // unconditionally.
@@ -6,6 +6,110 @@ namespace Esiur.Tests.Unit.Integration;
[Collection("Integration")] [Collection("Integration")]
public class EventSubscriptionIntegrationTests public class EventSubscriptionIntegrationTests
{ {
[Fact]
public async Task PropertyAndEventNotifications_ShareOneOrderedResourceRevision()
{
await using var cluster = await StartClusterAsync(out var getBeacon).WaitAsync(TimeSpan.FromSeconds(10));
var remote = await GetRemote(cluster);
var beacon = getBeacon();
var propertyRevisions = new List<ResourceCursor>();
var eventRevisions = new List<ResourceCursor>();
remote.Instance.PropertyModified += info => propertyRevisions.Add(info.Cursor);
remote.Instance.EventOccurred += info => eventRevisions.Add(info.Cursor);
await remote.OnAsync("Ping", _ => { });
beacon.Fire("ping", "ordered");
await WaitUntilAsync(
() => propertyRevisions.Count == 1 && eventRevisions.Count == 1,
TimeSpan.FromSeconds(3));
Assert.Equal(propertyRevisions[0].Generation, eventRevisions[0].Generation);
Assert.Equal(propertyRevisions[0].Revision + 1, eventRevisions[0].Revision);
Assert.Equal(eventRevisions[0], remote.Instance.Cursor);
}
[Fact]
public async Task QueryJournal_ReturnsHistoricalEventAfterCursor()
{
await using var cluster = await StartClusterAsync(out var getBeacon).WaitAsync(TimeSpan.FromSeconds(10));
var remote = await GetRemote(cluster);
var beacon = getBeacon();
var attachedAt = remote.Instance.Cursor;
var ping = remote.Instance.Definition.GetEventDefByName("Ping");
beacon.Fire("ping", "retained");
var page = await remote.QueryJournal(new ResourceJournalQuery
{
After = attachedAt,
Kind = ResourceJournalEntryKind.EventOccurred,
MemberIndex = ping.Index,
});
var entry = Assert.Single(page.Entries);
Assert.False(page.CursorExpired);
Assert.Equal(ResourceJournalEntryKind.EventOccurred, entry.Kind);
Assert.Equal(ping.Index, entry.MemberIndex);
Assert.Equal("retained", entry.Value);
Assert.True(entry.Cursor.Revision > attachedAt.Revision);
}
[Fact]
public async Task HistoricalSubscription_ReplaysOccurrenceMissedDuringReconnect()
{
await using var cluster = await StartClusterAsync(out var getBeacon).WaitAsync(TimeSpan.FromSeconds(10));
cluster.Connection.AutoReconnect = true;
cluster.Connection.ReconnectInterval = 1;
var remote = await GetRemote(cluster);
var beacon = getBeacon();
var received = new List<string>();
await remote.OnAsync("Ping", value => received.Add((string)value));
beacon.Fire("ping", "before");
await WaitUntilAsync(() => received.Count == 1, TimeSpan.FromSeconds(3));
foreach (var serverConnection in cluster.Server.Connections.ToArray())
serverConnection.Destroy();
await WaitUntilAsync(() => !cluster.Connection.IsConnected, TimeSpan.FromSeconds(3));
beacon.Fire("ping", "while-offline");
await WaitUntilAsync(() => cluster.Connection.IsConnected, TimeSpan.FromSeconds(5));
await WaitUntilAsync(() => received.Count == 2, TimeSpan.FromSeconds(5));
Assert.Equal(new[] { "before", "while-offline" }, received);
}
[Fact]
public async Task HistoricalSubscription_ReplaysEveryPageBeforeLiveDelivery()
{
const int occurrenceCount = 10_001;
BeaconResource? beacon = null;
await using var cluster = await IntegrationCluster.StartAsync(
async warehouse =>
{
beacon = new BeaconResource();
await warehouse.Put("sys/beacon", beacon);
},
resourceJournalCapacity: occurrenceCount + 1).WaitAsync(TimeSpan.FromSeconds(10));
for (var index = 0; index < occurrenceCount; index++)
beacon!.Fire("ping", index.ToString());
var remote = await GetRemote(cluster);
var received = new List<string>(occurrenceCount);
await remote.OnFromAsync(
"Ping",
new ResourceCursor(remote.Instance.Generation, 0),
value => received.Add((string)value));
await WaitUntilAsync(
() => received.Count == occurrenceCount,
TimeSpan.FromSeconds(10));
Assert.Equal("0", received[0]);
Assert.Equal((occurrenceCount - 1).ToString(), received[^1]);
}
[Fact] [Fact]
public async Task On_DeliversAutoDeliveredEventWithNoSubscribeNeeded() public async Task On_DeliversAutoDeliveredEventWithNoSubscribeNeeded()
{ {
@@ -60,6 +164,21 @@ public class EventSubscriptionIntegrationTests
Assert.Equal(new[] { "x", "y" }, b); // neither received "z" Assert.Equal(new[] { "x", "y" }, b); // neither received "z"
} }
[Fact]
public async Task OnAsync_CompletesAfterWireSubscriptionIsReady()
{
await using var cluster = await StartClusterAsync(out var getBeacon).WaitAsync(TimeSpan.FromSeconds(10));
var remote = await GetRemote(cluster);
var beacon = getBeacon();
var received = new List<string>();
await remote.OnAsync("Ping", value => received.Add((string)value));
beacon.Fire("ping", "immediate");
await WaitUntilAsync(() => received.Count == 1, TimeSpan.FromSeconds(3));
Assert.Equal(new[] { "immediate" }, received);
}
[Fact] [Fact]
public async Task NativePlusEquals_AutoSubscribesAndUnsubscribes() public async Task NativePlusEquals_AutoSubscribesAndUnsubscribes()
{ {
+19 -5
View File
@@ -205,7 +205,11 @@ internal sealed class IntegrationCluster : IAsyncDisposable
bool mismatchedSessionKeys = false, bool mismatchedSessionKeys = false,
bool requireKeyRotation = false, bool requireKeyRotation = false,
bool allowAuthentication = true, bool allowAuthentication = true,
bool registerServerAuthenticationProvider = true) bool registerServerAuthenticationProvider = true,
bool anonymous = false,
int resourceJournalCapacity = 10000,
Action<EpServer> serverCreated = null,
Func<Warehouse, Task> populateClient = null)
{ {
var port = NextAvailablePort(); var port = NextAvailablePort();
@@ -217,10 +221,13 @@ internal sealed class IntegrationCluster : IAsyncDisposable
if (encrypted || requireEncryption) if (encrypted || requireEncryption)
serverWh.RegisterEncryptionProvider(new AesEncryptionProvider()); serverWh.RegisterEncryptionProvider(new AesEncryptionProvider());
await serverWh.Put("sys", new MemoryStore()); await serverWh.Put(
"sys",
new MemoryStore(new ResourceJournalBuffer(resourceJournalCapacity)));
var server = await serverWh.Put("sys/server", new EpServer var server = await serverWh.Put("sys/server", new EpServer
{ {
Port = (ushort)port, Port = (ushort)port,
AllowUnauthorizedAccess = anonymous,
AllowedAuthenticationProviders = allowAuthentication AllowedAuthenticationProviders = allowAuthentication
? new[] ? new[]
{ {
@@ -234,6 +241,7 @@ internal sealed class IntegrationCluster : IAsyncDisposable
: Array.Empty<string>(), : Array.Empty<string>(),
RequireEncryption = requireEncryption, RequireEncryption = requireEncryption,
}); });
serverCreated?.Invoke(server);
await populate(serverWh); await populate(serverWh);
@@ -246,6 +254,8 @@ internal sealed class IntegrationCluster : IAsyncDisposable
mismatchedSessionKeys ? (byte)0x80 : (byte)0, mismatchedSessionKeys ? (byte)0x80 : (byte)0,
requireKeyRotation) requireKeyRotation)
: new TestClientAuthProvider()); : new TestClientAuthProvider());
if (populateClient is not null)
await populateClient(cluster.ClientWarehouse);
if (encrypted) if (encrypted)
cluster.ClientWarehouse.RegisterEncryptionProvider(new AesEncryptionProvider()); cluster.ClientWarehouse.RegisterEncryptionProvider(new AesEncryptionProvider());
@@ -255,12 +265,16 @@ internal sealed class IntegrationCluster : IAsyncDisposable
$"ep://localhost:{port}", $"ep://localhost:{port}",
new EpConnectionContext new EpConnectionContext
{ {
AuthenticationMode = AuthenticationMode.InitializerIdentity, AuthenticationMode = anonymous
Identity = "tester", ? AuthenticationMode.None
: AuthenticationMode.InitializerIdentity,
Identity = anonymous ? null : "tester",
AuthenticationProtocol = oneStepAuthentication AuthenticationProtocol = oneStepAuthentication
? "one-step" ? "one-step"
: anonymous
? null
: PasswordAuthenticationProvider.ProtocolName, : PasswordAuthenticationProvider.ProtocolName,
Domain = "test", Domain = anonymous ? null : "test",
EncryptionMode = encrypted EncryptionMode = encrypted
? encryptionMode ? encryptionMode
: EncryptionMode.None, : EncryptionMode.None,
@@ -3,13 +3,77 @@ namespace Esiur.Tests.Unit.Integration;
using Esiur.Data; using Esiur.Data;
using Esiur.Net.Sockets; using Esiur.Net.Sockets;
using Esiur.Protocol; using Esiur.Protocol;
using Esiur.Resource;
using Esiur.Security.Authority.Providers; using Esiur.Security.Authority.Providers;
using Esiur.Security.Cryptography; using Esiur.Security.Cryptography;
using Esiur.Stores;
using System.Net; using System.Net;
[Collection("Integration")] [Collection("Integration")]
public class SessionHeadersIntegrationTests public class SessionHeadersIntegrationTests
{ {
[Fact]
public async Task AcceptedConnection_RaisesReadyAndDisconnectedLifecycleEvents()
{
var ready = new TaskCompletionSource<EpConnection>(
TaskCreationOptions.RunContinuationsAsynchronously);
var disconnected = new TaskCompletionSource<EpConnection>(
TaskCreationOptions.RunContinuationsAsynchronously);
await using var cluster = await IntegrationCluster
.StartAsync(
_ => Task.CompletedTask,
serverCreated: server =>
{
server.ConnectionReady += connection => ready.TrySetResult(connection);
server.ConnectionDisconnected += connection => disconnected.TrySetResult(connection);
})
.WaitAsync(TimeSpan.FromSeconds(10));
var accepted = await ready.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.True(accepted.Session.Authenticated);
Assert.Equal("tester", accepted.Session.RemoteIdentity);
accepted.NetworkClose(accepted.Socket);
Assert.Same(
accepted,
await disconnected.Task.WaitAsync(TimeSpan.FromSeconds(5)));
}
[Fact]
public async Task AcceptedConnection_CanFetchInitiatorResourceBidirectionally()
{
var fetched = new TaskCompletionSource<IResource>(
TaskCreationOptions.RunContinuationsAsynchronously);
await using var cluster = await IntegrationCluster
.StartAsync(
_ => Task.CompletedTask,
serverCreated: server => server.ConnectionReady += connection =>
_ = Task.Run(async () =>
{
try
{
fetched.TrySetResult(await connection.Get("client/exported"));
}
catch (Exception exception)
{
fetched.TrySetException(exception);
}
}),
populateClient: async warehouse =>
{
await warehouse.Put("client", new MemoryStore());
await warehouse.Put("client/exported", new Node { Id = 818 });
})
.WaitAsync(TimeSpan.FromSeconds(10));
var proxy = Assert.IsType<EpResource>(
await fetched.Task.WaitAsync(TimeSpan.FromSeconds(5)));
dynamic exported = proxy;
Assert.Equal(818, Convert.ToInt32(exported.Id));
}
[Fact] [Fact]
public async Task AuthenticatedHandshake_StoresTypedHeadersWithoutAuthenticationData() public async Task AuthenticatedHandshake_StoresTypedHeadersWithoutAuthenticationData()
{ {
@@ -234,6 +298,36 @@ public class SessionHeadersIntegrationTests
Assert.Equal(203, Convert.ToInt32(result)); Assert.Equal(203, Convert.ToInt32(result));
} }
[Fact]
public async Task AnonymousWebSocketTransport_AssignsSchemaDomain()
{
await using var cluster = await IntegrationCluster
.StartAsync(
async warehouse =>
{
await warehouse.Put("sys/websocket-anonymous", new EncryptedEchoResource());
},
useWebSocket: true,
anonymous: true)
.WaitAsync(TimeSpan.FromSeconds(10));
var remote = (EpResource)await Task.Run(async () =>
await cluster.Connection.Get("sys/websocket-anonymous"))
.WaitAsync(TimeSpan.FromSeconds(10));
var function = remote.Instance.Definition
.GetFunctionDefByName(nameof(EncryptedEchoResource.Echo));
var result = await remote._Invoke(
function.Index,
new Map<byte, object>
{
[0] = 42,
});
Assert.Equal(42, Convert.ToInt32(result));
Assert.False(string.IsNullOrWhiteSpace(
Assert.Single(cluster.Server.Connections).RemoteDomain));
}
[Fact] [Fact]
public async Task WrongAuthenticatedSessionKey_FailsPromptlyDuringKeyConfirmation() public async Task WrongAuthenticatedSessionKey_FailsPromptlyDuringKeyConfirmation()
{ {
+44
View File
@@ -0,0 +1,44 @@
using Esiur.Data;
using Esiur.Resource;
namespace Esiur.Tests.Unit;
public sealed class RuntimeCasterRecordTests
{
[Fact]
public void DynamicRecordMaterializesAsDeclaredRecordType()
{
var warehouse = new Warehouse();
var nestedDefinition = warehouse.GetLocalTypeDefByType(typeof(NestedRecord));
var targetDefinition = warehouse.GetLocalTypeDefByType(typeof(TargetRecord));
var nested = new Esiur.Data.Record(nestedDefinition)
{
[nameof(NestedRecord.Name)] = "edge-1",
};
var source = new Esiur.Data.Record(targetDefinition)
{
[nameof(TargetRecord.Enabled)] = true,
[nameof(TargetRecord.Count)] = (short)7,
[nameof(TargetRecord.Nested)] = nested,
};
var converted = Assert.IsType<TargetRecord>(
RuntimeCaster.Cast(source, typeof(TargetRecord)));
Assert.True(converted.Enabled);
Assert.Equal(7, converted.Count);
Assert.Equal("edge-1", converted.Nested.Name);
}
private sealed class TargetRecord : IRecord
{
public bool Enabled { get; set; }
public int Count { get; set; }
public NestedRecord Nested { get; set; } = new();
}
private sealed class NestedRecord : IRecord
{
public string Name { get; set; } = string.Empty;
}
}
@@ -63,8 +63,8 @@ public static class ConfigurationResolver
static readonly string[] ValidSchemes = ["ep", "eps", "ws", "wss"]; static readonly string[] ValidSchemes = ["ep", "eps", "ws", "wss"];
/// <summary> /// <summary>
/// Accepts <c>ep(s)://host:port</c> (a bare Esiur endpoint, connects at the /// Accepts <c>ep(s)://host[:port]</c> (a bare Esiur endpoint, connects at the
/// WebSocket root) as well as <c>ws(s)://host:port/path</c> (for hosts like /// WebSocket root) as well as <c>ws(s)://host[:port]/path</c> (for hosts like
/// ASP.NET Core's <c>MapEsiur("/esiur")</c> that mount the WebSocket route /// ASP.NET Core's <c>MapEsiur("/esiur")</c> that mount the WebSocket route
/// somewhere other than root) — see <see cref="EndpointParser"/> for how /// somewhere other than root) — see <see cref="EndpointParser"/> for how
/// the two forms are dialed. /// the two forms are dialed.
@@ -75,37 +75,12 @@ public static class ConfigurationResolver
|| !ValidSchemes.Contains(uri.Scheme, StringComparer.OrdinalIgnoreCase) || !ValidSchemes.Contains(uri.Scheme, StringComparer.OrdinalIgnoreCase)
|| string.IsNullOrWhiteSpace(uri.Host) || string.IsNullOrWhiteSpace(uri.Host)
|| !string.IsNullOrEmpty(uri.UserInfo) || !string.IsNullOrEmpty(uri.UserInfo)
|| !HasExplicitPort(endpoint)) || uri.Port == 0
|| uri.Port > ushort.MaxValue)
throw new CliException( throw new CliException(
$"Endpoint \"{endpoint}\" is not a valid ep:// or ws:// endpoint with an explicit port.", $"Endpoint \"{endpoint}\" is not a valid ep:// or ws:// endpoint.",
ExitCodes.InvalidArguments); ExitCodes.InvalidArguments);
} }
static bool HasExplicitPort(string endpoint)
{
var schemeEnd = endpoint.IndexOf("://", StringComparison.Ordinal);
if (schemeEnd < 0)
return false;
var authorityStart = schemeEnd + 3;
var authorityEnd = endpoint.IndexOfAny(['/', '?', '#'], authorityStart);
if (authorityEnd < 0)
authorityEnd = endpoint.Length;
var authority = endpoint[authorityStart..authorityEnd];
var at = authority.LastIndexOf('@');
if (at >= 0)
authority = authority[(at + 1)..];
var colon = authority.StartsWith('[')
? authority.IndexOf(']') + 1
: authority.LastIndexOf(':');
return colon > 0
&& colon < authority.Length - 1
&& authority[colon] == ':'
&& ushort.TryParse(authority[(colon + 1)..], out var port)
&& port > 0;
}
} }
public static class DurationParser public static class DurationParser
+3 -3
View File
@@ -19,13 +19,13 @@ Runtime-specific publishes are configured as self-contained, single-file, and no
Create and verify a password-authenticated profile: Create and verify a password-authenticated profile:
```console ```console
esiur login production "ep://host:${ESIUR_PORT}" --provider password --identity ahmed esiur login production "ep://host" --provider password --identity ahmed
``` ```
The password prompt does not echo input. For automation, supply it on standard input: The password prompt does not echo input. For automation, supply it on standard input:
```console ```console
printf '%s' "$ESIUR_PASSWORD" | esiur login production "ep://host:${ESIUR_PORT}" \ printf '%s' "$ESIUR_PASSWORD" | esiur login production "ep://host" \
--provider password --identity ahmed --password-stdin --provider password --identity ahmed --password-stdin
``` ```
@@ -86,7 +86,7 @@ Every operational command accepts a saved profile or a temporary endpoint:
```console ```console
esiur --profile production describe sys/service esiur --profile production describe sys/service
esiur --endpoint "ep://host:${ESIUR_PORT}" query sys --output json esiur --endpoint "ep://host" query sys --output json
esiur get sys/service Name --timeout 30s esiur get sys/service Name --timeout 30s
``` ```