Files
histsdk/docs/plans/histevents.md
T
Joseph Doherty a530ae0f10 docs/plans: import 2023 R2 gRPC analysis + HCAL reimpl roadmap
Version-control the planning docs alongside the code they describe:
- grpc-transport.md     — 2023 R2 gRPC transport analysis (sanitized source path)
- hcal-capability-matrix.md — HistorianAccess surface x gRPC ops x histsdk status x feasibility tiers
- hcal-roadmap.md       — ordered build plan M0-M4 + cross-cutting workstreams
- histevents.md         — how a HistorianEvent reaches the DB (client->wire->server)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:28:34 -04:00

17 KiB

How a HistorianEvent reaches the Historian DB files

Living analysis doc. Traces an event end-to-end: client API → wire → server storage backend (SQL Database vs history Blocks .dat) → read-back.

Evidence base: 2023 R2 aahClientManaged.dll (decompiled ArchestrA.HistorianAccess, HistorianEvent, HistorianEventPropertyType), native aahStorage.exe (string analysis), the recovered gRPC + CloudHistorian contracts, and the histsdk read-side reverse-engineering (CM_EVENT registration + event-row parser).

Status legend: proven (from binary) · 🔶 strong inference · open.


TL;DR

An event is not a distinct wire message. The client turns each HistorianEvent into a HistorianDataValue of type Event against the built-in CM_EVENT tag, marshals it into a native VTQ, and streams it like any tag value on a dedicated Event connection. Events are batched into an opaque serialized event data packet and delivered (with their own store-and-forward "event snapshot" path). On the server they are persisted into one of two backends, chosen by a server-configured EventStorageMode: a SQL Database, or the history Blocks (.dat) files.

HistorianEvent (Type + typed property bag)
  └─(AddStreamedValue)→ HistorianDataValue{Type=Event, TagKey=CM_EVENT, EventTime, Q=192}
       └─ HistorianEvent.PackToVtq → CCommonArchestraEventValue::PackToVtq (native value blob)
            └─ HISTORIAN_VALUE2 (44B; blob ptr @+33) → HistorianClient.AddHistorianValue → queue
                 └─ flush → EnqueueEventDataPacket{ byte[] SerializedBytes } (batched VTQs)
                      └─ SERVER: aahEventStorage.exe (InSQLEventSystem)
                           per-client event-tag pipeline → recovery-log WAL → backend:
                             • Blocks   → elastic snapshot → frozen → history .dat (Circular/Permanent)
                             • Database → ArchestrAEvents.EventStorage.Contract assembly → SQL (A2ALMDB)
                           → EventReplication (redundant historians)
                 └─ (offline) → store-and-forward → ForwardEventSnapshotBegin/…/End on reconnect
  read-back: Retr.StartEventQuery / SQL provider views (Events, v_AlarmEventHistory2, v_EventSnapshot)

1. The HistorianEvent object

Decompiled ArchestrA.HistorianEvent — a structured header plus a typed property bag:

  • Header fields: ID/Id (Guid), Type/EventType (string, e.g. "Alarm.Set", "User.Write"), EventTime (DateTime), ReceivedTime, Severity (ushort), Priority (ushort), IsAlarm, IsSilenced, System, Source, Source_Name, Area, Namespace, DisplayText.
  • Revision fields: RevisionVersion (ushort), Delete (bool), Update (bool) — events are revisable (see §4 UpdateEventStatus).
  • Property bag: AddProperty(name, value, HistorianEventPropertyType, …) with typed overloads. HistorianEventPropertyType (alphabetical enum): Blob, Boolean, Byte, Date, DateTime, Decimal, Double, Duration, Float, Guid, Hex, Int, Integer, Long, Short, String, Time, UnsignedByte, UnsignedInt, UnsignedLong, UnsignedShort, Undefined.

These map onto the wire property-bag the histsdk read parser already decodes (HistorianEventRowProtocol): typemarkers 0x02 Boolean, 0x10 Guid, 0x18 FILETIME, 0x31 Int32, 0x43 UTF-16 string, … — i.e. the write enum and the read typemarkers are two views of the same typed-value format. The event-send serialization is the inverse of that read parser.


2. Client send path — an event becomes a streamed VTQ

