debezium / debezium/dbz

Cassandra 5 CDC: NullPointerException on ALTER TABLE Enabling CDC on a Pre-Existing Table

Open
#2,541 1 comment 0 reactions 0 assignees View on GitHub
component/cassandra-connector type/bug
Dominant language
HTML
Stars
6
Forks
8
Avg merge
2d 19h
Merged PRs (30d)
1

Description

## Bug report

**What Debezium connector do you use and what version?**

`debezium-connector-cassandra-5`, version `3.6.0.Final`
(`io.debezium.connector.cassandra.Cassandra5Connector`)

---

**What is the connector configuration?**

```json
{
"connector.class": "io.debezium.connector.cassandra.Cassandra5Connector",
"topic.prefix": "cdc",
"cassandra.config": "/path/to/cassandra.yaml",
"commit.log.relocation.dir": "/var/lib/debezium/relocation",
"offset.backing.store.dir": "/var/lib/debezium/offset",
"commit.log.real.time.processing.enabled": "true"
}
```

The bug is independent of `commit.log.real.time.processing.enabled` — it occurs in both
modes because it happens in the schema change listener, before any commit log processing.

---

**What is the captured database version and mode of deployment?**

- Apache Cassandra 5.0.8, deployed on Kubernetes as a StatefulSet
- Debezium connector running as a sidecar container on the same pod as Cassandra
- `cdc_enabled: true` in `cassandra.yaml`

---

**What behavior do you expect?**

When CDC is enabled on a pre-existing table via `ALTER TABLE . WITH cdc = true`,
the connector's schema change listener should detect the change and start capturing the table.
Specifically, `Cassandra5SchemaChangeListener.onTableUpdated()` should add the table to the CDC
entity list so that subsequent mutations are streamed to Kafka.

This is the only way a user can enable CDC on a table that already exists (for example after a
service upgrade that introduces Debezium to a cluster with existing data). The connector must
handle it without failing.

---

**What behavior do you see?**

This bug was discovered during investigation of a related issue: [#2540— Cassandra 5 CDC: Polling Mode Broken in Post-Upgrade and Post-Restore Scenarios](https://github.com/debezium/dbz/issues/2540).

The schema refresh fails with a `NullPointerException` and the listener silently does not add
the table. The connector stays `RUNNING` but never captures the table — no events are ever
produced for it.

```
WARN [s0] Unexpected error while refreshing schema for a SCHEMA_CHANGE event,
keeping previous version (CompletionException: java.lang.NullPointerException:
Cannot invoke "Object.toString()" because "oldCdcObject" is null)
(com.datastax.oss.driver.internal.core.control.ControlConnection)
```

### Root cause

The bug is in `Cassandra5SchemaChangeListener.onTableUpdated()`:

```java
@Override
public void onTableUpdated(final TableMetadata newTableMetadata,
final TableMetadata oldTableMetaData) {
Object newCdcObject = newTableMetadata.getOptions().get(CqlIdentifier.fromInternal("cdc"));
boolean newCdc = newCdcObject.toString().equals("true");
Object oldCdcObject = oldTableMetaData.getOptions().get(CqlIdentifier.fromInternal("cdc"));
boolean oldCdc = oldCdcObject.toString().equals("true"); // <-- NPE here
...
}
```

For a table created without CDC, the `cdc` option is absent from the table metadata, so
`getOptions().get(CqlIdentifier.fromInternal("cdc"))` returns `null`. Calling `.toString()`
on it throws `NullPointerException`.

When `ALTER TABLE ... WITH cdc = true` is issued:
- `newCdcObject` is `true` (the new value exists) — no NPE on that line
- `oldCdcObject` is `null` (the old table had no cdc option) — NPE on `oldCdcObject.toString()`

The same `null` risk exists on `newCdcObject` when CDC is being disabled on a table that had
it, or in other metadata edge cases.

The same pattern is present in `Cassandra3SchemaChangeListener` and
`Cassandra4SchemaChangeListener`.

### When does this happen?

This is triggered by the standard, and only, way of enabling CDC on an existing table:

```sql
ALTER TABLE my_keyspace.my_table WITH cdc = true;
```

It is especially relevant in the upgrade scenario: a cluster with existing tables is upgraded
to a version that runs Debezium, and the operator then enables CDC on those pre-existing
tables. Every such `ALTER TABLE` hits this NPE.

Tables created with `WITH cdc = true` from the start are not affected, because they are
picked up by `onTableCreated()` (or by the initial schema scan on connector start), not
`onTableUpdated()`.

---

**Do you see the same behaviour using the latest released Debezium version?**

Tested with `3.6.0.Final`. Not tested with a newer version or alpha/beta release.

---

**Do you have the connector logs, ideally from start till finish?**

The connector logs the NPE at the moment of the `ALTER TABLE`, then continues running
normally but without the table in its CDC entity list:

```
WARN [s0] Unexpected error while refreshing schema for a SCHEMA_CHANGE event,
keeping previous version (CompletionException: java.lang.NullPointerException:
Cannot invoke "Object.toString()" because "oldCdcObject" is null)
```

Subsequent `CDC enabled entities: [...]` log lines do not include the altered table.
A connector/pod restart works around the issue because the initial schema scan reads the
current schema directly and correctly sees `cdc = true`, bypassing `onTableUpdated()`.

---

**How to reproduce the issue using our [tutorial](https://github.com/debezium/debezium-examples/tree/main/tutorial) deployment?**

1. Start Cassandra 5 with `cdc_enabled: true`.
2. Create a table WITHOUT CDC:
```sql
CREATE TABLE ks.tbl (id text PRIMARY KEY, data text);
```
3. Start the connector and confirm it is RUNNING.
4. Enable CDC on the existing table:
```sql
ALTER TABLE ks.tbl WITH cdc = true;
```
5. Observe the NPE in the connector logs
(`Cannot invoke "Object.toString()" because "oldCdcObject" is null`).
6. Insert rows into `ks.tbl` and confirm no events are produced on the CDC topic.
7. Confirm the table is missing from the `CDC enabled entities` list.

---

## Suggested fix (not part of the official bug report template)

Add null checks before calling `toString()`:

```java
Object newCdcObject = newTableMetadata.getOptions().get(CqlIdentifier.fromInternal("cdc"));
boolean newCdc = newCdcObject != null && newCdcObject.toString().equals("true");
Object oldCdcObject = oldTableMetaData.getOptions().get(CqlIdentifier.fromInternal("cdc"));
boolean oldCdc = oldCdcObject != null && oldCdcObject.toString().equals("true");
```

This should be applied to `Cassandra3SchemaChangeListener`, `Cassandra4SchemaChangeListener`,
and `Cassandra5SchemaChangeListener`.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.