Event Log
Simulation events — alarms, automation events, milestones, errors — are recorded into an embedded relational event log and shown in the Events view.
org.simantics.event.db— the event store, the writer and the event source registry. No user interface, no Simantics database dependency.org.simantics.event— the Events view, built on NatTable.org.simantics.event.ontology— event types and the view's filter preferences.
Why not the Simantics database
The previous implementation stored every event as a resource. One event cost
roughly a dozen statements and literals, spread over EVENT.EventLog →
EVENT.EventSlice → EVENT.Event. That representation could not scale:
- Storage. A record of eight small fields turned into a dozen graph objects. The event log had to be capped at about a thousand events, and older events were silently discarded as the run went on.
- Queries. Filtering and sorting meant walking every event resource and reading its properties one relation at a time. There is no way to ask the graph for "the 200 events between positions 50000 and 50200 ordered by time".
- Event pairing. Matching a return event to the event it returns was a linear scan over every event of the source module, performed by a background job that re-ran as the log grew.
- The view.
GraphExplorerCompositekeeps a live query per visible node and reacts to every change. Past about a thousand rows this stops being viable regardless of how the data is stored.
A record-oriented log is exactly what a relational database is for, so that is what it now uses.
Choosing the database
The requirements were: pure embedding with no server process, one file per log so that a log can be dropped into an initial condition as-is, Java, an OSGi bundle, a license compatible with EPL-1.0 distribution, high append rates and indexed range reads.
| Candidate | Single file | Java-only | File format stability | Verdict |
|---|---|---|---|---|
SQLite (org.xerial:sqlite-jdbc) | yes | no, bundled JNI | guaranteed backwards compatible | chosen |
| H2 | yes (*.mv.db) | yes | broken across major versions (1.4 → 2.x) | rejected |
| HSQLDB | no — .script, .data, .log, .properties, .lobs | yes | good | rejected |
| Apache Derby | no — a directory | yes | good | rejected |
| DuckDB | yes | no, JNI | stable only since 1.0; tuned for scans, not row appends | rejected |
SQLite wins on the two points that matter most here.
Single file. An initial condition stores its event log as a file contribution next to the snapshot and the history archive. With SQLite the log is that file: saving it is a compacting copy, not an export into some other format. HSQLDB and Derby are disqualified outright by this.
Format stability. Initial conditions are long-lived artifacts that a later version of the product must still be able to open. The SQLite project commits to keeping the file format readable indefinitely. H2, the only serious pure-Java single-file alternative, has broken its file format across major versions before, which would make old initial conditions unreadable after an upgrade.
The cost is native code: sqlite-jdbc extracts a platform-specific library on
first use. Simantics products already load native solvers through JNI, so this
is not a new class of problem, and the driver ships binaries for every supported
platform inside one OSGi bundle.
Why not an event store
An event log is an append-only stream of immutable, typed, timestamped records, which is precisely the shape an event-sourcing database is built for. The obvious candidate is KurrentDB (formerly EventStoreDB). It is not usable here, for four independent reasons, and understanding why is worth more than the verdict.
| Requirement | KurrentDB |
|---|---|
| Embedded, no separate server | no — a C#/.NET server daemon; no JVM embedding is possible short of hosting a .NET runtime |
| Single database file | no — separate data/, index/ and log/ directories, data in 256 MB chunk files |
| Redistributable under EPL-1.0 | no — Kurrent License v1 is source-available and non-compete, not OSI-approved |
| Ad-hoc filtering and sorting | no — stream reads, subscriptions and projections only |
Its Java gRPC client is real, and the platform already ships grpc and netty, so the client side would be fine. The server is the problem: starting and stopping a .NET process per workspace inside a desktop modelling tool.
Two deeper mismatches would remain even if it embedded cleanly.
The read pattern is wrong. The Events view has to answer "rows 50000 to 50256, filtered by six toggles, ordered by the message column". Event-sourcing stores deliberately refuse that; you project into a read model instead. The read model for a sortable, filterable, paged table is a relational database, so this implementation would exist anyway, behind a server.
Events here are not immutable. The view hides and unhides events, marks and
unmarks milestones, renumbers every milestone label, sets a baseline, and
deletes arbitrary selected rows. None of those are updates in an event store:
each becomes a correction event that every read has to fold, and
correctMilestoneLabels — one UPDATE here — becomes a burst of events per
renumbering. Deleting an arbitrary event is not supported at all, since $tb
truncates only a prefix and soft delete takes a whole stream.
Initial condition saving would also regress. VACUUM INTO produces one
consistent file in milliseconds; an event store would need the stream exported
and replayed into a server on load, which is structurally the transferable
graph export this change set out to remove.
Several things this implementation builds by hand are things an event store
would provide: $all positions for total order, $maxCount for retention, and
catch-up subscriptions in place of EventStoreListener and the debounced reload
job. If the requirement were ever a shared, multi-user, server-side event
history — several engineers watching one running simulation, audit-grade
immutability, retention across sessions, events consumed by other services —
the server would stop being a cost and those features would start earning their
keep. SQLite would still sit in front of the table.
Schema
One file holds one log. PRAGMA user_version carries the schema version.
event_meta(key, value) -- discarded event count, baseline, name
event_type(id, uri, label, severity)
event(id, ordinal, time, type_id, type_number, severity, source_name,
tag, message, label, description, flags, milestone_label,
return_of, returned_by, return_time)Three things are deliberately denormalized onto the event row:
severity, so that the severity filters are a plain indexed predicate rather than a join;return_time, so that the Return Time column needs no self-join;- the event type's
labelandseverityinevent_type, so that a stored log stays fully readable — and exportable — without the ontology that defined its types.uriis what the view uses to resolve the type's icon.
flags is a bitmask (hidden, milestone, return event, no-return) replacing what
used to be four tag statements per event.
Reading at scale
EventStore.queryIds(EventQuery) evaluates the filter and the sort in the
database and returns just the matching identities, in order. The view keeps that
array — eight bytes per event — and reads the rows it is about to paint one page
of 256 at a time through EventStore.getEvents.
Measured on a million-event log (152 MB on disk):
| Operation | Time |
|---|---|
Full ordered identity scan (time, indexed) | 390 ms |
Identity scan sorted by message (not indexed) | 1.0 s |
| Substring filter over tag, message and source | 710 ms |
| Reading one 256-row page | 13 ms |
Scrolling only ever costs a page read. Re-filtering and re-sorting run in a background job, so the table stays responsive while they do.
Appearance
NatTable does not follow the Eclipse theme. It paints from its own
ThemeConfiguration rather than from the CSS engine, so a table left alone
keeps a light appearance inside a dark workbench.
EventTableTheme therefore carries a look per Eclipse theme — NatTable's modern
theme for light, its dark theme for dark, each subclassed for the Events table —
and EventTableThemeTracker subscribes to the workbench's theme-changed event so
that switching the Eclipse theme restyles an open table rather than requiring it
to be reopened. A theme counts as dark when its identifier says so, which is a
heuristic, but ITheme states nothing more direct and dark themes name
themselves accordingly.
The dark subclass exists because the stock one paints on pure black, which reads
as a hole cut out of a workbench whose panels are around #2F2F2F. Both
subclasses also align left rather than center, since the columns hold text, and
use the workbench font so the table matches the views around it.
Two things have to be reapplied on a theme change, not one: the NatTable theme carries the table's own colours and painters, while the per-row decorations — bold milestones, italic hidden events, greyed return events — are registered separately and their dimmed foreground differs between the two looks.
Row banding comes from the theme, but only reaches rows carrying the alternating
labels, so AlternatingRowConfigLabelAccumulator sits alongside the event label
accumulator in an AggregateConfigLabelAccumulator. Order matters: the event
labels go first, because NatTable resolves each style attribute from the first
label that defines it, which lets the event styles supply the font and foreground
while the banding still supplies the background.
Writing
EventWriter queues events and appends them in batches in one transaction, from
a background thread. The batching policy is the one the old graph writer used:
flush when the producer goes quiet, when a second has passed since the last
flush, or when the queue exceeds 500 events.
Return event pairing happens during the insert. A return event is matched to its
counterpart by (source_name, ordinal), which is an indexed lookup, and both
rows are linked to each other. This replaces the background resolver job that
used to scan the log.
EventStore.trim(maxEvents) discards the oldest events once the log grows past
its limit and remembers how many have been discarded, which is what the note
above the Events table reports.
Event sources
An event log reaches the Events view as an IEventSource registered with
EventSources:
EventStoreSource source = new EventStoreSource(id, name, store, priority);
source.contextUri(modelUri);
EventSources.getInstance().register(source);The view lists every registered source in a selector and shows the one the user
picks, so several experiments can record events at the same time and a new kind
of event contributor needs no changes to the view. getContextUri() names the
entity the events were produced from; the view uses it to resolve an event's
source name back into a model resource when the user asks to see it, and the
chart milestone rulers use it to find the log belonging to their run.
Integrating a product
A product that records events owns one store per simulation run and publishes it. The pattern is:
- Open a store when the run is activated, somewhere the product controls — the workspace temporary area is the natural place, since the authoritative copy is the one saved with the run's stored state.
- Register an
IEventSourceover it for as long as the run is active, withcontextUrinaming the model the events are produced from, and unregister it on disposal. - Write through an
EventWriter, which batches appends off the producer's thread. Give it the retention limit the product wants; the previous graph-based log could hold about a thousand events, whereas a relational log of a million is a few hundred megabytes and still pages instantly. - Save with
EventStore.copyTo, which is a compactingVACUUM INTO. It can be taken while the simulation is still running, and the result is a single self-contained file with no accompanying journal, so it drops straight into whatever container the product stores a run's state in. Apply any time shift to the copy rather than to the live store. - Close the store and delete its file when the run is disposed.
Migrating previously stored events
Products that stored events before this change have their own legacy formats to read, so migration is theirs to implement and document. Two things about the store make it straightforward.
Legacy material is almost certainly a graph of EVENT.Event resources, and
those types are retained precisely so it still resolves. A migrator can import
such a graph into a scratch memory-persistent virtual graph, read the events out
of it, and hand them to EventStore.insert as EventBuilders ordered by
timestamp; correctMilestoneLabels then renumbers the milestones and
setBaseline carries the baseline across. Return pairing falls out of the
insert, so a migrated log ends up with the pairing the old resolver had to scan
for.
Migrate on load and be forgiving about it: a log that cannot be read should be logged and skipped rather than failing the activation, since starting with an empty log beats not starting.
Whether to remove the legacy material afterwards is the one real decision. Keep it and stored state stays loadable by older versions, but it now holds two copies of its events that drift apart on every save, and if the legacy form was a database literal then the storage this change exists to reclaim is never reclaimed. Remove it and both problems go, at the cost of making the migration of each stored state a one-way door. Removing it on save rather than on load is the balance worth starting from: loading stays non-destructive, a failed save destroys nothing, and the decision is taken at the point where the user has already chosen to write.
One piece of cleanup is worth doing at the same time. Event logs used to live in
the workspace-persistent experiments virtual graph, attached to the model with
EVENT.HasEventLog, and were deleted when the run was disposed; a workspace can
still hold one left behind by a crash. Nothing reads them any more, so a product
should detach and remove them on activation rather than leave them as dead
weight for the life of the workspace. They need no migration of their own: the
persistent copy of a run's events is the one in its stored state.
What remains in the ontology
EVENT.EventType instances and EVENT.EventType.severity are still ontology
resources: they define the event classification and carry the icons. The
EVENT.Event, EVENT.EventLog and EVENT.EventSlice types are retained only
so that ontologies and databases created against earlier versions still resolve.
Do not create new instances of them.