whoami
Lucas Jenß
cat /etc/motd
The Coding Journal ツ — Notes taken on an epic coding journey. Technical solutions, debugging notes, and practical guides from the trenches of software development.
ls -la ~/languages/
- drwxr-xr-x
- ▶ PHP
- ▶ Ruby
- ▶ Scala
- ▶ C#
- ▶ JavaScript
- ▶ Objective-C
- ▶ Shell Scripting
ls -la ~/toolchain/
- drwxr-xr-x
- ▶ Typo3
- ▶ Akka
- ▶ Capistrano
- ▶ Git
- ▶ MAMP
- ▶ Adobe Illustrator
- ▶ NSTrackingArea (Cocoa)
uname -a
- drwxr-xr-x
- ▶ Mac OS X
- ▶ Unix
Configuring Akka persistence with a PostgreSQL journal store
Akka persistence lets actors retain state by writing events to a durable log and replaying them on demand, which is the basis for event sourcing and CQRS architectures. When you need a journal that survives restarts, supports queries, and runs on infrastructure you already operate, PostgreSQL is a pragmatic choice. Most teams already have a Postgres cluster running for their application data, so adding event storage there avoids the operational overhead of standing up a separate Cassandra, Kafka, or Redis cluster just for actor journaling.
This walkthrough covers schema setup, the JDBC plugin configuration, snapshot handling, tag-based read-side queries, and a few operational tweaks that matter in production. The examples assume a working Akka project on the JVM and a reachable Postgres 13 or newer instance. Where it makes sense, I'll note things that have bitten me while running this setup from a small studio in Brisbane and from co-working spaces in Melbourne when the NBN was acting up.
Setting up the PostgreSQL schema for Akka journaling
The Akka persistence JDBC plugin ships with a reference schema that you apply once per database. The default DDL creates a journal table, a snapshot table, and a tag table, plus indexes that match the expected access pattern: writes keyed by persistence_id and the ordering column, and reads filtered by tag. Before running the script, decide on a dedicated schema rather than dumping the tables into public. Putting the journal in its own schema keeps DDL changes isolated and makes it easier to grant restricted access to other roles.
A few columns deserve attention. The event_payload and snapshot_payload columns are typically BYTEA and store the serialized event bytes. If you want to query the payload from outside Akka, consider adding a generated JSONB column instead, or keep a parallel event_payload_json populated by a trigger. The ordering column uses a BIGSERIAL sequence by default; on a busy node that single sequence becomes a contention point, and switching to a UUIDv7-based ordering column can save you from lock waits during peak hours.
One thing I learned the hard way while debugging a stuck recovery in Adelaide: make sure the database role Akka uses has the right to create and use sequences, not just the standard CONNECT and USAGE privileges. Postgres separates table-level and sequence-level grants, and a missing grant on the ordering sequence will produce a confusing permission error deep inside the plugin.
Configuring the JDBC plugin in application.conf
The plugin is selected through configuration. Under akka.persistence.journal.plugin you point to "jdbc-journal", and the connection settings live under jdbc-journal and jdbc-snapshot-store. A minimal block looks like this:
jdbc-journal {
slick {
profile = "slick.jdbc.PostgresProfile$"
db {
url = "jdbc:postgresql://db.internal:5432/akka"
user = "akka_writer"
password = ${?AKKA_DB_PASSWORD}
maxPoolSize = 16
minIdle = 4
}
}
}
Two settings tend to be misconfigured. The first is the logicalName for both the journal and snapshot store. If you run multiple Akka applications against the same Postgres instance, each needs a distinct logicalName so the plugin writes a unique tag_view and avoids clobbering another app's query views. The second is the connection pool size. PostgreSQL has its own connection limits, and a default HikariCP pool of 10 per node will exhaust a small instance when three or four services point at the same database. Tune both ends and watch the pg_stat_activity view.
If your cluster runs across AEST and AEDT timezones and you store timestamp columns for event metadata, set the JDBC driver's stringtype=unspecified and pass the JVM flag -Duser.timezone=UTC. Postgres returns timestamp without time zone values in the session timezone, and Akka serializes Instant values using the system clock. Mixing the two produces events that look correct in logs but drift by hours when read back. The same lesson applies if you ever federate with systems in Tokyo or London.
Snapshot store and read-side processing
Snapshots complement the journal by storing aggregate state at a point in time, so recovery does not need to replay the full event history. The Akka JDBC plugin shares the same connection pool configuration as the journal, with a separate table and a configurable delete-old-events interval. For most aggregates I keep the snapshot interval at 50 or 100 events; the cost of writing a snapshot row is small compared to the cost of recovering an actor with 50,000 events after a node crash.
Read-side processing is where tag-based queries earn their keep, and asynchronous projections built on top of them are the read half of a CQRS split. When you tag events at write time, the plugin populates a tag_view per tag, and read-side processors can poll that view with a cursor that records the last seen ordering. PayID-style instant payment flows in Australia depend on this kind of read-side projection for the balance check. A useful walkthrough lives in the payid-casino-mobile article on this site, which builds a similar read-side pipeline from scratch.
If you are building something that needs to keep tight latency budgets, you can also wire the projection directly from the journal using PersistenceQuery and skip the polling. That works for a single read model, but the moment you need two or three independent read models, the tagged cursor approach scales further and keeps each consumer isolated. A useful exercise is to add a Materialized-View-Name column to the projection offset table so you can trace which view is lagging when an alert fires at 2 a.m.
| Store | Persistence model | Query support | Operational overhead | Best fit |
|---|---|---|---|---|
| PostgreSQL (JDBC) | Single-writer per node, ACID | Tag and persistence-id queries via JDBC | Low if you already run Postgres | Mixed read and write workloads with relational data |
| Cassandra | Distributed, append-only log | Tag queries with eventual consistency | High (cluster sizing, compaction) | Very high write throughput, multi-region |
| Kafka | Log-based, partitioned | Stream consumers with offsets | Medium (broker + ZK or KRaft) | Event streaming, replay from offsets |
| Redis | In-memory with optional AOF | Limited, key-based | Low for small datasets | Ephemeral state, fast recovery |
| In-memory (testkit) | Process-local | None across nodes | None | Unit and integration tests |
Handling tags, queries, and event adapters
Tags live in the same row as the event payload. You set them by overriding the tags method on your event, or by registering an EventAdapter that injects tags based on the event type. A common pattern is one tag per aggregate type and a second tag for the tenant identifier, so a multi-tenant deployment can route every event to the right consumer without leaking data across boundaries. Just remember that tag cardinality matters: a million distinct tags is fine, a billion is a slow-motion disaster for the tag_view.
Queries are issued through PersistenceQuery using readJournalFor[JdbcReadJournal]. The fluent API gives you currentEventsByTag, eventsByTag, and eventsByPersistenceId, each backed by the same SQL with a different WHERE clause. One thing the documentation glosses over is that currentEventsByTag materializes a Source that emits Try[EventEnvelope] rather than EventEnvelope directly. Pattern matching on the failure case at the edge of the stream is much cleaner than letting the failure bubble up into the actor.
For serialization, the default Jackson-based serializer is fine for prototypes, but a production deployment should switch to a schema-aware codec such as Avro or Protobuf, with the schema registry or a custom EventAdapter resolving type information. Storing the serializer id alongside the payload lets you evolve event types without writing migration scripts, and it keeps the journal consistent with this reference when you need patterns to share across teams.
Performance tuning and operational considerations
Once the system is running, three metrics tell you most of what you need to know: write latency on the journal table, replay duration on the slowest actor, and the rate of tag_view updates. Write latency should sit in the low milliseconds; if it climbs, check the pg_stat_user_tables view for bloat on the journal table and consider running VACUUM more aggressively. Replay duration is the canary for actors that are growing without snapshots. Tag view update rate is the leading indicator that a read-side processor has fallen behind and needs a fresh offset or a partitioned table.
Configuration mistakes to watch for
- Pointing the journal and snapshot store at the same logical database without separate
logicalNamevalues, which causestag_viewcollisions. - Forgetting to set
connection-init-sqltoSET search_path TO akka, public, so the plugin's queries resolve tables in the wrong schema after a search_path change. - Leaving
auto-initializeon in production, which lets the plugin silently apply DDL when it should fail loudly. - Skipping the sequence grant mentioned earlier, then spending an afternoon chasing a permission error that only shows up on the first write.
Operational checks worth scripting
- Verify the journal table size against the expected write rate weekly, and raise an alert when the table grows past 70 percent of the partition size.
- Confirm that snapshot writes are happening by counting rows in the
snapshottable per activepersistence_id. - Run a replay drill on a non-production copy after every schema change, so you know the recovery path actually works.
- Keep an eye on the
tag_viewlag and rotate the view name if a single tag accumulates too many distinct writers.
A few local notes for anyone running this on Australian infrastructure: the latency from a Sydney region app server to a Postgres instance in the same region is typically under two milliseconds, but cross-region writes to a failover replica can spike to 30 milliseconds or more, which will surface as P99 write timeouts in your Akka metrics. ACCC reporting rules for some financial workloads also require that event logs be retained for at least seven years, so size your journal partitions accordingly and plan a cold-storage archival job from day one. If you are wiring this into a real-time payment flow, the same retention and tag-isolation rules apply, and it is worth bookmarking the schema before you start adding tenants.
Postgres is rarely the most glamorous choice, but it is the one that tends to keep working years after the project has been handed to a team in Melbourne who has never seen the original code.
For more hands-on notes and a deeper look at the Australian payment integrations that often sit on top of an event-sourced core, browse the rest of The Coding Journal and subscribe to the RSS feed.
cat ~/interests.json
| Key | Value |
|---|---|
| editor | Terminal-first workflow |
| os | Mac OS X / Unix |
| vcs | Git, distributed version control |
| deploy | Capistrano, cron automation |
| graphics | SVG, Adobe Illustrator troubleshooting |
| networking | IP validation, SSH, VPN |
git log --oneline --reverse
Solving SVG import issues in Adobe Illustrator CS6 and CC
When importing an SVG into Illustrator, the operation fails with an unknown error [CANT]. A workaround for this Adobe-side bug.
Solving NDK build issues on OS X
Troubleshooting native development kit compilation problems on Mac OS X.
Programmatically adding PHP generated TypoScript to the backend configuration
Integrating dynamically generated TypoScript into Typo3 backend setups using PHP.
ArgumentError: Could not parse PKey: no start line
Debugging an SSH key parsing error encountered during deployment.
Validating IP-Addresses in PHP
Using PHP filter functions with flags like FILTER_FLAG_IPV4 and FILTER_FLAG_IPV6, and understanding how filter_var handles reserved IP addresses.
Cocoa: Using NSTrackingArea
A short tutorial on using Cocoa's NSTrackingArea to capture mouseEntered and mouseExited events.
cat ~/contact.txt