From decompiled ArchestrA.HistorianAccess (line refs into the decompile):

  1. Open an Event connection. HistorianConnectionArgs.ConnectionType = HistorianConnectionType.Event, ReadOnly = false (sample Step10.SendEvents).
  2. Default event tag. CreateDefaultEventTag() (:3006) registers tag CM_EVENT / "AnE Event" / TagDataType = Event and stores eventTagHandle. Same CM_EVENT registration histsdk reverse-engineered (RTag2 + EnsT2; tag id 353b8145-5df0-4d46-a253-871aef49b321).
  3. Wrap as VTQ. AddStreamedValue(HistorianEvent) (:3123):
    historianDataValue.objValue      = historianEvent;          // header + property bag
    historianDataValue.DataValueType = HistorianDataType.Event;
    historianDataValue.TagKey        = eventTagHandle;          // CM_EVENT
    historianDataValue.StartDateTime = historianEvent.EventTime;
    historianDataValue.OpcQuality    = 192;
    return AddStreamedValue((ConnectionIndex)1, historianDataValue, false, out error); // 1=Event
    
  4. Marshal + queue. The private AddStreamedValue (:3173):
    • builds a 44-byte native HISTORIAN_VALUE2 (InitBlockUnaligned(…,0,44)),
    • HistorianAccessUtil.ConvertManagedStructToUnmanagedStruct(value, &HV2, bVersioned…) — its case HistorianDataType.Event (HistorianAccessUtil:89) calls HistorianEvent.PackToVtq(out byte[]) to produce the event value blob, whose pointer is placed at HISTORIAN_VALUE2+33 (freed after send; offset 33 is the value-union pointer used for Event/String types),
    • HistorianClient.AddHistorianValue(client, &HV2, &err) (:3209) queues the VTQ into the native delivery buffer and returns immediately.

So an event uses the same streaming machinery as a process value; only DataValueType (Event) and the target tag (CM_EVENT) differ.

2a. Event value serialization — HistorianEvent.PackToVtq /🔶

HistorianEvent.PackToVtq (HistorianEvent:1392) populates a native CCommonArchestraEventStruct then hands it to the native packer CCommonArchestraEventValue::PackToVtq(…, 192, 192, vtq) (Q=192), associated with the built-in EVENT_TAGID / EVENT_TAGNAME (CTagMetadata.CommonArchestraEvent). The actual byte layout is produced in C++ — not visible in managed code — so pinning exact write bytes needs a wire/IL capture, exactly as the read side did. But the field set + order the managed code writes into the struct is now known:

SetReceivedTime  (uint64 FILETIME, from UniqueTime.GetUniqueFileTime — unique/monotonic)
SetEventType     (wchar* string, e.g. "Alarm.Set")
SetEventTime     (uint64 FILETIME, from EventTime)
SetId            (GUID)
SetRevisionVersion (uint16)
SetIsUpdate      (bool)        ← revision flags
SetIsDelete      (bool)
Namespace        (string, trimmed, non-printable-validated)
…then the typed property bag: Dictionary<string, Tuple<HistorianEventPropertyType, object>>

This matches HistorianEventRowProtocol on read: the property bag is name→(type,value) with the same typed-value encoding (typemarkers 0x02/0x10/0x18/0x31/0x43/…). So a managed event-send serializer is tractable: emit the header struct fields above, then the typed property bag in the read parser's format. The remaining unknown is only the exact native framing offsets — best obtained by capturing one PackToVtq output, then golden-byte testing.


3. Event transport / delivery pipeline (CloudHistorian + gRPC contracts)

Events have a dedicated, batched connection + delivery pipeline, distinct from tag data but structurally parallel:

Stage Event op Tag-data analogue
Open connection OpenEventConnection2 { byte[] ClientInfo } → { byte[] ServerInfo } OpenConnection
Send batch EnqueueEventDataPacket { byte[] SerializedBytes } EnqueueTagDataPacket { byte[] SerializedBytes }
Store-and-forward ForwardEventSnapshotBegin / ForwardEventSnapshot / ForwardEventSnapshotEnd ForwardSnapshot…
Revise UpdateEventStatus (revision write)

Key point: the event data packet is an opaque serialized byte buffer (SerializedBytes, DataMember d) — the queued event VTQs batched together, exactly the same envelope shape as the tag data packet. On-prem this is what the storage-streaming op (AddStreamValues) carries; in the cloud variant it is EnqueueEventDataPacket.

