Kafka / 08 Ecosystem
All modules
Module 08 · Day 6 · Kafka 4.3

Ecosystem

Kafka stores bytes. Real systems need more: an agreement on what the bytes mean, a way to move data in and out of databases without writing code, and a plan for records that cannot be processed. This module covers the tools and patterns for those three needs.

Try it: is this schema change safe?

A schema describes the fields of a record. Pick a change to the Order schema and see which compatibility rules accept it. The lab runs three of these against a real schema registry.

offset 0

What it is and why it exists

Brokers do not look inside records. To them a key and a value are two arrays of bytes. This keeps Kafka fast and general, and it leaves three problems to you.

The one-sentence version. A schema registry guards what records mean, Kafka Connect moves data in and out without custom code, and a few well-known patterns handle the cases where a database and Kafka must agree or a record cannot be processed.

offset 1

Mental model

A schema is a contract with versions

Producers and consumers of a topic are released at different times. For a while, new producers write data that old consumers read, or new consumers read data that old producers wrote. A schema change is safe only if it works in the mixed state. The registry checks this for you with a compatibility mode.

ModeGuaranteeWho upgrades firstAllowed changes
BACKWARD (the default)Consumers with the new schema can read data written with the previous one.ConsumersRemove fields. Add fields that have a default.
FORWARDConsumers with the previous schema can read data written with the new one.ProducersAdd fields. Remove fields that have a default.
FULLBoth of the above.Any orderAdd or remove only fields that have a default.
NONENo check.—Anything. You carry the risk.

Each mode checks against the latest version only. The variants with _TRANSITIVE in the name check against every earlier version, which matters when consumers replay old data.

Connect is a pipeline you configure

Source systemA database, a file, an application interface.
Source connectorReads the system and produces records.
TransformsOptional small changes to each record.
ConverterTurns the record into bytes, and back.
Kafka topic
Sink connectorConsumes records and writes them to the target.
A worker is the process that runs connectors. A connector splits its job into tasks, and workers share the tasks. A sink connector is a consumer group; a source connector tracks its own position in the source system.

Two writes that must agree

A service saves an order in its database and publishes "order created" to Kafka. These are two systems. If the service crashes between the two writes, one happened and the other did not. This is the dual write problem. Step through the failure and the fix:

Step through it: dual write and the outbox

Design

database

kafka topic

Choose a design and press the button.
offset 2

How it works internally

Schema registry

A schema registry is a separate service. It is not part of Apache Kafka; the most used one is from Confluent, and others exist. It stores its data in a compacted Kafka topic, _schemas, so it has no database of its own.

  1. Schemas are grouped under a subject. By default the subject is the topic name plus -value or -key.
  2. The producer's serializer sends the record's schema to the registry. If it is new, the registry checks it against the subject's compatibility mode. If it passes, it gets a version number and a global schema id.
  3. The serializer writes the schema id in front of the record's bytes. The schema itself is not sent with each record.
  4. The consumer's deserializer reads the id, fetches that schema from the registry once, caches it, and decodes the record.

Three formats are common: Avro, Protobuf and JSON Schema. Avro and Protobuf produce small binary records and have clear rules for adding and removing fields.

Kafka Connect

Connect runs in two modes. Standalone is one process with configuration in files, for tests. Distributed is a group of workers that you control through an HTTP interface on port 8083. Use distributed mode for anything real.

A distributed worker group keeps all its state in three Kafka topics:

Topic settingHolds
config.storage.topicEvery connector's configuration.
offset.storage.topicHow far each source connector has read in its source system.
status.storage.topicThe state of each connector and task.

Workers with the same group.id form one cluster. If a worker stops, its tasks move to the others, in the same way that partitions move in a consumer group.

A converter decides the byte format in the topic: plain text, JSON, or Avro with a schema registry. A transform makes a small change to each record, such as renaming a field or routing to a different topic. Keep transforms simple; real processing belongs in Kafka Streams.

Errors in Connect

By default one bad record stops a task: errors.tolerance=none. With errors.tolerance=all, the task skips records that fail in the converter or a transform. For a sink connector you can name a dead-letter topic. The failed record is written there unchanged, with headers that say what failed and where. The lab shows both behaviours.

Change data capture

