This is the power-user runbook for standing up a production TheTransitClock
deployment from a clean machine. The flow is: build the shaded JARs, point them
at a Postgres database, import a GTFS static feed, then run Core.jar against
a GTFS-realtime vehicle-positions URL. The REST API and web UI are deployed as
WARs into Tomcat and reach Core over RMI.
Docker shortcut. The repo ships a
docker-compose.ymlplusdocker/Dockerfilethat bundle Postgres 17, the build, Core, and Tomcat 11
- JDK 21 into one stack. If you're standing up a fresh deployment, jump to §0 Containerized deployment — every step below is wired up there. The detailed §1–§10 flow is the bare-metal reference for when Docker isn't an option (or when you need to debug what a particular step is actually doing).
The included docker-compose.yml runs each tier as its own container on a
single docker network. The build is a multi-stage docker/Dockerfile:
- builder — runs
mvn install -DskipTestsagainst the full reactor. - tools —
FROM builder, pluspsql. Used viadocker compose run --rm tools <cmd>for SchemaGenerator, GtfsFileProcessor, CreateWebAgency, CreateAPIKey, RmiQuery — i.e. every one-shot admin command in §4–§7. - core-runtime — JDK 21 +
Core.jar(copied from builder). - tomcat-runtime — Tomcat 11 + JDK 21 +
api.war/web.war(copied from builder).
Per-deployment secrets live in a top-level .env (gitignored), and
the rest of the per-deployment files live in .deploy/ (also gitignored):
.env (cp from .env.example;
TRANSITCLOCK_DB_PASSWORD now,
TRANSITCLOCK_APIKEY after step 7)
.deploy/
├── conf/
│ ├── transitclockConfig.xml (copy of docs/examples/, with
│ │ your AVL feed URL filled in)
│ └── postgres_hibernate.cfg.xml (copy of docs/examples/)
├── ddl/ (SchemaGenerator output — applied
│ to the `web` and per-agency DBs)
├── logs/ (mounted into Core)
└── <your-gtfs>.zip
The flow then is just the §1–§9 flow run inside containers:
# 0. Set up the top-level .env. TRANSITCLOCK_DB_PASSWORD now,
# TRANSITCLOCK_APIKEY after step 7. compose substitutes both into the
# services that need them and refuses to start a service whose
# required value is unset (with a one-line message pointing here).
cp .env.example .env
$EDITOR .env
# 1+2. Build all images (builder runs once, runtime stages copy from it).
docker compose build
# 3. Start Postgres. docker/postgres-init.sh creates the `web` database
# and the `transitclock` role (using TRANSITCLOCK_DB_PASSWORD from
# .env); POSTGRES_DB creates the per-agency database.
docker compose up -d db
# 4. Generate DDL via mvn exec:java in the tools container, then apply it
# to the right databases via psql (also in the tools container).
#
# The `bash -euo pipefail -c` form makes a failure anywhere in the chain
# abort the whole step (otherwise psql/mvn errors after the first &&
# can be silently lost). `psql -v ON_ERROR_STOP=1` does the same on
# psql's side — without it, psql continues past CREATE TABLE failures
# and exits 0, which can leave half-applied schemas.
docker compose run --rm tools bash -euo pipefail -c "
cd /workspace/transitclock
mvn -q exec:java \
-Dexec.mainClass=org.transitclock.applications.SchemaGenerator \
-Dexec.args='-o /deploy/ddl -p org.transitclock.db.structs'
mvn -q exec:java \
-Dexec.mainClass=org.transitclock.applications.SchemaGenerator \
-Dexec.args='-o /deploy/ddl -p org.transitclock.db.webstructs'
test -s /deploy/ddl/ddl_postgres_org_transitclock_db_structs.sql
test -s /deploy/ddl/ddl_postgres_org_transitclock_db_webstructs.sql
grep -c 'create table' /deploy/ddl/ddl_postgres_org_transitclock_db_structs.sql"
docker compose run --rm tools bash -euo pipefail -c "
psql -v ON_ERROR_STOP=1 -h db -U transitclock -d <agency-db> \
-f /deploy/ddl/ddl_postgres_org_transitclock_db_structs.sql
psql -v ON_ERROR_STOP=1 -h db -U transitclock -d web \
-f /deploy/ddl/ddl_postgres_org_transitclock_db_webstructs.sql"
# 5. Drop your transitclockConfig.xml + postgres_hibernate.cfg.xml into
# .deploy/conf/. They get bind-mounted at /etc/transitclock/ inside
# every container.
# 6. Import GTFS.
docker compose run --rm tools bash -euo pipefail -c "java -Xmx2g \
-Dtransitclock.core.agencyId=<id> \
-Dtransitclock.db.dbType=postgresql \
-Dtransitclock.db.dbHost=db \
-Dtransitclock.db.dbName=<agency-db> \
-Dtransitclock.db.dbUserName=transitclock \
-Dtransitclock.db.dbPassword=changeme \
-Dtransitclock.hibernate.configFile=/etc/transitclock/postgres_hibernate.cfg.xml \
-jar /workspace/transitclock/target/GtfsFileProcessor.jar \
-c /etc/transitclock/transitclockConfig.xml \
-gtfsZipFileName /deploy/<your-gtfs>.zip \
-storeNewRevs"
# 7. CreateWebAgency takes the DB target from positional args, so it
# doesn't need -Dtransitclock.db.dbName=web. CreateAPIKey *does* need
# it (see the comment on the next block). hostName MUST be `core` (the
# docker network DNS name of the core service) so the API resolves it
# inside the network.
docker compose run --rm tools bash -euo pipefail -c "java \
-Dtransitclock.hibernate.configFile=/etc/transitclock/postgres_hibernate.cfg.xml \
-Dtransitclock.db.dbType=postgresql -Dtransitclock.db.dbHost=db \
-Dtransitclock.db.dbUserName=transitclock -Dtransitclock.db.dbPassword=changeme \
-jar /workspace/transitclock/target/CreateWebAgency.jar \
<id> core <agency-db> postgresql db transitclock changeme"
# Mint the API key. Note -Dtransitclock.db.dbName=web — see §7 below
# for why CreateAPIKey crashes without it.
docker compose run --rm tools bash -euo pipefail -c "java \
-Dtransitclock.hibernate.configFile=/etc/transitclock/postgres_hibernate.cfg.xml \
-Dtransitclock.db.dbType=postgresql -Dtransitclock.db.dbHost=db \
-Dtransitclock.db.dbName=web \
-Dtransitclock.db.dbUserName=transitclock -Dtransitclock.db.dbPassword=changeme \
-jar /workspace/transitclock/target/CreateAPIKey.jar \
-c /etc/transitclock/transitclockConfig.xml \
-n 'ops' -u 'http://localhost' -e 'ops@example.org' -p '555-0100' -d 'Ops key'"
# Add the printed key to .env:
# TRANSITCLOCK_APIKEY=<the key>
# (docker compose substitutes it into tomcat's CATALINA_OPTS at boot.
# Without it tomcat refuses to start with a helpful error message.)
# 8+9. Start Core and Tomcat. Both pull DB credentials and the API key
# from .env via docker-compose.yml's interpolation.
docker compose up -d core tomcat
# Smoke test:
curl "http://localhost:8080/api/v1/key/<API_KEY>/agency/<id>/command/gtfs-rt/tripUpdates?format=human"If that curl returns a non-empty TripUpdates protobuf within a minute or two of starting Core, you're done. The §1–§9 sections below are the bare-metal equivalent of what §0 just walked through and aren't required reading; jump to §10 Updating GTFS for the day-2 workflow.
If the smoke test returns empty / errors, the most common causes are:
- Empty protobuf: Core hasn't completed a full AVL polling cycle yet
(default 5s, but matchers need 1–2 cycles before predictions land).
Tail
docker compose logs core | grep -i predictionand.deploy/logs/<agencyId>/core/.../avl.log.gz. apiKey="null"/ 401 on every page:transitclock.apikeyintomcat.envdoesn't match the key minted in step 7. See §10.- "no agencies" / RMI timeouts: the WebAgency row's
hostNamedoesn't match the docker network DNS name (must becore), ortransitclock.db.dbName=webis missing fromCATALINA_OPTS. See §10. - No tables / DDL incomplete: confirm with
docker compose exec db psql -U transitclock -d <agency-db> -c '\dt' | wc -l. Should be 30+. - Web UI shows
Blocks: NNN Assigned: 0%with predictions still flowing: the Hibernate 5.5 → 6.5 / Jakarta upgrade introduced four load-bearing regressions that together broke block matching; all four are fixed on this branch and pinned by tests. See the matching row in the troubleshooting table at the end of this document for the audit list if a partial cherry-pick reintroduces the symptom.
A couple of Docker-specific gotchas worth knowing:
-
Don't use
--mount=type=cachefor~/.m2in the builder stage. BuildKit cache mounts live outside the image, so the downstreamtoolsstage would inherit an empty/root/.m2and admin commands likemvn exec:javawould refuse to resolve the inter-module dependencies. The committed Dockerfile drops the cache mount and lets the deps land in image layers; the runtime images don't change, only the build cache does. -
Java RMI needs
-Djava.rmi.server.hostname=core(or whatever your Core service is called on the docker network). Without it Core advertises whatever its container IP happens to be, and Tomcat can't reach it on a restart. Thecoreservice indocker-compose.ymlalready passes this. -
Don't combine
ENTRYPOINT ["java"]in the Dockerfile with a shellcommand:in compose — Java will try to loadshas a main class. Setentrypoint: ["sh", "-c"]in compose and put the fulljava …invocation incommand:instead, which is what we do. -
Non-root runtime users on Linux hosts:
core-runtimeruns as UID10001andtomcat-runtimeas UID10002, so the bind-mounted.deploy/logs/directory has to be writable by UID10001. macOS Docker Desktop's VirtioFS makes this transparent; on a Linux host you'll wantsudo chown -R 10001:10001 .deploy/logsonce before the firstdocker compose up -d core. -
Custom HTTP headers on the AVL feed:
PollUrlAvlModuleonly supports HTTP basic auth viatransitclock.avl.authenticationUser/authenticationPassword. If your provider needs a different header (WMATA:api_key: <key>) and accepts the same value as a query-string parameter, embed it ingtfsRealtimeFeedURI— that path requires no code changes, but the secret will leak:PollUrlAvlModule.getAndProcessData()logs the full URL at INFO on every poll, so the key ends up in${transitclock.logging.dir}/<agencyId>/core/.../avl.log.gzandcore.log.gz, rotated and gzipped but unencrypted.RmiQuery -c configreturns every config value (includingtransitclock.avl.gtfsRealtimeFeedURI) over RMI without any authentication, so anyone who can reach Core's RMI ports can read the key.- Pasting a
core.logsnippet into a GitHub issue or chat guarantees a leak.
Concrete mitigations, in order of effort:
- Strip the parameter before sharing logs:
gunzip -c core.log.gz | sed -E 's/api_key=[^& ]+/api_key=REDACTED/g'. chmod 0700${transitclock.logging.dir}and own it as the Core service user only.- Don't expose the RMI ports (2099/2098) past localhost / the docker network.
- Subclass
PollUrlAvlModulewith a ~10-line override ofsetRequestHeaders(URLConnection)that reads the secret from aStringConfigValueyou mark assecret=true, then register the subclass intransitclock.modules.optionalModulesList. This is the only path that keeps the secret out ofcore.logentirely.
| Input | Purpose | Where to get it |
|---|---|---|
GTFS static feed (.zip) |
Routes, stops, trips, schedule, shapes — the static skeleton TheTransitClock matches AVL onto. | Your transit agency's open-data portal, Mobility Database, or transit.land. Must be GTFS, not GTFS-Flex. |
| GTFS-realtime VehiclePositions feed (URL) | Live AVL stream. Must be a VehiclePositions feed (not TripUpdates / Alerts). | Same agency or aggregator. The URL is polled every 5 s by default. HTTP basic auth is supported via transitclock.avl.authenticationUser / …Password; arbitrary custom headers (e.g. WMATA's api_key:) require subclassing PollUrlAvlModule or embedding the secret in the URL — see the AVL-feed gotcha further down. |
| PostgreSQL 16+ | Persists config, GTFS, AVL, predictions, arrivals/departures, web agency registry, and API keys. | Any standard install. The shipped docker-compose.yml pins Postgres 17. MySQL also works (-Dtransitclock.db.dbType=mysql); HSQLDB is for tests only. |
| JDK 21 | Runtime. The WARs are compiled with --release 21 so the deployed JRE must be ≥21. |
Any LTS distribution. |
| Tomcat 11 | Hosts api.war and web.war. Not required if you only need the engine + RMI. Tomcat 9 will not work — both WARs target the Jakarta jakarta.servlet namespace. |
Apache Tomcat 11 distribution (Servlet 6.1 / Jakarta EE 11). |
Optional: a writable log directory (default /Logs, override with
-Dtransitclock.logging.dir=...), and a writable PID directory (default
/usr/local/transitclock/, override with -Dtransitclock.core.pidDirectory=...).
From the repo root:
mvn install -DskipTestsThis drops the following self-contained executable JARs into
transitclock/target/:
| JAR | Main class | Role |
|---|---|---|
Core.jar |
org.transitclock.applications.Core |
Long-running engine: ingests AVL, runs matchers, generates predictions, exposes RMI servers. |
SchemaGenerator.jar |
org.transitclock.applications.SchemaGenerator |
Emits DDL from the Hibernate-annotated entity classes. |
GtfsFileProcessor.jar |
org.transitclock.applications.GtfsFileProcessor |
One-shot GTFS static importer. |
CreateWebAgency.jar |
org.transitclock.applications.CreateWebAgency |
Registers an agency in the web database so the API can route RMI lookups. |
CreateAPIKey.jar |
org.transitclock.applications.CreateAPIKey |
Mints a REST API key. |
RmiQuery.jar |
org.transitclock.applications.RmiQuery |
CLI for poking a running Core's RMI servers. |
UpdateTravelTimes.jar |
org.transitclock.applications.UpdateTravelTimes |
Offline travel-time recompute over historical AD data. |
ScheduleGenerator.jar |
org.transitclock.applications.ScheduleGenerator |
Offline schedule generation. |
Tomcat-deployable WARs land at:
transitclockApi/target/api.wartransitclockWebapp/target/web.war
SchemaGenerator.jarhas a known classloader issue when invoked as a shaded JAR (one-jar /META-INFcollisions for Hibernate'sservice-loader-style resources). Usemvn exec:javainstead — see step 4 below. The other shaded JARs run fine viajava -jar.
Create two Postgres databases. The split is convention plus a write-side
hardcode: CreateWebAgency.main writes its row into a database literally
named web (String webAgencyDbName = "web"; in
org.transitclock.applications.CreateWebAgency), and the rest of the runbook
points the API and CreateAPIKey at the same name with
-Dtransitclock.db.dbName=web. Nothing else in the runtime forces the name —
in principle you could pick a different one if you also fix
CreateWebAgency — but staying with web is much less work. Per-agency
core data lives in a database whose name defaults to the agency ID and can
be overridden with -Dtransitclock.db.dbName=....
# Example: agency id "02". This form assumes a local Postgres install
# with a `postgres` Unix user (Debian/Ubuntu/Homebrew defaults). On a
# managed service (RDS, Cloud SQL, …) run the same statements through
# whatever client you normally use, as the master/admin role.
sudo -u postgres psql <<'SQL'
CREATE USER transitclock WITH PASSWORD 'changeme';
CREATE DATABASE "02" OWNER transitclock; -- core data (per agency)
CREATE DATABASE web OWNER transitclock; -- WebAgency, ApiKey
SQLThis example uses a single Postgres role for both databases for simplicity;
multi-tenant setups (one Core JVM per agency, separate ops teams) often want a
distinct owner per database — substitute the second CREATE DATABASE … OWNER
accordingly and adjust the credentials passed to each tier in later steps.
Naming the database after the agency id is the path of least resistance — Core
falls back to using transitclock.core.agencyId as the database name when
transitclock.db.dbName is unset.
Two Hibernate packages, one DDL each:
org.transitclock.db.structs→ core tables (predictions, AVL, AD, vehicles, …)org.transitclock.db.webstructs→ web layer (WebAgency,ApiKey)
Run SchemaGenerator via mvn exec:java from transitclock/:
cd transitclock
mvn exec:java \
-Dexec.mainClass=org.transitclock.applications.SchemaGenerator \
-Dexec.args="-o target -p org.transitclock.db.structs"
mvn exec:java \
-Dexec.mainClass=org.transitclock.applications.SchemaGenerator \
-Dexec.args="-o target -p org.transitclock.db.webstructs"This writes ddl_postgres_org_transitclock_db_structs.sql,
ddl_postgres_org_transitclock_db_webstructs.sql (and _mysql_, _oracle_
variants) into transitclock/target/.
Apply the right pair to the right database:
psql -U transitclock -d 02 -f target/ddl_postgres_org_transitclock_db_structs.sql
psql -U transitclock -d web -f target/ddl_postgres_org_transitclock_db_webstructs.sqlYou need two files. Put them somewhere stable like /etc/transitclock/.
This is the runtime config TheTransitClock parses on startup. The XML root tag
becomes the property prefix — the root must be <transitclock>. A root tag
of <transitime> (as appears in some legacy samples shipped with the repo) is
silently ignored, because every typed ConfigValue in the codebase is
registered under the transitclock.* namespace.
A minimal working example is checked in at
docs/examples/transitclockConfig.xml.
The keys you must set:
| XML path | Property name | Meaning |
|---|---|---|
<transitclock><core><agencyId> |
transitclock.core.agencyId |
Agency identifier; also default DB name. |
<transitclock><modules><optionalModulesList> |
transitclock.modules.optionalModulesList |
Semicolon-separated list of module classes to start (separator must be ; — comma is not accepted). For GTFS-RT use org.transitclock.avl.GtfsRealtimeModule. |
<transitclock><avl><gtfsRealtimeFeedURI> |
transitclock.avl.gtfsRealtimeFeedURI |
URL (or comma-separated URLs) of the VehiclePositions feed. |
<transitclock><hibernate><configFile> |
transitclock.hibernate.configFile |
Path or classpath name of the Hibernate XML (next file). |
Optional knobs worth knowing:
transitclock.avl.feedPollingRateSecs— default5.transitclock.avl.feedTimeoutInMSecs— default10000.transitclock.avl.authenticationUser/transitclock.avl.authenticationPassword— HTTP basic auth on the AVL feed.transitclock.rmi.rmiPort— default2099.transitclock.rmi.secondaryRmiPort— default2098.
The Hibernate config. Connection URL/user/password can live here, or be
overridden by -Dtransitclock.db.dbHost=, -Dtransitclock.db.dbName=,
-Dtransitclock.db.dbUserName=, -Dtransitclock.db.dbPassword= system
properties. A minimal example is at
docs/examples/postgres_hibernate.cfg.xml.
Hibernate's lookup order (HibernateUtils.getSessionFactory /
getConfiguration): filesystem first, then classpath. Pass an absolute path
on the command line and you don't have to think about classpath.
The default value of
transitclock.db.dbTypeismysql. If you're on Postgres you must set-Dtransitclock.db.dbType=postgresql(this is what Core uses to construct the JDBC URL whenhibernate.connection.urlisn't set in the cfg.xml).
java -Xmx2g \
-Dtransitclock.core.agencyId=02 \
-Dtransitclock.db.dbType=postgresql \
-Dtransitclock.db.dbHost=localhost \
-Dtransitclock.db.dbUserName=transitclock \
-Dtransitclock.db.dbPassword=changeme \
-Dtransitclock.hibernate.configFile=/etc/transitclock/postgres_hibernate.cfg.xml \
-jar transitclock/target/GtfsFileProcessor.jar \
-c /etc/transitclock/transitclockConfig.xml \
-gtfsZipFileName /tmp/agency-gtfs.zip \
-storeNewRevsCritical flags:
-c— points attransitclockConfig.xmlso the processor can connect to the DB.-gtfsZipFileName— local zip; alternatively-gtfsUrl <url>to fetch, or-gtfsDirectoryName <dir>if already unzipped.-storeNewRevs— promotes the imported revision inActiveRevisionsso Core picks it up. Skip this and Core will keep using the previous active rev.
Useful tuning flags (full list in transitclock/README.md):
-maxTravelTimeSegmentLength, -maxSpeedKph, -maxStopToPathDistance,
-trimPathBeforeFirstStopOfTrip.
Both commands target the web database, but they pick the target differently
and one of those differences trips people up. CreateWebAgency ignores
transitclock.db.dbName and writes to a database literally named web
(hardcoded in its main). CreateAPIKey reads transitclock.db.dbName at
class-init time and crashes with a …/null JDBC URL if you omit it. So the
first command below has no dbName flag and the second one does — that's
not a typo.
CreateWebAgency writes a row into the hardcoded web database. Positional
args, in order:
agencyId hostName dbName dbType dbHost dbUserName dbPassword
hostName is the host where Core will be reachable over RMI (this is what the
API will look up to know where to send RMI calls). For a single-box deployment,
use localhost.
java \
-Dtransitclock.hibernate.configFile=/etc/transitclock/postgres_hibernate.cfg.xml \
-Dtransitclock.db.dbType=postgresql \
-Dtransitclock.db.dbHost=localhost \
-Dtransitclock.db.dbUserName=transitclock \
-Dtransitclock.db.dbPassword=changeme \
-jar transitclock/target/CreateWebAgency.jar \
02 localhost 02 postgresql localhost transitclock changemeMint an API key. Note -Dtransitclock.db.dbName=web — ApiKeyManager
seeds its database name from DbSetupConfig.getDbName() when its singleton
class-inits, and without that property the JDBC URL ends in /null and the
command crashes. The flag is the easy form; equivalently you can put
<db><dbName>web</dbName></db> in the config file passed via -c, since
ConfigFileReader.processConfig runs before ApiKeyManager is touched:
java \
-Dtransitclock.hibernate.configFile=/etc/transitclock/postgres_hibernate.cfg.xml \
-Dtransitclock.db.dbType=postgresql \
-Dtransitclock.db.dbHost=localhost \
-Dtransitclock.db.dbName=web \
-Dtransitclock.db.dbUserName=transitclock \
-Dtransitclock.db.dbPassword=changeme \
-jar transitclock/target/CreateAPIKey.jar \
-c /etc/transitclock/transitclockConfig.xml \
-n "ops" -u "http://localhost" -e "ops@example.org" -p "555-0100" -d "Ops key"The generated key is printed to stdout. Save it — REST clients pass it as a URL segment, and the webapp needs it as a JVM property (see step 9).
java -Xmx4g -server \
-Dtransitclock.core.agencyId=02 \
-Dtransitclock.configFiles=/etc/transitclock/transitclockConfig.xml \
-Dtransitclock.hibernate.configFile=/etc/transitclock/postgres_hibernate.cfg.xml \
-Dtransitclock.db.dbType=postgresql \
-Dtransitclock.db.dbHost=localhost \
-Dtransitclock.db.dbUserName=transitclock \
-Dtransitclock.db.dbPassword=changeme \
-Dtransitclock.logging.dir=/var/log/transitclock \
-Dtransitclock.core.pidDirectory=/var/run/transitclock \
-jar transitclock/target/Core.jarWhat happens on startup:
- Loads
transitclockConfig.xml(semicolon-separated paths intransitclock.configFilesare supported). - Reads the active GTFS revision from
ActiveRevisionsand snapshots it intoDbConfig. - Starts every class listed in
transitclock.modules.optionalModulesListas a thread.GtfsRealtimeModulebegins polling the feed URL. - Binds RMI servers (
PredictionsServer,VehiclesServer,ConfigServer,CommandsServer,CacheQueryServer) on port 2099, with secondary comms on 2098. Open these ports through any firewall between Core and your Tomcat host. - Begins async batched writes to Postgres via
DataDbLogger.
Logs land at ${transitclock.logging.dir}/${agencyId}/core/YYYY/MM/DD/*.log.gz
(see the rolling-file appenders in transitclock/src/main/resources/logback.xml,
which is bundled into Core.jar and picked up automatically). Watch
avl.log.gz and prediction.log.gz to confirm AVL is being ingested and
predictions are being generated. Add -Dlogback.configurationFile=/path/to/logback.xml
to the Core invocation if you need a different layout (additional appenders,
JSON output, syslog, etc.).
Sanity-check from a second shell. RmiQuery resolves the Core RMI host
through the WebAgency table, so the easy form is to pass
-Dtransitclock.rmi.rmiHost=localhost and skip the DB lookup:
RMI_OPTS="-Dtransitclock.rmi.rmiHost=localhost"
# List all vehicles Core knows about.
java $RMI_OPTS -jar transitclock/target/RmiQuery.jar -a 02 -c vehicles
# Predictions for a specific stop. Pick any stop_id from the GTFS
# stops.txt you imported in step 6 (or fish one out with `-c routeConfig`).
java $RMI_OPTS -jar transitclock/target/RmiQuery.jar -a 02 -c preds -s <stopId>
# Predictions for everything within 1500 m of a lat/lon.
java $RMI_OPTS -jar transitclock/target/RmiQuery.jar -a 02 -c preds -lat 47.6 -lon -122.3Without transitclock.rmi.rmiHost set, RmiQuery needs the same DB plumbing
as CreateAPIKey in step 7 (-Dtransitclock.configFiles=...,
-Dtransitclock.hibernate.configFile=..., -Dtransitclock.db.dbType=postgresql,
-Dtransitclock.db.dbName=web, plus user/password) — the host comes out
of the WebAgency row you wrote in that step.
Valid -c values are vehicles, preds, routeConfig, config,
activeBlocks, and resetVehicle. preds requires either -s or both
-lat and -lon; called without one it prints
Error: must specify stop(s) to get predictions. to stderr and exits.
Both WARs target Tomcat 11 (jakarta.servlet); Tomcat 9 will not work —
the WARs use the Jakarta namespace. Drop the WARs into Tomcat 11's webapps/.
The API needs transitclock.configFiles and the same DB-related properties as
Core, because it reads the WebAgency and ApiKey tables out of the web
database to validate keys and route RMI lookups. The webapp shares the same
JVM and reuses those DB credentials for the report tier
(org.transitclock.reports, which queries the per-agency database directly
via Hibernate); the page-rendering JSPs themselves don't open a DB session,
but the report endpoints will 500 if the credentials are missing. The webapp
additionally needs transitclock.apikey set: every page renders JavaScript
that reads the key out of System.getProperty("transitclock.apikey") into
a JS apiKey variable and uses it as a URL segment for API calls. Without
it the JSP ships apiKey="null" and every request 401s. Use the key minted
in step 7.
/etc/default/tomcat9 (or wherever you set CATALINA_OPTS):
CATALINA_OPTS="\
-Dtransitclock.configFiles=/etc/transitclock/transitclockConfig.xml \
-Dtransitclock.hibernate.configFile=/etc/transitclock/postgres_hibernate.cfg.xml \
-Dtransitclock.db.dbType=postgresql \
-Dtransitclock.db.dbHost=localhost \
-Dtransitclock.db.dbName=web \
-Dtransitclock.db.dbUserName=transitclock \
-Dtransitclock.db.dbPassword=changeme \
-Dtransitclock.apikey=<API_KEY_FROM_STEP_7>"Setting
transitclock.db.dbName=webis what makes the API read theWebAgencyregistry from the right database. Without it,WebAgency.getWebAgencyDbName()returnsnull(viaDbSetupConfig.getDbName()), and thatnullgets passed straight through toHibernateUtils.getSessionFactory(null, …). The agency-id fallback there only fires if the parameter is non-null — sodbNamestaysnull, the JDBC URL is constructed asjdbc:postgresql://<host>/null, and the lookup fails. The API will then return "no agencies" until you set the property and redeploy.
Deploy:
sudo cp transitclockApi/target/api.war /var/lib/tomcat9/webapps/
sudo cp transitclockWebapp/target/web.war /var/lib/tomcat9/webapps/
sudo systemctl restart tomcat9Smoke-test the GTFS-RT TripUpdates endpoint the API generates from your live predictions:
curl "http://localhost:8080/api/v1/key/<API_KEY>/agency/02/command/gtfs-rt/tripUpdates?format=human"The format=human query produces protobuf-as-text; drop it for the binary
GTFS-RT feed.
If the response is empty, Core hasn't generated any predictions yet — give
it at least one full polling cycle of the AVL feed and check
prediction.log.gz and avl.log.gz before assuming the API is misconfigured.
When the schedule changes, re-run GtfsFileProcessor with the new zip and
-storeNewRevs. Old revisions stay in the database (revisions are tracked by
configRev and travelTimesRev, with ActiveRevisions pointing at the live
pair), so Core continues serving until you flip the active rev. Restart Core
after the new rev is active.
| Symptom | Likely cause |
|---|---|
Could not load in hibernate config file ... on startup |
transitclock.hibernate.configFile value isn't on the filesystem or classpath. Use an absolute path. |
| Core starts but no AVL is processed | optionalModulesList is empty, the feed URL is wrong, or transitclock.avl.shouldProcessAvl=false. Check avl.log.gz. |
| API returns "no agencies" / RMI timeouts | WebAgency row missing or pointing at a wrong host/port; transitclock.db.dbName=web not set in CATALINA_OPTS. |
| Predictions table never grows | No active GTFS revision (forgot -storeNewRevs), or the AVL feed has no vehicles matching the GTFS routes/blocks. |
| "Could not contact RMI" between API and Core | Ports 2099 and 2098 blocked, or hostName passed to CreateWebAgency doesn't resolve from the API host. |
| Settings in your config file have no effect | Root tag is <transitime> (legacy). Change to <transitclock>; every typed ConfigValue is registered under transitclock.*. |
CreateAPIKey crashes / JDBC URL ends in /null |
Forgot -Dtransitclock.db.dbName=web. ApiKeyManager resolves its DB name at class-init from DbSetupConfig.getDbName(); without the override the URL becomes …/null and the connection fails. |
SchemaGenerator aborts with Could not load requested class : org.hibernate.dialect.Oracle10gDialect |
The Dialect.ORACLE enum in org.transitclock.applications.SchemaGenerator references the Hibernate 5 class name, which was removed in Hibernate 6.x. Change it to org.hibernate.dialect.OracleDialect and rebuild. The Postgres pass runs before the Oracle pass, so the postgres DDL files are written even on the failed run — but pipefail aborts the bash chain before the second mvn exec:java (for webstructs) runs. |
API endpoints 500 with Connection refused to host: core after docker compose up core recreated the container |
Tomcat caches the RMI stub it pulled out of WebAgency at first lookup; when core's container IP changes (recreate, not just restart of the same container) the cached stub points at a stale endpoint and every API call fails. docker compose restart tomcat clears the cache. The webapp itself (port 8080 /web/) keeps loading because that's plain HTML/JSP and only the API tier owns the RMI client. |
Active-blocks page shows Assigned: 0% even though AVL is flowing and trip-updates are vending |
Hibernate 6 / SLF4J 2.0 regressions from the Jakarta upgrade. The cascading symptom is Block.getTrips() NPE'ing on every Block load (Hibernate 6's entity initializer formats toString() during load, before the lazy collection proxy is attached, so a guard on Hibernate.isInitialized alone falls through and unmodifiableList(null) throws); the matcher dies before assigning any vehicle. Two related races (Trip.getScheduleTime and ValidateSessionThread using globalSession without Block.getLazyLoadingSyncObject()) and an SLF4J 1.7 / logback 1.3 binding mismatch (which silently swallowed every TransitClock log line, hiding the NPEs) also bit during diagnosis. All four are fixed on this branch. Audit a partial backport by checking: (1) Block.getTrips null-guards trips before Collections.unmodifiableList; (2) Trip.getScheduleTime synchronizes on Block.getLazyLoadingSyncObject() for the lazy-load path; (3) DbConfig.ValidateSessionThread's probe is wrapped in the same lock; (4) every module's slf4j-api is on 2.x and logback-classic/logback-core are on 1.3.x. |