Validation surfaced via error codes: InvalidAlarmEventPropertyLength=212, AlarmEventPropertyHasNonPrintableChar=214, AlarmEventPropertyHasInvalidSpecialChar=215, AlarmEventPropertyNameIsAReservedName=216 — the server validates alarm/event property names + values on ingest.

Offline → events spool to the store-and-forward cache and replay as event snapshots (ForwardEventSnapshot*) on reconnect — a separate SF stream from tag-data snapshots.


4. Revisions / updates

HistorianEvent.Update / .Delete / .RevisionVersion + the contract's UpdateEventStatus op mean events are not write-once: an event can be re-sent to update or delete a previously stored event (e.g. alarm acknowledge/clear), bumping RevisionVersion.


5. The storage-backend switch — EventStorageMode

The client reads the server's event-storage backend from HISTORIAN_INFO byte offset 514 (HistorianAccess :5715):

EventStorageMode = (info[514] == -1) ? Unsupported
                 : (info[514] == 0)  ? Database     // SQL Server
                                     : Blocks;       // history .dat blocks

HistorianEventStorageMode ∈ { Database, Blocks, Unsupported }. The destination is a server decision; the client streams the same VTQ regardless.


6. Server side — where it lands (confirmed on the live local install)

The server-side event component is aahEventStorage.exe (service InSQLEventSystem, "AVEVA Historian Event System"; plus aahEventSvc.exe), at …\Wonderware\Historian\x64\aahEventStorage.exe. Its string table maps the full pipeline:

event packets / forwarded snapshots → per-client "event tag pipeline" → batch enqueue
   → Event Storage Recovery Log (WAL; "enqueuing N events to log",
        path SystemParameter EventStorageLogPath = C:\Historian\Data\Logs\EventStorage)
   → persist to the active backend:
        Block Storage  ("Enabled Block Storage for events")  → history .dat blocks
        Database       ("storing N events in database")      → SQL via loaded managed
              assembly ArchestrAEvents.EventStorage.Contract.EventStorageDatabaseConnection
              (e.g. ";Initial Catalog=A2ALMDB;Integrated Security=true;Encrypt=True;…")
   → also fed to EventReplication (aahReplication.exe) for redundant historians

So persistence is pluggable (a loadable connection assembly) and dual-mode, guarded by a recovery log. Which backend is live depends on configuration (the EventStorageMode of §5).

This historian = Block storage (verified)

  • C:\Historian\Data\Circular holds 527 .dat history blocks (Permanent empty); the EventStorage recovery log dir exists. aahEventStorage logs "Enabled Block Storage for events".
  • SDK-shape alarm/events are present and retrievable: Runtime.dbo.v_AlarmEventHistory2 returns 224 rows over the last 30 days.
  • A2ALMDB (the System-Platform alarm DB the connection string references) is not present here — that path is only used when integrated with AVEVA System Platform alarming. Absent it, ArchestrA events land in blocks, exactly as aahStorage.exe advertises ("Stores ArchestrA Event Data", snapshot→block).

The SQL surface is provider-backed views, not physical tables

In Runtime, the rich event objects are views with NULL OBJECT_DEFINITION — i.e. the historian's OLE DB History provider exposes them as virtual/extension tables that read the block store, not stored T-SQL:

  • Events, v_EventHistory, v_EventSnapshot, v_EventStringSnapshot, v_AlarmEventHistory2 (columns: EventStampUTC, AlarmState, TagName, Description, Area, Type, Value, Priority, Category, Provider, Operator, DomainName, UserFullName, MilliSec, …) — these are the read-back of the SDK alarm/event property bag.

So SELECT … FROM Events (and v_AlarmEventHistory2) is the provider reading the block store, which is why the handoff could query events even though they live in .dat blocks.

Database-mode physical store

When events ARE stored in SQL (Database mode / A2ALMDB integration), the writer is the loaded ArchestrAEvents.EventStorage.Contract connection assembly doing batched inserts ("storing N events in database", "creating event storage database connection role"). The exact table schema there is the A2ALMDB alarm schema (not present on this box to dump).

Read-back

Uniform regardless of backend: Retr.StartEventQueryGetNextEventQueryResultBuffer (provider) surfaces events from wherever they were stored — so histsdk ReadEventsAsync is mode-agnostic. Engine filter note: "EventTime filtering can only be specified through StartDateTime and EndDateTime".

6b. Two different "event" subsystems — don't conflate