A database already keeps an ordered log of every change, for its own recovery and replication. Change data capture reads that log and publishes each insert, update and delete as a record. The best-known tool is Debezium, a set of source connectors for Kafka Connect.

  • It first takes a snapshot of the existing rows, then follows the log.
  • Each record carries the row before and after the change and the type of operation.
  • It sees every change in commit order, including changes made by other applications and by hand.
  • It adds little load, because it reads the log and does not query the tables.

Module 13 runs Debezium against PostgreSQL: it shows the snapshot, the insert, update and delete records, and an outbox with a rolled-back transaction.

The outbox pattern

The service writes its business change and an event row into the same database, in one database transaction. The event row goes into an outbox table. A change data capture connector reads new outbox rows from the database log and publishes them to Kafka. The database transaction makes the two writes atomic. The connector delivers each event at least once, so consumers must ignore repeats by event id.

Dead-letter topics in your own consumers

The same idea applies outside Connect. When your consumer cannot process a record after its retries, it writes the record to a separate topic with the error and the original topic, partition and offset, then commits and continues. Someone, or something, must then read that topic: alert on it, fix the cause, and replay.

Two cautions. Moving a record aside breaks ordering for its key: later records for that key may be processed before it is replayed. And an error that affects every record, such as a database that is down, should stop the consumer, not send everything to the dead-letter topic.

offset 3

Every relevant config

Connect defaults come from the Kafka 4.3 Connect configuration reference. Where that page gave no value, the cell says so.

Connect worker

Worker configDefaultWhat it doesWhen to change it
bootstrap.serversrequiredBroker addresses.—
group.idrequiredNames the worker cluster. Workers with the same value work together.One value for each Connect cluster. It must differ from every consumer group id.
key.converter, value.converterrequiredThe byte format of keys and values in topics.Set a cluster default here and override it for a connector when needed.
header.converterSimpleHeaderConverterThe format of record headers.Rarely.
config.storage.topic, offset.storage.topic, status.storage.topicrequiredThe three internal topics.Unique names for each Connect cluster.
config.storage.replication.factor, offset.storage.replication.factor, status.storage.replication.factor3Copies of those topics.Keep 3.
offset.storage.partitions, status.storage.partitions25, 5Partition counts of two internal topics. The config topic always has one.Rarely.
plugin.pathnullWhere connector files are found.Always set. The lab points it at the file connector that ships with Kafka.
listenershttp://:8083Address of the HTTP interface.Protect it. Anyone who can reach it can create connectors.
offset.flush.interval.ms60000 (1 minute)How often source positions are saved.Lower it to repeat less after a crash.
exactly.once.source.supportdisabledLets source connectors write records and their source position in one transaction.Turn on when a source connector supports it and duplicates matter.
connector.client.config.override.policyAllWhether a connector may override producer and consumer settings.Restrict it on a shared Connect cluster.
scheduled.rebalance.max.delay.ms300000 (5 minutes)How long the tasks of a worker that left wait for it to return before they move.Lower for faster failover; higher to avoid moves during a rolling restart.
task.shutdown.graceful.timeout.ms5000Time given to tasks to stop cleanly.Rarely.

Each connector

Connector configDefaultWhat it doesWhen to change it
connector.classrequiredWhich connector to run.—
tasks.max1The most tasks the connector may create.Raise for parallel work. A sink cannot use more tasks than partitions.
topics (sink)required for sinksThe topics to consume.—
transforms, predicatesemptyA list of small changes to apply, and conditions for them.For renames, routing, masking a field.
errors.tolerancenoneall skips records that fail in conversion or transforms.Use all only together with a dead-letter topic or error logging.
errors.deadletterqueue.topic.nameno value given in the reference page; empty in practiceSink connectors only: where failed records go.Set it whenever tolerance is all.
errors.deadletterqueue.context.headers.enableno value given in the reference page; off in the lab until setAdds headers with the error and the original position.Turn on. Without it you cannot tell why a record failed.
errors.retry.timeout, errors.retry.delay.max.ms0, 60000How long to retry a failed operation, and the longest pause between tries.Raise the first for target systems that are briefly unavailable.
errors.log.enable, errors.log.include.messagesfalse, falseWrites failures to the worker log, with or without record content.Turn on the first. Think about private data before the second.
exactly.once.support, transaction.boundary (source)requested, pollExactly-once settings for a source connector.With exactly.once.source.support on the worker.

