Modification Tracking¶
Overview¶
A user in Hamburg changes a customer's address. A user in Munich has that same customer on screen, and a third JVM — a middle-tier server — holds it in a PDO cache. Nobody told either of them anything. This is the problem the modification tracker solves: it is the framework's change-notification bus, and it works across processes, across tiers, and across the database.
The design rests on a single idea: every tracked name owns a serial counter in a database table, and every write bumps it. A JVM that wants to know whether something changed only has to compare serials. The database is the single source of truth, so a client connected directly to the database and a client three tiers away from it observe the very same numbers — no message broker, no database-specific notification mechanism, no extra infrastructure.
Concretely:
ModificationTracker— the interface and the singleton. Lives intentackle-session, so the API stays free of any database dependency.DbModificationTracker— the implementation: a high-priority thread with its own session, polling for changes.PdoModificationTracker— the PDO-aware subclass that ordinary applications actually run.ModificationListener— what the application registers to be told about changes, most conveniently throughPdo.listen(...).MasterSerialEvent— a piggyback channel that lets a server push application-specific events down to a remote client, riding along on the tracker's poll.
The tracker is not a daemon thread. As long as it runs, the JVM will not terminate — and when it dies, the application dies with it (see Lifecycle). That is deliberate: an application that has silently lost its change notifications is an application whose caches are silently going stale, and Tentackle would rather stop than be wrong quietly. See Correctness First.
The Modification Table¶
Everything is built on one small table, mapped by
DbModification and
named modification:
| Column | Meaning |
|---|---|
tablename |
the tracked name — usually a table name, but any string |
id |
a unique numeric id for that name; id 0 is the master row |
serial |
the modification counter for that name |
The row with id = 0 is special: it is the master serial, the sum of all modifications of everything.
Every count updates two rows — the row of the tracked name, and the master row — in a single statement:
That single row is what makes polling cheap. A JVM that wants to know whether anything at all changed reads exactly one value:
If the master serial has not moved, nothing has changed anywhere and the poll is over — one round trip, one row, regardless of how many tables the application tracks. Only when the master serial does move does the tracker read all the individual serials and work out who needs to be notified.
Names need not correspond to a table. Anything can be tracked — an application-specific name like
"pricelist-recalculated" works exactly the same way; it simply gets a row with an id but no table behind it.
DbModification.addToModificationTable(...)
distinguishes the two cases: for a real entity table it seeds the serial from the highest tableserial found
in that table, and for a pure name it starts at 0.
The table is created and seeded by
DbModification.initializeModificationTable(...), which is also what happens implicitly when the tracker is
given a session and finds the table missing or unreadable, and explicitly when the application calls
invalidate().
Counting: How a Write Becomes a Serial¶
The persistence layer calls the tracker whenever it modifies data.
AbstractDbObject
invokes countModification() on insert, update and delete, and additionally
ModificationTracker.addDeletion(...) for deletes. Applications almost never call these themselves.
long countModification(Session session, String name);
void addDeletion(Session session, String name, long id, long tableSerial);
Both take an explicit session (or null for the thread-local one) because the count must be made in the
same session — and therefore the same transaction — as the modification it describes. Neither is ever
invoked for a remote session: a remote client's write happens on the server, and the server's tracker counts
it there.
The ModificationTally¶
Per tracked name the tracker keeps a
ModificationTally.
A tally does not write to the database on every count. It increments an in-memory pendingCount and
remembers, per session, the transaction number and the table serials consumed. The physical
UPDATE modification SET serial = serial + pendingCount happens later, in performPendingCount() — and only
when no transaction touching this name is still running:
public synchronized void performPendingCount() {
if (pendingCount > 0) {
sessionsInTx.keySet().removeIf(session -> !session.isTxRunning());
if (sessionsInTx.isEmpty()) {
// No more transactions running: flush to the database.
// This ensures that triggered listeners will see the committed data!
...
}
}
}
That guard is the whole point. A listener fires because a serial advanced; if the serial advanced while the writing transaction was still open, the listener would go and read data that is not committed yet — and see the old values, or block. Deferring the flush until every relevant transaction has ended guarantees that by the time anyone is notified, the data is there.
It is also a batching win: a loop saving five hundred rows produces five hundred pendingCount increments and
exactly one UPDATE on the modification table, flushed by the next poll.
The price is that countModification() cannot return the definitive serial. It returns
lastSerial + pendingCount — the guessed lowest value, documented as "could be higher already". Callers
that need certainty read the serial after the fact via getSerial(...).
Rollback correction¶
If the pending counts were simply left alone, a rolled-back transaction would still advance the serial and
invalidate everybody's caches for a change that never happened.
DbUtilities therefore
calls rollbackModification(session, txNumber) on every rollback, and each tally compares the count made by
that transaction against its total pendingCount:
- all pending counts came from the rolled-back transaction → drop them entirely. No serial moves, no cache is invalidated, nothing ever happened.
- other transactions contributed too → the tally cannot know whose numbers are whose, so it keeps the
count and instead records the consumed table serials as gaps in
TableSerialHistory.
The TableSerialHistory is the companion mechanism for entities that carry a tableserial column. It
remembers, in memory on the server connected to the database, which id/tableserial tuples correspond to
deletions, rolled-back writes, or repeated updates — so that when a cache later asks "what changed between
serial X and Y?", the gaps in the answer can be explained instead of causing a needless invalidation. See
PDO Caching and
Locking.
Operation Modes¶
ModificationTracker.isLocalClientMode() selects between two counting strategies. It is not a preference —
it is a consequence of the deployment.
| Local client mode | Optimized mode (default) | |
|---|---|---|
| Who | desktop app talking straight to the database, no middle tier | servers, and remote clients |
| Counting | performPendingCount() runs immediately, inside countModification() |
pending, flushed by the tracker thread once no transaction is running |
| Session | must use the thread-local session — the count has to join the caller's transaction | the tracker's own session |
| Tally state | no sessionsInTx bookkeeping, no TableSerialHistory |
full bookkeeping |
PdoModificationTracker.setSession(...) decides this automatically:
// enable local client mode if this is a non-server application with a local jdbc url.
// in all other cases use optimized mode.
Application application = Application.getInstance();
setLocalClientMode(!session.isRemote() && application != null && !application.isServer());
Local client mode exists because there is no middle tier to defer the flush to: the client is the writer and
the reader, and its own transaction must carry the count. It is an option for simple desktop applications with
very few parallel users, but as the interface documentation says plainly, it "isn't recommended" —
tiers.md explains why the middle tier is worth having.
Note also that countModification() falls back to immediate counting when the tracker thread is not alive
(!isAlive()), because nothing would ever flush the pending count otherwise.
The Tracker Thread¶
DbModificationTracker extends
DefaultSessionTaskDispatcher, so it is
both a thread with a session and a task dispatcher (see
Tasks and Daemons). Its configuration is
deliberate:
setDaemon(false); // as long as this thread runs, the JVM will not terminate
setPriority(MAX_PRIORITY); // runs periodically as reliable as possible
setSessionClosedOnTermination(true);
setSleepInterval(2000); // 2s default; servers use 1000
The poll loop¶
The dispatcher's main loop calls lockInternal() before running queued tasks, and DbModificationTracker
hooks in there — the polling happens on the way to acquiring the lock, at most once per half sleep interval:
- Flush pending counts (local session), or, on a remote client's very first poll, tell the server this is
the tracker's session (
configureRemoteSession(), needed before the server will accept master serial events for it). - Read the master serial via
getModifiedMasterSerial(). Unchanged → done, that is the whole poll. - If it moved: if the new serial is lower than the last one, the modification table was reinitialized
underneath — force a full
invalidate(). - Read all id/serial tuples, update the tallies' last serials, and for every listener whose tracked name
advanced, collect a
ModificationEventDetail. - Fire the listeners — immediately, or by parking a delayed event.
Then invokeDelayedListeners() runs, and unlockInternal(sleepMs) shortens the sleep if a delayed event is
due sooner than the normal interval.
Surviving trouble¶
The poll is wrapped in an ExceptionHelper chain handling two specific failures:
ReconnectedException— the reconnection machinery restored the connection under the tracker's feet. Log, start over, resume. (RequiresDb.enableReconnection(true, >0)on the tracker's session.)SessionClosedException— the server, or the database, closed the session. IfisReOpenOnceEnabled()says so, reopen once and treat the poll as successful. The default implementation returns true only for non-grouped sessions, because a grouped session usually means the tracker's session is a lead session for something like a desktop client, and such an application must remain killable by closing that session — which is exactly how theLogoutEventkill path works.
Anything else propagates and terminates the tracker — and with it, the application.
cleanup() also reports lateness: if the last poll is more than twice the sleep interval old, it logs a
warning, since that means long-running tasks, a blocked connection, or a session the server has closed.
Using the tracker's thread and session¶
Because the tracker is a task dispatcher with a live session that ticks reliably, it doubles as the application's general-purpose background worker. Two convenience statics on the interface cover the common cases:
// run a Runnable once on the tracker thread
ModificationTracker.submit(() -> precomputeSomething());
// run it inside a transaction of the tracker's session
ModificationTracker.transaction("cleanup", () -> purgeExpiredRows());
ModificationTracker.transaction(() -> purgeExpiredRows()); // tx named "<ModificationTracker>"
DesktopApplication, for instance, uses ModificationTracker.submit(...) to pre-compile validator scripts in
the background the first time a PDO class is used.
The tracker is also an
ExclusiveSessionProvider: an application can
borrow the tracker's session for a short period and give it back.
Session session = ModificationTracker.getInstance().requestSession();
try {
// exclusive use — the tracker is not polling meanwhile
}
finally {
ModificationTracker.getInstance().releaseSession(session);
}
requestSession() locks the dispatcher (which is what triggers the poll described above); until
releaseSession(), no other thread gets it, and re-entrant requests must be released the matching number of
times. Keep it short: the tracker is not polling while you hold it.
Listening for Changes¶
The listener contract¶
| Method | Purpose |
|---|---|
String[] getNames() |
the tracked names to listen to; null or empty means master listener — fire on any change |
int getPriority() |
execution order within a poll; lower comes first |
long getTimeFrame() |
if > 0, fire at a random moment within this window instead (priority then has no effect) |
long getTimeDelay() |
delay before firing, to collect events for several names; combined with a time frame it acts as an offset |
void dataChanged(ModificationEvent ev) |
the callback |
ModificationListenerAdapter supplies the
boring parts (priority 100, no frame, no delay), and
PdoListener extends it to take
PDO classes rather than table names:
ModificationTracker.getInstance().addModificationListener(new PdoListener(StoredBundle.class) {
@Override
public void dataChanged(ModificationEvent ev) {
BundleFactory.getInstance().clearCache();
}
});
Registration is per name: a listener naming three classes produces three internal entries, one per table entry
— and if the tracker has never heard of a name before, registering a listener is what configures it (creating
the modification row if necessary). removeModificationListener(listener) removes all entries of that
listener, comparing by identity.
The Pdo shortcuts¶
For the common case, Pdo offers
lambda-friendly wrappers so no anonymous class is needed:
// with the event
PdoListener l1 = Pdo.listen(ev -> refresh(ev.getSerial()), Customer.class, Order.class);
// when the event doesn't matter
PdoListener l2 = Pdo.listen(this::reload, Customer.class);
// later
Pdo.unlisten(l1);
Both listen(...) overloads build a PdoListener over the classes' table names, register it with the tracker
singleton and hand it back — the returned listener is the handle you need for unlisten(...). They are pure
convenience over ModificationTracker.addModificationListener(...); a listener that needs a priority, a time
frame or a delay must still subclass PdoListener or ModificationListenerAdapter and override the getters.
Pdo.terminateHelperThreads() shuts the tracker down along with the other helper threads.
The event¶
ModificationEvent comes in three flavors, decided by
the listener that receives it:
| Flavor | isMasterEvent() |
getName() |
getDetails() |
When |
|---|---|---|---|---|
| master | true |
null |
null — do not call |
listener registered without names; carries only the master serial |
| single | false |
the name | one detail | one tracked name advanced |
| multi | false |
first name | one detail per name | several of the listener's names advanced in the same poll |
Each ModificationEventDetail is a
name + serial pair, and equality is by name alone — which is what lets details for the same name collapse
cleanly when a delayed event accumulates several polls' worth. getSession() on the event returns the
tracker's session.
Calling
getDetails()on a master event throws aNullPointerException— the details map isnull. CheckisMasterEvent()first, asPdoCache.expire(...)does.
Delaying and coalescing¶
getTimeFrame() and getTimeDelay() exist for one reason: not every client should react at the same
instant. If two hundred clients see the same serial move and all immediately hammer the server to reload,
the notification that was supposed to help has just created a thundering herd.
getTimeDelay()postpones the firing by a fixed number of milliseconds. Modifications arriving in the meantime are merged into the same pending event — the details accumulate, the invocation time does not move. Useful to collect a burst of related changes into one callback.getTimeFrame()makes each listener fire at a random point inside the window, spreading the load across the fleet. A listener with a frame is registered with priority 0; the priority is meaningless once the time is random anyway.- Given both, the delay is the offset and the frame the randomization on top of it.
Either way, the same listener fires only once even if the event recurs before it gets invoked — the
tracker keeps one DelayedEvent per listener, ordered by invocation time, and shortens its own sleep so it
wakes up when the earliest one is due.
Remote Clients and Master Serial Events¶
A client with a remote session cannot read the modification table — there is no database to read. Instead its
tracker asks the server's tracker over TRIP, through
DbModificationTrackerRemoteDelegate:
| Delegate method | Server side |
|---|---|
configureRemoteSession() |
marks this session as the tracker session (once) |
selectMasterSerial() |
returns the server tracker's in-memory master serial, wrapped |
selectIdSerialForName(name) |
one name's id/serial |
selectAllIdSerials() |
all id/serial tuples |
selectAllIdSerialNames() |
all id/serial/name tuples — fetched once at startup to save round trips |
The server answers from memory: its own tracker is already polling the database, so serving a client costs no SQL. Cascade this and you have the multi-tier picture — a nested server's tracker polls its parent exactly the way a client polls it.
Why a MasterSerial and not a long¶
Every poll of a remote client is a round trip that returns a single number. That is a free ride waiting to be
used — so instead of a bare long, the delegate returns a
MasterSerial:
When there is nothing to say, the server returns the record
DefaultMasterSerial(serial).
When there is something to say, it returns a
MasterSerialEvent — the same serial, plus arbitrary
application payload — and the client's tracker handles it before returning the serial to its poll loop. The
notification costs zero extra round trips.
Defining an event¶
An event is a MasterSerialEvent implementation: a mutable serial plus whatever payload it wants.
@TripReferencable(TripReferencable.NEVER)
public class PriceListPublished implements MasterSerialEvent {
private long serial;
private String listName; // payload
@Override public void setSerial(long serial) { this.serial = serial; }
@Override public long serial() { return serial; }
public String getListName() { return listName; }
}
Two optional methods control queue behavior on the server:
isOverridingOlderEvents()— return true if only the latest event of this kind matters (status information and the like). Default false: every event is delivered.isOverridingEvent(MasterSerialEvent olderEvent)— consulted only when the above is true; the default compares classes, so a newer event replaces older events of the same type.
The handler and its mapping¶
The handler is a plain Consumer of the event, annotated with
@MasterSerialEventService:
@MasterSerialEventService(PriceListPublished.class)
public class PriceListPublishedHandler implements Consumer<PriceListPublished> {
@Override
public void accept(PriceListPublished event) {
reloadPrices(event.getListName());
}
}
@MasterSerialEventService is a
@MappedService — the mapping from event class to
handler class is resolved at build time and looked up through
MasterSerialEventHandlerFactory
(default:
DefaultMasterSerialEventHandlerFactory,
backed by a ClassMapper). The annotation is validated at compile time by
MasterSerialEventServiceAnnotationProcessor;
see Annotation Processors.
This indirection buys two things that a direct callback would not:
- The server never needs to know the handlers. It only enqueues events.
- Different clients can react differently to the same event. The handler is chosen by whatever is on the client's module path.
Handlers are singletons, instantiated via a no-args constructor, and must be stateless. There is exactly
one per event type. No handler registered means the event is silently ignored (logged at fine) — which may
be exactly what a given client wants.
Sending an event¶
The server side enqueues on the client's session:
RemoteDbSessionImpl
holds a ConcurrentLinkedQueue per session and rejects the call unless that session is the client's tracker
session — hence the configureRemoteSession() handshake on the first poll.
On the next poll,
DbModificationTrackerRemoteDelegateImpl.createMasterSerial(...)
drains the queue:
- queue empty →
DefaultMasterSerial(serial) - exactly one event → that event, with the serial stamped into it
- several events → a
MasterSerialListEvent, itself aMasterSerialEventand anArrayList<MasterSerialEvent>, each element stamped with the serial
On the client, extractMasterSerial(...) unwraps: if the master serial is a Collection, each element is
dispatched to its handler; otherwise the event itself is. Then the plain serial() continues through the poll
loop as if nothing had happened.
MasterSerialListEvent.add(...)is the only safe way to add. It is what enforcesisOverridingOlderEvents();addAll,addFirstand friends are not overridden and will bypass the rule.
A worked example: LogoutEvent¶
The framework ships one master serial event, and it illustrates the whole mechanism:
LogoutEvent— no payload,isOverridingOlderEvents()returns true (asking twice to log out is still once).- The server sends it from
AdminExtensionAdapterRemoteDelegateImplwhen an administrator asks a client to log off. LogoutEventHandlerintentackle-pdoterminates the tracker — which, by default, terminates the application.LogoutEventHandlerintentackle-fx-rdcreplaces it for desktop clients, callingFx.terminate()on the FX thread instead.
Same event, same server, two handlers — chosen by which module the client happens to have. That is the
@MappedService indirection paying for itself.
Lifecycle¶
Set up by AbstractApplication
(see Application Bootstrap):
configure()→configureModificationTracker()gives the tracker its session but does not start the thread.setSession(...)clears the counters, checks the modification table (initializing it if the check throws), and flags a remote session for theconfigureRemoteSession()handshake.finishStartup()starts the thread — unless thenotrackerproperty is set, in which case PDO caching is globally disabled, because a cache without invalidation goes stale.
Subclasses adapt it to their tier:
AbstractServerApplicationtightens the poll to 1000 ms — the server's tracker is what every client's tracker ultimately waits on.DesktopApplicationgives the tracker its own cloned session, grouped with the application's lead session, so polling never competes with the UI's session — and so closing the lead session kills the client.
Termination is the application's termination¶
finishStartup() registers a shutdown runnable that fires whenever the tracker thread ends:
ModificationTracker.getInstance().addShutdownRunnable(() -> {
if (ModificationTracker.getInstance().isTerminationRequested()) {
LOGGER.info("termination requested");
AbstractApplication.this.stop(0, null);
}
else {
LOGGER.severe("*** emergency shutdown ***");
AbstractApplication.this.stop(2, null); // exit code 2
}
});
An orderly terminate() exits 0. A tracker that died for any other reason exits 2 — loudly. Register your own
cleanup with addShutdownRunnable(...) / removeShutdownRunnable(...); they run once each, in
cleanup(), after the pending counts have been flushed and the session closed, and an exception in one is
logged rather than allowed to stop the rest.
invalidate()¶
invalidate() forces the tracking information to be rebuilt: counters cleared, master serial reset to 1, the
modification table reinitialized (local sessions only), all table entries refreshed. The tracker calls it on
itself when it notices the master serial went backwards — someone reinitialized the table underneath. It
runs as a task on the tracker thread, or directly if called from that thread or before it started.
Reading Serials Directly¶
Not everything needs a callback. The current serial is readable at any time:
long s1 = ModificationTracker.getInstance().getSerial("customer"); // by tracked name
long s2 = ModificationTracker.getInstance().getSerial(Customer.class); // by class
getSerial(Class) resolves the name for you: DbModificationTracker uses the @TableName annotation (falling
back to instantiating the object to ask it), and PdoModificationTracker overrides this to resolve PDO
interfaces through PdoUtilities. Asking for a name the tracker has not seen yet configures it on the spot —
including creating its row — so getSerial(...) never returns a meaningless zero for an unknown name.
This is how PdoCache establishes
its baseline minTableSerial when it starts up, before any event has ever fired.
Who Uses It¶
| Consumer | What it does |
|---|---|
PdoCache |
expires cached PDOs via expire(ModificationEvent); consults TableSerialHistory for gaps; checks isLocalClientMode() |
DefaultSecurityManager |
a PdoListener(Security.class) invalidates the permission rules when they change |
StoredBundleControl |
a PdoListener(StoredBundle.class) clears the bundle cache, so a translator's edit reaches every JVM |
DesktopApplication |
uses the tracker as a background worker via ModificationTracker.submit(...) |
| RDC tables and forms | refresh their contents when the underlying entities change |
Extending the Tracker¶
The implementation is a @Service(ModificationTracker.class) singleton, so the whole thing is replaceable —
PdoModificationTracker is itself nothing but a subclass registering for the same service. The interesting
hooks are protected:
| Method | Why override |
|---|---|
getModifiedMasterSerial() |
listen for changes beyond the modification table — return non-null to trigger a poll cycle |
selectAllIdSerials() |
track additional modification tables, e.g. those of another application |
createModificationEvent(details) |
supply an application-specific event subclass |
extractMasterSerial(...) / handleMasterSerialEvent(...) |
change how server-pushed events are unwrapped or dispatched |
isReOpenOnceEnabled() |
change the policy for reopening a session the server closed |
For application-specific server→client notifications, though, you almost never need a subclass: implement a
MasterSerialEvent plus an @MasterSerialEventService handler and the existing tracker carries it for you.
Related Documentation¶
- Tentackle Session — the session, the
SessionInfo, and the tracker's place among them. - PDO Caching — the tracker's biggest consumer; table serials, expiration and operation modes.
- Locking — the
tableserialandserialcolumns the tracker counts. - Tentackle Database — where
DbModificationTracker,ModificationTallyandDbModificationlive. - Multi-Tier Cascade — why a tracker polls a tracker polls a database.
- TRIP — the protocol carrying master serials and their events.
- I/O and Networking — the reconnection machinery behind
ReconnectedException. - Service and Configuration API —
@Serviceand@MappedService, behind the tracker singleton and the event handlers. - Tasks and Daemons — the dispatcher the tracker is built on.
- Application Bootstrap — where the tracker is configured and started.
- Correctness First — why the tracker takes the application down with it.