Architecture FAQ¶
This page answers the questions architects usually raise before adopting Tentacolous. It is deliberately blunt about the trade-offs. If a question is missing, open an issue and it will be added here.
Positioning¶
Why use this instead of Debezium?¶
They solve overlapping problems with very different operational cost.
| Tentacolous | Debezium | |
|---|---|---|
| Capture mechanism | SQL triggers + an event table polled by your app | Reads the transaction log (WAL / binlog / redo) |
| Extra infrastructure | None. It runs inside your existing Spring Boot process | Kafka Connect (or Debezium Server / Embedded), plus Kafka in the usual topology |
| Database privileges | Rights to create a table, a function and triggers | Replication / log-reading privileges, often a dedicated replication slot or CDC feature enabled by a DBA |
| Consumes changes as | Annotated Java methods in the same app, and/or Kafka / RabbitMQ | Kafka topics (or a sink connector) |
| Schema/DDL changes | Handled by re-running schema management; triggers are regenerated | Handled by the connector's schema history topic |
Captures TRUNCATE, bulk metadata ops |
No (triggers do not fire for TRUNCATE on most engines) |
Yes |
| Load on the primary DB | Trigger writes one extra row per captured change; poller runs SELECT ... WHERE status = 'PENDING' |
Minimal on the primary; the log reader is out of band |
| Throughput ceiling | Thousands of events/second per instance, bounded by the event table | Very high; designed for firehose CDC |
| Multi-language consumers | Only through a broker sink | Native (topics) |
Choose Tentacolous when you want in-process reactions to external writes, you cannot or do not want to run Kafka Connect, you do not have log-level DB privileges, and your change volume is moderate. It is a library, not a platform.
Choose Debezium when you need high-volume CDC, log-level fidelity (including
TRUNCATE and transaction boundaries), polyglot consumers, or you already run the Kafka
Connect ecosystem.
They also compose: some teams use Tentacolous for a handful of tables that drive application logic and keep Debezium for the analytics pipeline.
Is this change-data-capture?¶
It is trigger-based CDC with a transactional outbox. The trigger writes the event row in the same transaction as the business change, so the event is never lost if the transaction commits and never visible if it rolls back. It is not log-based CDC, so it does not see changes the trigger cannot see (see limitations).
Isn't polling old-fashioned? Why not LISTEN/NOTIFY or a push model?¶
Polling a single indexed table on a 1-second interval is cheap and portable across all six
supported engines. A push model (LISTEN/NOTIFY, SqlTableDependency, etc.) is
engine-specific, does not survive a consumer restart without a catch-up query anyway, and
still needs the durable event table for retries. Polling with a durable table gives you
replay, retry and back-pressure for free. Tune poll-interval down if you need lower
latency.
Guarantees¶
What guarantees do I get?¶
- Durability / no lost events. The event is written in the business transaction. If the
business
COMMITsucceeds, the event exists. If it rolls back, the event is gone. There is no window where the row changed but the event did not. - At-least-once delivery to listeners and to sinks. An event is retried until it
succeeds or reaches
max-attempts. A crash between "listener ran" and "row markedPROCESSED" causes a redelivery on restart. - Per-record ordering. Events are processed in
idorder (insertion order). For a given entity + record key, listeners see changes in the order they were committed. The Kafka sink keys records by record key, so a partition preserves that order downstream. - No exactly-once. Design listeners and consumers to be idempotent. Use
eventIdas the deduplication key. - At-most-once dispatch per poller cycle. Claiming an event is an atomic
UPDATE ... WHERE id = ? AND status = 'PENDING', so two instances or two cycles cannot both dispatch the same event.
Are events delivered in transaction order? What about concurrent writers?¶
Events are ordered by the event table's generated id. Under concurrent transactions the
id is assigned when the trigger fires, i.e. at write time within each transaction, and
rows only become visible to the poller after commit. The poller reads committed rows in
id order. Cross-record ordering is best-effort; per-record ordering is reliable
because a single record's changes are serialized by row locks in the source table.
Is the payload a consistent snapshot of the row?¶
Yes for the row that changed. payload is the row image the trigger saw (the new row for
INSERT/UPDATE, the removed row for DELETE); old_payload is the pre-image for
UPDATE. It is a single-row snapshot, not a multi-table consistent view. If you need
related data, either widen the entity or load it in the listener (accepting that it now
reflects a slightly later state).
What isolation / transactional context does a listener run in?¶
Listeners run in the poller's thread and transaction, after the business transaction already committed. They are not part of the original transaction and cannot veto it. If a listener writes to the database and throws, its own writes roll back and the event is retried; the business change stays committed.
Failure handling¶
What happens if the listener fails?¶
- The exception is caught and logged with the event id, entity, operation and method.
last_erroris stored on the event row andattemptsis incremented.- If
attempts < max-attempts, the row goes back toPENDINGand is retried on a later poll cycle. - When
attempts >= max-attempts, the row is set toFAILEDand left alone.
Because of retries, listeners must be idempotent. If several listeners handle the same
entity + operation and one throws, dispatch for that event stops (later listeners in
order do not run) and the whole event is retried — so earlier listeners run again too.
What happens if the application crashes mid-processing?¶
- Crash before claim: the event is still
PENDING, picked up on the next start. - Crash after claim, before
PROCESSED: the row is left inPROCESSING. The current poller does not automatically reap stalePROCESSINGrows, so in production you should monitor for rows stuck inPROCESSINGbeyond a threshold (usingprocessing_started_at) and reset them toPENDING. This is a deliberate simplicity trade-off; a supervised reaper query in a scheduled job is a few lines. - The business data is never affected by any of this — the outbox row is the only thing in flight.
What do I do with FAILED events?¶
Monitor the count (SELECT count(*) FROM db_change_event WHERE status = 'FAILED') and
alert on it. Recovery is a manual or scripted decision: fix the root cause, then set the
rows back to PENDING (and attempts = 0) to replay them, or archive them. Tentacolous
does not have a dead-letter queue; the FAILED rows are the dead-letter table.
What if the database is down?¶
The poller cycle throws, is logged, and the next cycle retries. Nothing is lost: unwritten
events were never committed (the business transaction also failed), and unprocessed events
are still PENDING. When the database returns the backlog drains in id order.
Does a slow listener block everything?¶
Yes. Processing is single-threaded per instance and sequential within a batch. A listener that takes 5 seconds throttles the whole pipeline to ~12 events/minute on that instance. Keep listener bodies fast; for heavy work, hand off to a queue (a broker sink, or your own executor) and return quickly. This is called out in Production.
Kafka and message brokers¶
What about Kafka? Do I need it?¶
No. Kafka (and RabbitMQ) are optional sinks, off by default. Listeners work with no broker at all. Enable a sink when you want changes to leave the process — for other services, other languages, or an event backbone.
How does the Kafka path work?¶
The poller hands each event to every registered ChangeEventSink after the in-process
listeners run, in the same retry envelope. The native Kafka sink:
- publishes one record per event, keyed by record key (same row → same partition → ordered);
- uses
acks=allandenable.idempotence=trueby default (override viaspring.kafka.producer.*); - sends an
envelope(before/after + metadata) orrawbody, plustentacolous-event-id/-entity/-operationheaders; - fails the event (→ retry) if the broker does not ack within
send-timeout.
Because the sink runs after a committed transaction and is retried on failure, broker
delivery is at-least-once; consumers dedupe on tentacolous-event-id.
Is this a transactional outbox to Kafka?¶
Yes. The event row is written in the business transaction; the relay to Kafka is a separate step with retry. This is the standard outbox-to-broker pattern, without Kafka Connect. The cost versus Debezium is throughput and the fact that the relay is your app process.
What ordering / delivery does the broker consumer see?¶
At-least-once, per-record ordered within a partition (records are keyed by record key). No global ordering across records. No exactly-once into the consumer unless the consumer implements it (idempotent writes or a processed-id table).
Can I use both Kafka and RabbitMQ, or my own transport?¶
Yes. Enabling both registers both sinks and every event goes to both (each retried as a
unit, so both can see a redelivery). For anything else, implement ChangeEventSink and
expose it as a bean — a webhook, an SNS topic, an internal bus. order() controls
placement relative to the built-in sinks.
Onboarding tables¶
Can I capture tables without writing code?¶
Yes, with a capture. It creates the trigger and forwards the change to the sinks with no Java listener method. Two equivalent styles:
@Configuration
@TentacolousCapture(entity = Order.class,
actions = {ActionListener.INSERT, ActionListener.UPDATE},
field = "status", valueType = ValueType.STRING, value = "APPROVED",
exclude = {"internal_notes"})
public class CaptureConfig { }
@Bean
Capture approvedOrders() {
return Capture.of(Order.class)
.operations(DbOperation.INSERT, DbOperation.UPDATE)
.where("status", ValueType.STRING, "APPROVED")
.exclude("internal_notes");
}
Captures support the same operations selector, entityName, exclude, declarative filter,
programmatic TentacolousFilter, and order as the listener annotations. The capture's
filter decides which changes reach the sinks. See Message brokers.
A capture still needs an entity class (or entityName + table resolution) so Tentacolous
knows the table name, key column and column set. It does not scan arbitrary tables you
have not declared.
Do I need a JPA entity?¶
You need a class Tentacolous can map to a table name and key. A JPA @Entity with
@Table / @Id is the common case; the snake-cased class name and an id column are the
fallback. exclude trims columns from the payload.
What about tables owned by another team or a legacy schema?¶
That is the main use case — Tentacolous reacts to writes from anywhere. You do need the
privilege to add a trigger to that table, and you should coordinate: a trigger is visible
to the table owner and runs on their writes. Use schema-management: none plus a
reviewed migration so the DDL goes through their process.
Scaling and operations¶
How does it scale?¶
Vertically per instance, and horizontally with caveats.
Per instance the levers are:
| Lever | Effect |
|---|---|
poll-interval |
Lower = less latency, more empty SELECTs. 1s is the default. |
batch-size |
Events pulled per cycle (default 100). Raise for bursty, fast listeners. |
| Listener speed | The real ceiling. Sequential, single-threaded per instance. |
max-attempts |
Retry budget before FAILED. |
Horizontally: you can run multiple application instances against the same database.
Event claiming is an atomic conditional UPDATE, so no event is processed twice even if
every instance polls. What you do not get today is work-stealing efficiency — each
instance SELECTs the same pending batch and then races to claim rows, so there is wasted
read work as instance count grows. For a handful of instances this is fine. If you need
many workers or very high throughput, forward to Kafka and scale consumers there instead.
Practical ceiling: the design targets moderate volumes — roughly up to low thousands of events per second through one instance with lightweight listeners, bounded by the event table's insert and update rate. Beyond that, use log-based CDC.
What is the load on the source database?¶
- Writes: each captured
INSERT/UPDATE/DELETEon a watched table does one extraINSERTinto the event table inside the same transaction. Roughly doubles the write cost of a captured statement and slightly lengthens the transaction. - Reads: one indexed
SELECT ... WHERE status = 'PENDING' ORDER BY id LIMIT nper poll interval per instance, plus oneUPDATEper event to claim and one to finish. - Growth: the event table grows with change volume. You must archive or delete
processed rows (a scheduled
DELETE FROM db_change_event WHERE status = 'PROCESSED' AND processed_at < now() - interval '7 days'or a partition drop). History-based listeners read past rows for the same record, so keep enough retention to cover the deepest history you use.
How much latency does a change take to reach a listener?¶
Roughly poll-interval on average (half of it plus processing time), so ~0.5–1 s with
defaults. Drop poll-interval to 100ms–250ms for near-real-time at the cost of more
idle queries. It is not sub-millisecond; if you need that, this is the wrong tool.
Can I run it with high availability?¶
Yes. Run N instances; if one dies the others keep polling and claiming. There is no leader
election and no split brain because claims are atomic. During a rolling deploy an event
in PROCESSING on the instance being killed needs the stale-row handling described in
failure handling.
How do I observe it?¶
- The
db_change_eventtable is the source of truth: query counts bystatus, oldestPENDING, rows inPROCESSINGpast a threshold,FAILEDwithlast_error. - The poller logs at
INFOon start andERRORon cycle and per-event failures. - Recommended alerts:
FAILED > 0, oldestPENDINGage,PROCESSINGage, event table row count / size. - There are no built-in Micrometer metrics yet; wrap a
ChangeEventSinkor a scheduled query if you want gauges.
How do I roll it back or turn it off?¶
Set tentacolous.enabled: false — the poller does not start. The triggers still write to
the event table (harmless, just growth) until you drop them. To fully remove: stop the
app, drop the triggers and the function, drop the event table. Because listeners are
plain Spring beans and the library is a single JAR with optional broker deps, there is no
platform to decommission.
Does it lock me in?¶
Low lock-in. The annotations are the only Tentacolous-specific surface in your code, and
they wrap ordinary methods. The event table is a plain SQL table you can read with
anything. If you outgrow it, the envelope message format is close to Debezium's shape,
so a migration to log-based CDC is mostly re-pointing consumers.
Security¶
What are the security considerations?¶
- The event table stores row payloads as JSON. Treat it as containing the same data
classification as the source tables. Use
excludeto drop secrets, tokens and PII you do not need downstream. - Restrict
SELECT/UPDATEondb_change_eventto the application role. - Creating triggers needs elevated privileges; in production prefer
schema-management: validateornoneand apply the DDL through a reviewed migration so the app's runtime role does not hold DDL rights. - Broker sinks send payloads off the box — apply transport security (TLS) and topic ACLs as you would for any event stream.
See Security.
Limitations¶
What are the hard limitations?¶
- Trigger-visible changes only.
TRUNCATEdoes not fire row triggers on most engines; some bulk-load paths and replication apply may bypass triggers. Partition operations and DDL are not captured. - No exactly-once. At-least-once everywhere; idempotency is your responsibility.
- Single-threaded processing per instance. Throughput is bounded by listener speed and the event table.
- No automatic reaping of stale
PROCESSINGrows — you add the monitor/reset. - No built-in metrics or admin UI. The table is the interface.
- Event table maintenance is yours — archival / partitioning must be set up.
- Schema coupling. A trigger references the watched table's columns; incompatible DDL requires regenerating the trigger (re-run schema management or migrate).
- SQLite: reused primary keys can merge unrelated histories — use
AUTOINCREMENTor UUIDs for watched tables.
If these are dealbreakers for your volume or fidelity needs, use log-based CDC.