Schema registry and its serializers

SettingDefaultWhat it doesWhen to change it
Compatibility level (global or for one subject)BACKWARD (read from the lab registry)The rule for new schema versions.Use FULL or a transitive variant for topics with many independent consumers or long retention.
Storage topic_schemas, one partition, compacted (seen in the lab)Where the registry keeps everything.Protect it. If it is lost, no consumer can decode old records.
schema.registry.url (client)requiredWhere serializers find the registry.—
auto.register.schemas (client)trueLets a producer register a new schema at run time.Set false in production and register schemas in the build pipeline, so that a schema change is reviewed. unverified default
offset 4

Failure scenarios

A schema change breaks consumers

What breaks
Consumers throw on every record, or silently read wrong values.
Cause
No registry, or compatibility set to NONE, or a field was renamed or its type changed.
Fix
Check compatibility in the build pipeline before a release, with the registry's test call. To rename or retype a field, add a new field, move readers and writers over, then remove the old one.

The registry is unavailable

What breaks
New producers and consumers cannot start, and any client that meets a schema id it has not cached fails. Clients that already have their schemas in memory keep working.
Fix
Run more than one registry instance. Treat it as a critical dependency of every client.

A connector task fails on one record

What breaks
The task stops. Nothing after that record is delivered. The connector itself still reports "running".
You observe
In the lab: connector state RUNNING, task state FAILED, with the trace "Tolerance exceeded in error handler". The output file stayed empty although two of the three records were good.
Fix
Monitor task states, not only connector states. Decide on tolerance and a dead-letter topic for each connector. Restart a failed task through the HTTP interface after fixing the cause.

The dead-letter topic fills and nobody looks

What breaks
Records are "handled" by moving them aside, and are lost in practice.
Fix
Alert on any record arriving in a dead-letter topic. Give the topic an owner and a replay procedure. Set its retention long enough to act.

Dual write leaves the database and Kafka different

What breaks
An order exists with no event, or an event exists for an order that was rolled back.
Cause
Two separate writes with a crash, a timeout or an error between them.
Fix
The outbox pattern, or change data capture on the business tables directly.

A stopped capture connector fills the database disk

What breaks
The database keeps its change log until the connector confirms it has read it. If the connector is down for long, the log grows until the disk is full and the database stops.
Fix
Alert on the connector's lag behind the database log and on the size of the retained log. Have a procedure to drop the connector's position in an emergency and take a new snapshot. Module 13 measured this: a stopped connector left its replication slot behind by 8.6 MB after 60,000 rows.

A source connector repeats records after a crash

Cause
Source positions are saved every offset.flush.interval.ms, 1 minute. Records sent after the last save are sent again.
Fix
Make consumers ignore repeats, or turn on exactly-once source support if the connector allows it.
offset 5

Hands-on lab

Every command below was run on 4 October 2026 against the lab cluster. Part A uses Kafka Connect, which is inside the Kafka image. Part B uses a schema registry container that I added to docker-compose.yml; it listens on port 8086 of your machine.

k() { docker exec -i kafka-1 /opt/kafka/bin/"$@"; }
B=kafka-1:19092

Part A, step 1. Start a Connect worker

docker exec kafka-1 sh -c 'cat > /tmp/connect.properties <<EOF
bootstrap.servers=kafka-1:19092
group.id=connect-lab
key.converter=org.apache.kafka.connect.storage.StringConverter
value.converter=org.apache.kafka.connect.storage.StringConverter
offset.storage.topic=connect-offsets
config.storage.topic=connect-configs
status.storage.topic=connect-status
plugin.path=/opt/kafka/libs/connect-file-4.3.1.jar
listeners=http://0.0.0.0:8083
EOF'
docker exec -d kafka-1 sh -c '/opt/kafka/bin/connect-distributed.sh /tmp/connect.properties > /tmp/connect.log 2>&1'

# the image has wget but not curl; define two helpers for the HTTP interface
get()  { docker exec kafka-1 wget -q -O - "http://localhost:8083$1"; }
post() { docker exec kafka-1 wget -q -O - --header 'Content-Type: application/json' --post-data "$2" "http://localhost:8083$1"; }

