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
@@ -13,15 +13,27 @@ public class CustomEventOccurredInfo
public readonly object Value;
public readonly object Issuer;
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 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;
EventDef = eventDef;
Receivers = receivers;
Issuer = issuer;
Value = value;
Cursor = cursor;
RecordedAt = recordedAt;
}
}
+16 -1
View File
@@ -15,12 +15,27 @@ namespace Esiur.Resource
public readonly IResource Resource;
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;
Value = value;
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)
{
}
}
}
+227 -23
View File
@@ -49,6 +49,8 @@ public class Instance
List<ulong?> ages = new();
List<DateTime?> modificationDates = new();
private ulong instanceAge;
private Guid streamGeneration;
private readonly object journalSync = new();
private byte hops;
private DateTime instanceModificationDate;
@@ -331,12 +333,34 @@ public class Instance
}
/// <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>
public ulong Age
{
get { return instanceAge; }
internal set { instanceAge = value; }
get { lock (journalSync) return instanceAge; }
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>
@@ -454,6 +478,16 @@ public class Instance
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>
/// If True, the instance can be stored to disk.
@@ -477,24 +511,25 @@ public class Instance
IResource res;
if (this.resource.TryGetTarget(out res))
{
instanceAge++;
var now = DateTime.UtcNow;
lock (journalSync)
{
var cursor = NextCursor();
var now = DateTime.UtcNow;
ages[pt.Index] = instanceAge;
modificationDates[pt.Index] = now;
ages[pt.Index] = cursor.Revision;
modificationDates[pt.Index] = now;
instanceModificationDate = now;
//if (pt.HasHistory)
//{
// store.Record(res, pt.Name, value, ages[pt.Index], now);
//}
//else //if (pt.Storage == StorageMode.Recordable)
//{
store.Modify(res, pt, value, ages[pt.Index], now);
//}
store.Modify(res, pt, value, cursor.Revision, now);
CommitJournal(res, new ResourceJournalEntry(
cursor,
now,
ResourceJournalEntryKind.PropertyModified,
pt.Index,
value), pt.Historical);
//ResourceModified?.Invoke(res, pt.Name, value);
PropertyModified?.Invoke(new PropertyModificationInfo(res, pt, value, instanceAge));
PropertyModified?.Invoke(new PropertyModificationInfo(res, pt, value, cursor, now));
}
}
}
@@ -526,7 +561,21 @@ public class Instance
IResource 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;
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))
{
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))
{
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="resource">Resource to manage.</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.store = store;
@@ -763,6 +953,17 @@ public class Instance
this.id = id;
this.name = name ?? "";
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);
//children = new AutoList<IResource, Instance>(this);
@@ -779,6 +980,7 @@ public class Instance
if (resource is IDynamicResource dynamicResource)
{
this.definition = dynamicResource.ResourceDefinition;
warehouse.RegisterDynamicTypeDef(this.definition);
}
else
{
@@ -796,6 +998,8 @@ public class Instance
// connect events
if (!(resource is EpResource))
{
if (resource is IDynamicResourceEventSource dynamicEventSource)
dynamicEventSource.ResourceEventOccurred += EmitResourceEventByIndex;
Type t = ResourceProxy.GetBaseType(resource);
@@ -12,15 +12,30 @@ public struct PropertyModificationInfo
public readonly PropertyDef PropertyDef;
public string Name => PropertyDef.Name;
public readonly ulong Age;
public ulong Revision => Cursor.Revision;
public readonly ResourceCursor Cursor;
public readonly DateTime RecordedAt;
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;
PropertyDef = propertyDef;
Age = age;
Cursor = cursor;
Age = cursor.Revision;
RecordedAt = recordedAt;
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);
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);
@@ -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)
{
return _proxyTypeDefs[domain];
@@ -1317,6 +1366,7 @@ public class Warehouse
public bool TryRegisterRemoteTypeDef(string domain, RemoteTypeDef typeDef)
{
domain = string.IsNullOrWhiteSpace(domain) ? "anonymous" : domain;
lock (_typeDefsLock)
{
if (!_remoteTypeDefs.ContainsKey(domain))