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. GraphExplorerComposite keeps 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.

CandidateSingle fileJava-onlyFile format stabilityVerdict
SQLite (org.xerial:sqlite-jdbc)yesno, bundled JNIguaranteed backwards compatiblechosen
H2yes (*.mv.db)yesbroken across major versions (1.4 → 2.x)rejected
HSQLDBno — .script, .data, .log, .properties, .lobsyesgoodrejected
Apache Derbyno — a directoryyesgoodrejected
DuckDByesno, JNIstable only since 1.0; tuned for scans, not row appendsrejected

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.

RequirementKurrentDB
Embedded, no separate serverno — a C#/.NET server daemon; no JVM embedding is possible short of hosting a .NET runtime
Single database fileno — separate data/, index/ and log/ directories, data in 256 MB chunk files
Redistributable under EPL-1.0no — Kurrent License v1 is source-available and non-compete, not OSI-approved
Ad-hoc filtering and sortingno — 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 label and severity in event_type, so that a stored log stays fully readable — and exportable — without the ontology that defined its types. uri is 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):

OperationTime
Full ordered identity scan (time, indexed)390 ms
Identity scan sorted by message (not indexed)1.0 s
Substring filter over tag, message and source710 ms
Reading one 256-row page13 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 IEventSource over it for as long as the run is active, with contextUri naming 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 compacting VACUUM 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.