# wait about 20 seconds
get /
get /connector-plugins
k kafka-topics.sh --bootstrap-server $B --list | grep connect
{"version":"4.3.1","commit":"26b251a451ce941d","kafka_cluster_id":"4L6g3nShT-eMCtK--X86sw"}

FileStreamSinkConnector (sink), FileStreamSourceConnector (source),
MirrorCheckpointConnector, MirrorHeartbeatConnector, MirrorSourceConnector

connect-configs
connect-offsets
connect-status

Part A, step 2. File to topic to file

docker exec kafka-1 sh -c 'printf "line one\nline two\n" > /tmp/in.txt'

post /connectors '{"name":"file-source","config":{"connector.class":"org.apache.kafka.connect.file.FileStreamSourceConnector","tasks.max":"1","file":"/tmp/in.txt","topic":"connect-lines"}}'
post /connectors '{"name":"file-sink","config":{"connector.class":"org.apache.kafka.connect.file.FileStreamSinkConnector","tasks.max":"1","file":"/tmp/out.txt","topics":"connect-lines"}}'

# wait 8 seconds, then add a line while the connectors run
docker exec kafka-1 sh -c 'echo "line three" >> /tmp/in.txt'
# wait 6 seconds
docker exec kafka-1 cat /tmp/out.txt
get /connectors
get /connectors/file-source/status
k kafka-consumer-groups.sh --bootstrap-server $B --describe --group connect-file-sink
line one
line two
line three

["file-source","file-sink"]
{"name":"file-source","connector":{"state":"RUNNING",…},"tasks":[{"id":0,"state":"RUNNING",…}],"type":"source"}

GROUP              TOPIC          PARTITION  LOG-END-OFFSET
connect-file-sink  connect-lines  0          3

No code was written. The third line appeared in the output file a few seconds after it was added to the input file. The sink connector is a consumer group named connect- plus its name, so the lag tools from module 03 work on it.

Part A, step 3. One bad record, no tolerance

Put three records in a topic. The middle one is not JSON. Start a sink that expects JSON.

k kafka-topics.sh --bootstrap-server $B --create --topic json-in --partitions 1 --replication-factor 3
printf '{"id":1}\nthis is not json\n{"id":2}\n' | k kafka-console-producer.sh --bootstrap-server $B --topic json-in