Classic event detectors ArchestrA alarms/events (the SDK path)
What server-side detectors watching tag conditions client-streamed HistorianEvent (alarms, user events)
Config/store Runtime.dbo._EventTag (TagName, DetectorTypeKey, DetectorString, Action*, ScanRate, Edge, Priority) CM_EVENT / CommonArchestraEvent tag
History physical BASE TABLE Runtime.dbo.EventHistory (EventLogKey, TagName, DateTime, DetectDateTime, Edge); 30 rows block store (or A2ALMDB), surfaced via v_AlarmEventHistory2 / v_EventSnapshot
Source evaluated by the server sent via AddStreamedValue(HistorianEvent)

AddStreamedValue(HistorianEvent) feeds the right column (ArchestrA alarms/events) — it is not the classic EventHistory detector log.


7. Relationship to histsdk

  • histsdk implements event reads only (ReadEventsAsync via StartEventQuery); its CM_EVENT EnsT2/RTag2 dance is read-subscription registration.
  • Event writing is unimplemented but viable. Chain to replicate: Event-type connection → register CM_EVENT (done) → serialize HistorianEvent (header + typed property bag) into the event-VTQ value blob (inverse of HistorianEventRowProtocol) → batch into an event data packet → stream via AddStreamValues (2023 R2 gRPC: StorageService.AddStreamValues).

Open threads

  • 🔶 Event value blob: field set/order known (§2a); exact native framing still needs one CCommonArchestraEventValue::PackToVtq output capture + golden-byte test (mirror the read-side HistorianEventRowProtocol reverse-engineering). Now feasible locally — the live historian is installed, so the same instrument-and-capture approach used for reads applies.
  • EnqueueEventDataPacket.SerializedBytes packet framing (header + N event VTQs batched).
  • Database-mode store: server writer is aahEventStorage.exe loading the managed ArchestrAEvents.EventStorage.Contract connection assembly; SQL retrieval surface is the provider-backed Events / v_AlarmEventHistory2 / v_EventSnapshot views (NULL T-SQL def). This box runs Block storage (A2ALMDB absent). A2ALMDB physical schema still un-dumped (needs a System-Platform-integrated box).
  • ArchestraEvent vs CommonArchestraEvent: send path packs CommonArchestraEvent via EVENT_TAGID; both are event-tag schemas in CTagMetadata (the server stores either).
  • UpdateEventStatus wire payload for Update/Delete revisions.
  • EventStorage recovery-log (C:\Historian\Data\Logs\EventStorage) on-disk format (WAL).
  • 🔶 Decompile ArchestrAEvents.EventStorage.Contract.dll (managed) for the exact DB insert contract/schema — locate it (not under …\Wonderware\Historian; check GAC / Framework\Bin).

Changelog

  • Rev 4 (live local install): confirmed server side. aahEventStorage.exe (InSQLEventSystem) is the event store engine — per-client event-tag pipeline → recovery-log WAL → Block storage OR SQL (loadable ArchestrAEvents.EventStorage.Contract assembly) → EventReplication. This box uses Block storage (527 .dat in C:\Historian\Data\Circular; A2ALMDB absent). SQL Events/v_AlarmEventHistory2/v_EventSnapshot are provider-backed views over the blocks (NULL OBJECT_DEFINITION), not physical tables — v_AlarmEventHistory2 (224 rows/30d) is the SDK-event read surface. Distinguished the classic event-detector subsystem (_EventTag → physical EventHistory) from the ArchestrA alarm/event path (the SDK's target).
  • Rev 3: event value serialization pinned to native CCommonArchestraEventValue::PackToVtq via managed HistorianEvent.PackToVtq; documented the CCommonArchestraEventStruct field set/order (ReceivedTime, EventType, EventTime, Id, RevisionVersion, IsUpdate/IsDelete, Namespace, typed property bag) and the path to a managed send serializer.
  • Rev 2: HistorianEvent structure + HistorianEventPropertyType enum; client marshaling (HISTORIAN_VALUE2 / ConvertManagedStructToUnmanagedStruct / AddHistorianValue); dedicated event pipeline (OpenEventConnection2 / EnqueueEventDataPacket / ForwardEventSnapshot / store-and-forward); revisions (Update/Delete/UpdateEventStatus); Blocks-mode clarified (events = generic VTQ snapshots, no event-specific block code).
  • Rev 1: client send path, EventStorageMode switch, Blocks/Database backends, read-back.