Refactor SIGDS docs, archive DEC log, add pipe tests - Updated all references to AcSignalRDataSource docs to new SIGNALR_DATASOURCE/README.md location; introduced SIGDS topic and paired issues/TODO files. - Implemented new Decision Log archival policy: last-15-active entries remain, older entries moved to year-month archive (LLMP-DEC-65, 67); updated docs-archive skill for two-rule rotation. - Added new SIGDS architectural TODO (ACCORE-SIGDS-T-D9F2) for relocating DataSource code. - Updated doc tables, glossaries, and conventions for SIGDS. - Added AcBinarySerializerPipeParallelTests.cs for parallel serialization/deserialization round-trip tests. |
||
|---|---|---|
| .. | ||
| README.md | ||
| SIGNALR_DATASOURCE_ISSUES.md | ||
| SIGNALR_DATASOURCE_TODO.md | ||
README.md
SignalR DataSource
Change-tracked real-time collection on top of the SignalR transport layer. Source: SignalRs/AcSignalRDataSource.cs in AyCode.Services.Server.
For the underlying transport (tag system, wire protocol, client base) see
AyCode.Services/docs/SIGNALR/README.md. For server hub infrastructure see../SIGNALR/README.md.
Overview
AcSignalRDataSource is an abstract generic IList<T> that synchronizes with the server via CRUD tags. It handles change tracking, rollback, sync state, and pluggable Binary/JSON+GZip deserialization — so consuming code works with a regular list while the DataSource manages communication.
public abstract class AcSignalRDataSource<TDataItem, TId, TIList> : IList<TDataItem>, IList, IReadOnlyList<TDataItem>
where TDataItem : class, IId<TId> // entity with ID
where TId : struct // Guid, int, etc.
where TIList : class, IList<TDataItem> // List<T> or AcObservableCollection<T>
Consumers must subclass — the class is abstract:
public class MySampleDataSource : AcSignalRDataSource<MyEntity, Guid, AcObservableCollection<MyEntity>>
{
public MySampleDataSource(AcSignalRClientBase client, SignalRCrudTags tags)
: base(client, tags) { }
}
Constructor:
new MySampleDataSource(
AcSignalRClientBase signalRClient, // transport client
SignalRCrudTags crudTags, // 5 tags for CRUD operations
object[]? contextIds = null // optional server-side filter context
)
SignalRCrudTags
A sealed class that bundles 5 independent tag integers — one per CRUD operation. Tags are NOT sequential offsets; each tag is independently assigned:
public sealed class SignalRCrudTags(
int getAllTag,
int getItemTag,
int addTag,
int updateTag,
int removeTag)
Usage (consuming project — domain-agnostic example):
public abstract class MyAppSignalRTags : AcSignalRTags
{
public const int SampleGetAll = 300;
public const int SampleGetItem = 301;
public const int SampleAdd = 302;
public const int SampleUpdate = 303;
public const int SampleRemove = 304;
}
var crudTags = new SignalRCrudTags(
MyAppSignalRTags.SampleGetAll,
MyAppSignalRTags.SampleGetItem,
MyAppSignalRTags.SampleAdd,
MyAppSignalRTags.SampleUpdate,
MyAppSignalRTags.SampleRemove
);
Tag lookup: GetMessageTagByTrackingState(TrackingState) maps tracking state to the corresponding tag via switch expression.
Serializer Selection
public AcSerializerType SerializerType { get; set; } = AcSerializerType.Binary;
Controls the deserialization format on the raw byte[] load path (see Data Loading). Must match the server's SerializerOptions.SerializerType for that endpoint. Default: Binary. Override to JsonGZip for JSON datasources.
Data Loading
await dataSource.LoadDataSource(clearChangeTracking: true); // sync-wait transport
await dataSource.LoadDataSourceAsync(clearChangeTracking: true); // async callback path
await dataSource.LoadDataSource(fromSource, refreshDataFromDbAsync: false,
setSourceToWorkingReferenceList: false, clearChangeTracking: true); // in-memory load (no transport)
await dataSource.LoadDataSourceFromResponseData(responseData, serializerType,
refreshDataFromDbAsync: false, setSourceToWorkingReferenceList: false,
clearChangeTracking: true); // pre-fetched payload
await dataSource.LoadItem(id); // single item by ID
// fires OnDataSourceItemChanged
// with TrackingState.Get
Three deserialization paths in LoadDataSourceFromResponseData:
-
Raw byte[] (
responseData is byte[]) — consumer-side deserialize:AcSerializerType.Binary+IAcObservableCollection:BeginUpdate()→AcBinaryDeserializer.PopulateMerge→EndUpdate()(single batched UI notification).AcSerializerType.Binary+ plainIList<T>:AcBinaryDeserializer.Populate.AcSerializerType.JsonGZip+IAcObservableCollection:GzipHelper.DecompressToString→PopulateFromJson.AcSerializerType.JsonGZip+ plainIList<T>:GzipHelper.DecompressToString→JsonTo.
-
Typed object (
responseData is TIList) — protocol already deserialized:IAcObservableCollection:BeginUpdate→Clear+foreach Add→EndUpdate.- Otherwise: direct
Clear+foreach Add.
-
Fallback re-serialize (responseData neither
byte[]norTIList— e.g., bareList<T>in test scenarios without protocol):AcBinarySerializer.Serialize(responseData)→ bytes → process as Path 1 (Binary).
Context/Filtering: ContextIds (object[]) and FilterText (string) are sent with every GetAll request for server-side filtering.
Change Tracking
Each modified item is wrapped in TrackingItem<TDataItem, TId>:
| Field | Purpose |
|---|---|
TrackingState |
Add, Update, Remove, Get |
CurrentValue |
Current item reference |
OriginalValue |
Clone for rollback (TrackingItemHelpers.JsonClone or ReflectionClone) |
The ChangeTracking<TDataItem, TId> class manages the list of tracked items.
CRUD Operations
Add(item): → TrackingState.Add + InnerList.Add(item)
Add(item, autoSave): → optionally calls SaveItem after
AddOrUpdate(item, autoSave): → exists? Update : Add (TrackingState auto-determined)
Insert(index, item): → TrackingState.Add + InnerList.Insert(index, item)
Insert(index, item, autoSave): → optionally calls SaveItem after
Update(i, item, autoSave): → TrackingState.Update + clone original + InnerList[i] = item
Update(item, autoSave): → as Update(i, ...) by item.Id lookup
Remove(id, autoSave): → TrackingState.Remove + clone original + InnerList.RemoveAt
Remove(item, autoSave): → as Remove(id) by item.Id
TryRemove(id, out item): → no-throw remove (returns bool)
RemoveAt(index, autoSave): → by index
autoSave parameter — if true, immediately calls SaveItem() for that single change after the local mutation.
Manual tracking: SetTrackingStateToUpdate(item) marks an existing item as modified without replacing it — useful when properties are mutated in-place.
Additional collection helpers
| Member | Returns | Purpose |
|---|---|---|
AddRange(IEnumerable) |
void |
batch insert (observable-aware: IAcObservableCollection.AddRange or List<T>.AddRange, fallback per-item Add) |
TryGetValue(id, out item) |
bool |
ID-keyed lookup |
TryGetIndex(id, out index) |
bool |
index-keyed lookup |
IndexOf(id) / IndexOf(item) |
int |
-1 if not found |
Contains(item) |
bool |
by ID |
AsReadOnly() |
ReadOnlyCollection<TDataItem> |
wrap |
BinarySearch(...) |
int |
currently throws NotImplementedException (see SIGNALR_DATASOURCE_ISSUES.md) |
Events
| Event | Signature | Fires when |
|---|---|---|
OnDataSourceItemChanged |
Func<ItemChangedEventArgs<T>, Task>? |
After each item is saved or LoadItem completes (TrackingState in args) |
OnDataSourceLoaded |
Func<Task>? |
After LoadDataSource* completes |
OnSyncingStateChanged |
Action<bool>? |
On 0→1 (true) and 1→0 (false) sync transitions |
ItemChangedEventArgs<T> carries Item: T and TrackingState: TrackingState.
SaveChanges
| Method | Returns | Transport pattern | Use case |
|---|---|---|---|
SaveChanges() |
Task<List<TrackingItem>> (remaining failures) |
Sync-wait via ContinueWith |
Caller needs to know what failed |
SaveChangesAsync() |
Task (void) |
Fire-and-forget callback (Action<SignalResponseDataMessage>) |
Background save, no result inspection |
SaveItem(id) |
Task<TDataItem> |
Sync-wait | Save a single tracked item |
SaveItem(id, trackingState) |
Task<TDataItem> |
Sync-wait | Save with explicit state |
SaveItem(item, trackingState) |
Task<TDataItem> |
Sync-wait | Save without lookup |
Both batch flows:
BeginSync()
for each tracked item:
tag = CrudTags.GetMessageTagByTrackingState(state)
response = SignalRClient.PostDataAsync(tag, item)
on success: ProcessSavedResponseItem → CopyTo InnerList item, remove tracking,
fire OnDataSourceItemChanged
on failure: TryRollbackItem() → restore OriginalValue
EndSync()
Rollback
bool TryRollbackItem(TId id, out TDataItem? originalValue);
void Rollback(); // reverts all tracked changes
TryRollbackItem restores OriginalValue to InnerList:
TrackingState.Add: removes item entirely (no original existed).TrackingState.Update/Remove: re-appliesOriginalValue.CopyTo(InnerList[index]), or re-adds if missing.- The tracking entry is removed in either case.
Rollback() iterates and calls TryRollbackItem for every tracked item.
Sync State
private int _activeSyncOperations; // Interlocked counter
BeginSync(): Interlocked.Increment → fires OnSyncingStateChanged(true) on 0→1
EndSync(): Interlocked.Decrement → fires OnSyncingStateChanged(false) on 1→0
IsSyncing: _activeSyncOperations > 0
UI binds to IsSyncing to show loading indicators. The counter supports nested sync operations.
Working Reference List
dataSource.SetWorkingReferenceList(externalList);
// Now dataSource operates directly on externalList — same reference, no copy
TIList innerList = dataSource.GetReferenceInnerList();
bool hasRef = dataSource.HasWorkingReferenceList;
Useful when the UI already has a bound collection and you want the DataSource to manage it in-place.
Locking Strategy
| Lock | Scope | Used by |
|---|---|---|
object _syncRoot |
Synchronous | Count, Contains, IndexOf, GetEnumerator, Add (no-save), Insert (no-save), CopyTo |
SemaphoreSlim _asyncLock |
Asynchronous | LoadDataSource*, AddOrUpdate, Update, Remove (with save), SaveItem, SaveChanges* |
GetEnumerator() returns InnerList.ToList().GetEnumerator() — safe copy to avoid mutation during iteration.
Relationship to Transport
The DataSource is a consumer of the SignalR transport, not part of it:
DataSource.SaveChanges()
→ CrudTags.GetMessageTagByTrackingState(state) → tag
→ AcSignalRClientBase.PostDataAsync(tag, item) ← transport layer
→ OnReceiveMessage(tag, bytes, requestId) ← wire protocol
→ Server method with [SignalR(tag)] ← tag dispatch
Projects can also call the transport directly without DataSource — see AyCode.Services/docs/SIGNALR/README.md.
Cross-cutting:
ACCORE-SIG-I-T7S2(GetAll raw byte[] for populate/merge) lives in../SIGNALR/SIGNALR_ISSUES.mdbecause it spans transport + DataSource concerns equally.
Key Source Files
| Component | Path |
|---|---|
| DataSource (abstract base) | SignalRs/AcSignalRDataSource.cs (in AyCode.Services.Server) |
Tracking helpers (JsonClone / ReflectionClone) |
SignalRs/TrackingItemHelpers.cs (in AyCode.Services.Server) |
| CRUD tags | SignalRs/SignalRCrudTags.cs (in AyCode.Services) |
| Transport client | SignalRs/AcSignalRClientBase.cs (in AyCode.Services) |