post /connectors '{"name":"json-strict","config":{"connector.class":"org.apache.kafka.connect.file.FileStreamSinkConnector","tasks.max":"1","file":"/tmp/strict.txt","topics":"json-in","value.converter":"org.apache.kafka.connect.json.JsonConverter","value.converter.schemas.enable":"false"}}'
# wait 10 seconds
get /connectors/json-strict/status
docker exec kafka-1 cat /tmp/strict.txt
{"name":"json-strict","connector":{"state":"RUNNING",…},
 "tasks":[{"id":0,"state":"FAILED",…,"trace":"org.apache.kafka.connect.errors.ConnectException: Tolerance exceeded in error handler …

(strict.txt is empty)

The connector says RUNNING and its task says FAILED. The output file is empty: the two good records were not delivered either.

Part A, step 4. The same, with a dead-letter topic

post /connectors '{"name":"json-dlq","config":{"connector.class":"org.apache.kafka.connect.file.FileStreamSinkConnector","tasks.max":"1","file":"/tmp/dlq-ok.txt","topics":"json-in","value.converter":"org.apache.kafka.connect.json.JsonConverter","value.converter.schemas.enable":"false","errors.tolerance":"all","errors.deadletterqueue.topic.name":"json-in-dlq","errors.deadletterqueue.topic.replication.factor":"3","errors.deadletterqueue.context.headers.enable":"true"}}'
# wait 12 seconds
get /connectors/json-dlq/status
docker exec kafka-1 cat /tmp/dlq-ok.txt
k kafka-console-consumer.sh --bootstrap-server $B --topic json-in-dlq --from-beginning \
  --formatter-property print.headers=true --timeout-ms 6000
{"name":"json-dlq","connector":{"state":"RUNNING",…},"tasks":[{"id":0,"state":"RUNNING",…}],"type":"sink"}

{id=1}
{id=2}

__connect.errors.topic:json-in,__connect.errors.partition:0,__connect.errors.offset:1,
__connect.errors.connector.name:json-dlq,__connect.errors.task.id:0,
__connect.errors.stage:VALUE_CONVERTER,
__connect.errors.class.name:org.apache.kafka.connect.json.JsonConverter,
__connect.errors.exception.class.name:org.apache.kafka.connect.errors.DataException,
__connect.errors.exception.message:Converting byte[] to Kafka Connect data failed due to serialization error: ,
__connect.errors.exception.stacktrace:… Unrecognized token 'this': was expecting (JSON String, Number, …)
    this is not json

The task keeps running and both good records are delivered. The bad record is in the dead-letter topic with its original value, and the headers say exactly where it came from (topic json-in, partition 0, offset 1), which stage failed, and why.

Part B, step 1. Start the schema registry

docker compose up -d schema-registry
R=http://localhost:8086
H='Content-Type: application/vnd.schemaregistry.v1+json'
curl -s $R/config
{"compatibilityLevel":"BACKWARD"}

Part B, step 2. Register a schema and evolve it

Version 1 has id and amount. Version 2 adds currency with a default. Version 3 tries to add customerId without a default.

V1='{"schema":"{\"type\":\"record\",\"name\":\"Order\",\"fields\":[{\"name\":\"id\",\"type\":\"string\"},{\"name\":\"amount\",\"type\":\"double\"}]}"}'
V2='{"schema":"{\"type\":\"record\",\"name\":\"Order\",\"fields\":[{\"name\":\"id\",\"type\":\"string\"},{\"name\":\"amount\",\"type\":\"double\"},{\"name\":\"currency\",\"type\":\"string\",\"default\":\"CAD\"}]}"}'
V3='{"schema":"{\"type\":\"record\",\"name\":\"Order\",\"fields\":[{\"name\":\"id\",\"type\":\"string\"},{\"name\":\"amount\",\"type\":\"double\"},{\"name\":\"currency\",\"type\":\"string\",\"default\":\"CAD\"},{\"name\":\"customerId\",\"type\":\"string\"}]}"}'

curl -s -X POST -H "$H" -d "$V1" $R/subjects/orders-value/versions
curl -s -X POST -H "$H" -d "$V2" $R/subjects/orders-value/versions
curl -s -X POST -H "$H" -d "$V3" $R/subjects/orders-value/versions
curl -s $R/subjects/orders-value/versions
{"id":1,"version":1,…,"schemaType":"AVRO",…}
{"id":2,"version":2,…}
{"error_code":40901,"message":"Schema being registered is incompatible with an earlier schema for subject
 \"orders-value\", details: [{errorType:'READER_FIELD_MISSING_DEFAULT_VALUE', description:'The field
 'customerId' at path '/fields/3' in the new schema has no default value and is missing in the old schema' …
[1,2]

The registry refused version 3. A consumer with the new schema could not read an old record, because the old record has no customerId and the schema gives no default to use.

Part B, step 3. Test a change without registering it

Version 4 removes amount. Test it under the default mode, then switch the subject to FULL and test again.

V4='{"schema":"{\"type\":\"record\",\"name\":\"Order\",\"fields\":[{\"name\":\"id\",\"type\":\"string\"},{\"name\":\"currency\",\"type\":\"string\",\"default\":\"CAD\"}]}"}'
curl -s -X POST -H "$H" -d "$V4" "$R/compatibility/subjects/orders-value/versions/latest?verbose=true"

curl -s -X PUT -H "$H" -d '{"compatibility":"FULL"}' $R/config/orders-value
curl -s -X POST -H "$H" -d "$V4" "$R/compatibility/subjects/orders-value/versions/latest?verbose=true"
{"is_compatible":true,"messages":[]}

{"compatibility":"FULL"}
{"is_compatible":false,"messages":["{errorType:'READER_FIELD_MISSING_DEFAULT_VALUE', description:'The field
 'amount' at path '/fields/1' in the old schema has no default value and is missing in the new schema' …

Removing a field is allowed under BACKWARD: new consumers simply ignore it in old data. Under FULL it is refused, because old consumers still expect amount and would find nothing. This test call is what a build pipeline should run before a release.

Part B, step 4. See where the registry keeps its data

k kafka-topics.sh --bootstrap-server $B --describe --topic _schemas | head -1
Topic: _schemas  PartitionCount: 1  ReplicationFactor: 3  Configs: min.insync.replicas=2,cleanup.policy=compact
offset 6

Common mistakes and misconceptions

People thinkWhat is true
Kafka checks the format of records.Brokers store bytes. Checks happen in the clients, with a registry.
The schema registry is part of Apache Kafka.It is a separate product. Kafka Connect is part of Apache Kafka.
BACKWARD means old consumers keep working.It means new consumers can read old data. Old consumers reading new data is FORWARD.
JSON needs no schema.It has one; it is only unwritten. Unwritten contracts break without warning.
A connector in state "running" is healthy.Its tasks can be failed. Check task states.
A dead-letter topic solves bad records.It moves them. Someone must read, fix and replay them.
Writing to the database and then to Kafka is safe if I retry.A crash between the two leaves them different. Use an outbox or change data capture.
Polling a table for new rows is the same as change data capture.Polling misses deletes and intermediate updates, and adds query load. Reading the database log does neither.
offset 7

Staff-level questions

Which compatibility mode do you set as the company default?

BACKWARD fits the common case where consumers can be upgraded before producers and data is not replayed from far back. For topics with long retention or with consumers that reprocess history, use BACKWARD_TRANSITIVE, because a new consumer must read every old version.

For topics that many teams consume, where you cannot make them all upgrade first, FULL_TRANSITIVE is the safe choice: only optional fields can come and go, and any version can read any other.

How do you make a breaking change to a topic's schema?

You do not change it in place. Create a new topic with the new schema, for example with a version in its name. Produce to both for a time, or run a small job that converts one into the other. Move consumers one by one. Stop the old topic when its last consumer has left and its retention has passed.

This is slow on purpose. A topic's schema is a public interface.

Outbox table or change data capture on the business tables?

Capture on business tables is quick to start, needs no application change, and publishes the internal table layout. Every consumer then depends on your columns, and a refactoring of the database breaks them.

An outbox publishes events you designed, with a stable shape and a business meaning ("order confirmed"), at the cost of writing them in the application. For events that other teams consume, prefer the outbox. For copying data to a warehouse or a cache you own, direct capture is fine.

How do you run Kafka Connect for many teams?

Decide between one shared cluster and one for each team or workload. Shared is cheaper, but connectors share memory and a bad one can disturb the others, and a rebalance touches all tasks. Separate clusters isolate failures and upgrades.

In either case: lock down the HTTP interface, restrict which client settings connectors may override, keep credentials in a secrets provider and out of connector configurations, monitor every task state and sink lag, and manage connector configurations from version control.

Design the retry and dead-letter handling for a consumer that calls a payment service.

Separate the errors. A temporary error, such as a timeout, deserves retries with growing pauses. If the payment service is down, pause the consumer; do not push thousands of records aside. A permanent error on one record, such as an invalid field, goes to a dead-letter topic at once with the error and the original position.

Then handle order. If payments for one account must stay in order, moving one aside lets later ones overtake it. Either hold all later records for that key until it is resolved, or design the operation so that order does not matter. Finally make the call safe to repeat with a request id, because the consumer delivers at least once.

What breaks if the _schemas topic is lost?

Every record written with the registry contains only a schema id. Without the registry's data, nobody can decode records, old or new. Re-registering schemas may give different ids, which does not help for existing data.

So treat that topic as critical data: three copies, a backup of its content outside the cluster, and a tested restore that keeps the ids.

offset 8

Self-test

1. What does a broker know about the content of a record?

Nothing. Keys and values are bytes to it.

2. What does BACKWARD compatibility guarantee?

A consumer with the new schema can read data written with the previous schema.

3. Under BACKWARD, is "add a field without a default" allowed?

No. The lab registry refused it with READER_FIELD_MISSING_DEFAULT_VALUE.

4. What travels with each record when a schema registry is used?

A schema id, not the schema.

5. Name the three internal topics of a distributed Connect cluster.

The config, offset and status storage topics.

6. What is the difference between a connector and a task?

The connector defines the job and splits it. Tasks do the copying, and workers share them.

7. What does errors.tolerance=all do without a dead-letter topic?

It skips records that fail in conversion or transforms, without keeping them.

8. What is the dual write problem?

Writing to a database and to Kafka as two separate steps, so that a failure between them leaves the two different.

9. How does the outbox pattern solve it?

The event is written to a table in the same database transaction as the business change, and a capture connector publishes it to Kafka afterwards.

10. What does change data capture read?

The database's own log of changes.