Skip to content

[improve][pulsar-io] Added support for generic record and raw JSON string schemas to CassandraSink - #16179

Closed
david-streamlio wants to merge 16 commits into
apache:masterfrom
david-streamlio:cassandra-sink-fix
Closed

[improve][pulsar-io] Added support for generic record and raw JSON string schemas to CassandraSink#16179
david-streamlio wants to merge 16 commits into
apache:masterfrom
david-streamlio:cassandra-sink-fix

Conversation

@david-streamlio

Copy link
Copy Markdown
Contributor

Motivation

The current implementation of the Cassandra Sink connector only supported a single schema type (key, string). This is not useful for production. So I modified the code to be able to support any schema type in Cassandra.

Modifications

Added classes that interrogate the database to determine the schema type at runtime. I also added a framework that will extract the values from the supported incoming schema types (GenericRecord, and String) using the table metadata.

Verifying this change

  • [ x] Make sure that the change passes the CI checks.

(Please pick either of the following options)

This change added tests and can be verified as follows:

Added integration tests for testing against a Cassandra database

Does this pull request potentially affect one of the following parts:

If yes was chosen, please highlight the changes

  • Dependencies (does it add or upgrade a dependency): (yes)
  • The public API: (no)
  • The schema: (yes)
  • The default values of configurations: (no)
  • The wire protocol: (no)
  • The rest endpoints: (no)
  • The admin cli options: ( no)
  • Anything that affects deployment: (no)

Documentation

Check the box below or label this PR directly.

Need to update docs?

  • [ x] doc-required
    (Your PR needs to update docs and you will update later)

@github-actions github-actions Bot added the doc-required Your PR changes impact docs and you will update later. label Jun 22, 2022
@david-streamlio

Copy link
Copy Markdown
Contributor Author

@tspannhw can you review and upvote?

@github-actions

Copy link
Copy Markdown

The pr had no activity for 30 days, mark with Stale label.

@github-actions github-actions Bot added the Stale label Aug 21, 2022
@david-streamlio

Copy link
Copy Markdown
Contributor Author

/pulsarbot run-failure-checks

@david-streamlio

Copy link
Copy Markdown
Contributor Author

/pulsarbot ready-to-test

@eolivelli eolivelli left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work, thanks

I left some comments, please take a look

Comment thread pulsar-io/cassandra/pom.xml Outdated
@codecov-commenter

codecov-commenter commented Oct 13, 2022

Copy link
Copy Markdown

Codecov Report

Merging #16179 (21cedba) into master (1b5722d) will decrease coverage by 0.04%.
The diff coverage is n/a.

Impacted file tree graph

@@             Coverage Diff              @@
##             master   #16179      +/-   ##
============================================
- Coverage     46.34%   46.29%   -0.05%     
- Complexity    10394    10420      +26     
============================================
  Files           703      703              
  Lines         68838    68858      +20     
  Branches       7379     7383       +4     
============================================
- Hits          31905    31880      -25     
- Misses        33324    33375      +51     
+ Partials       3609     3603       -6     
Flag Coverage Δ
unittests 46.29% <ø> (-0.05%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Impacted Files Coverage Δ
...java/org/apache/pulsar/proxy/stats/TopicStats.java 58.82% <0.00%> (-41.18%) ⬇️
...lsar/broker/loadbalance/impl/ThresholdShedder.java 3.27% <0.00%> (-27.87%) ⬇️
.../apache/pulsar/broker/loadbalance/LoadManager.java 61.11% <0.00%> (-16.67%) ⬇️
...rg/apache/pulsar/broker/lookup/v1/TopicLookup.java 60.00% <0.00%> (-13.34%) ⬇️
...roker/service/persistent/MessageDeduplication.java 43.23% <0.00%> (-10.49%) ⬇️
...org/apache/pulsar/broker/loadbalance/LoadData.java 58.33% <0.00%> (-8.34%) ⬇️
...he/pulsar/client/impl/PartitionedProducerImpl.java 30.34% <0.00%> (-5.13%) ⬇️
.../apache/pulsar/client/impl/BatchMessageIdImpl.java 67.50% <0.00%> (-4.73%) ⬇️
...tent/PersistentDispatcherSingleActiveConsumer.java 55.17% <0.00%> (-4.71%) ⬇️
...pulsar/broker/service/PulsarCommandSenderImpl.java 73.84% <0.00%> (-4.62%) ⬇️
... and 64 more

@david-streamlio

Copy link
Copy Markdown
Contributor Author

/pulsarbot run-failure-checks

@david-streamlio

Copy link
Copy Markdown
Contributor Author

@eolivelli I have made the requested changes, can you PTAL when you get a chance? Thank!

@david-streamlio

Copy link
Copy Markdown
Contributor Author

@eolivelli , Can you please take a look at this when you get the chance? Thanks again!

@david-streamlio

Copy link
Copy Markdown
Contributor Author

@eolivelli Can I please get some feedback on these changes I made in response to your initial feedback? Thanks again for the review, I really appreciated it.

@tisonkun
tisonkun requested a review from eolivelli December 11, 2022 01:10
Signed-off-by: tison <wander4096@gmail.com>

@tisonkun tisonkun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@david-streamlio Please fix the compilation failure. It seems that you use JUnit while Pulsar use TestNG as the test platform.

I've pushed a merge commit and fixing for license header to your remote branch so be aware to git pull before working on it.

BTW, please try to expand all star import.

@david-streamlio

Copy link
Copy Markdown
Contributor Author

/pulsarbot run-failure-checks

@david-streamlio

Copy link
Copy Markdown
Contributor Author

@tisonkun @eolivelli I would appreciate another review when you have the time.

@eolivelli eolivelli left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

@Technoboy- Technoboy- added this to the 3.2.0 milestone Jul 31, 2023

@lhotari lhotari left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. There doesn't seem to be integration tests, but those could be added later.

Comment on lines -35 to -41
public class CassandraStringSink extends CassandraAbstractSink<String, String> {
@Override
public KeyValue<String, String> extractKeyValue(Record<byte[]> record) {
String key = record.getKey().orElseGet(() -> new String(record.getValue()));
return new KeyValue<>(key, new String(record.getValue()));
RecordWrapper<String> wrapRecord(Record<String> record) {
return new StringRecordWrapper(record.getValue());
}
} No newline at end of file

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The behavior of the existing CassandraStringSink shouldn't be modified since it breaks backwards compatibility.

@lhotari lhotari left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The behavior of the existing CassandraStringSink shouldn't be modified since it breaks backwards compatibility. Breaking existing behavior will be surprising for existing users. That's why it's better to add the new behavior only when specific configuration is used.

@david-streamlio

Copy link
Copy Markdown
Contributor Author

Closing this in favour of two PRs against apache/pulsar-connectors, where the connector now lives:

Why it moved rather than being updated here. PIP-465 removed the IO connectors from this
repository (#25408, merged 2026-03-27), a day after apache/pulsar-connectors was initialised with
the Cassandra connector as it then stood. The connector migrated cleanly; this PR, still open on top
of it, did not. Every file here targets pulsar-io/cassandra/**, which no longer exists on master,
plus a pom.xml, and none of those survive the Gradle migration either. Rebasing produces
delete/modify conflicts on all 23 files with nowhere to resolve them to, so the change had to be
ported rather than rebased. Apologies for leaving it sitting this long in the first place.

Why two PRs. Re-reading this one, it was doing two independent things under a title that mentions
only one. The Cassandra sink cannot connect to a cluster requiring authentication at all — there are
no credential fields and CassandraAbstractSink builds its Cluster with contact points only — and
that fix has nothing to do with schema mapping. It is four files and reviewable on its own, so it
should not wait behind a design discussion about field-to-column mapping. That is #136. The
schema-mapping work is #137, stacked on it.

@lhotari — your change request is addressed, and I took it further than asked. You wrote:

The behavior of the existing CassandraStringSink shouldn't be modified since it breaks backwards
compatibility. Breaking existing behavior will be surprising for existing users. That's why it's
better to add the new behavior only when specific configuration is used.

You were right, and re-reading the diff the damage was wider than the line you flagged. This PR also
dropped the @Connector annotation that registers the cassandra sink, changed the input from
Record<byte[]> to Record<String>, and removed the key-falls-back-to-value behaviour — on top of
reinterpreting the message as JSON. Four breaking changes to a shipped connector, for a feature that
did not need to touch it.

Rather than gate the new behaviour behind a configuration flag, #137 makes it a separate connector.
CassandraStringSink, CassandraAbstractSink and the cassandra entry in pulsar-io.yaml are
unchanged, and the new behaviour arrives as cassandra-generic-record (schema-carrying records) and
cassandra-json (raw JSON strings). An operator selects one by name or does not get it, so there is
no configuration path by which the existing sink can start behaving differently. Its diff is
additive — 934 insertions, zero deletions — and the pre-existing CassandraStringSinkTest passes
unmodified, which is the check that backs the compatibility claim.

One thing in #136 does touch the existing path and is worth your eye: config loading moves from
CassandraSinkConfig.load to IOConfigUtils.loadWithSecrets, so a sensitive password can come
from a Pulsar secret instead of plaintext config. It is the helper kinesis, redis, canal and
azure-data-explorer already use, and CassandraStringSinkTest passing unchanged is the evidence that
it is a drop-in for configs with no secrets — but it is a real change to that path and I would rather
point at it than have it found.

Your other note — that there were no integration tests and they could come later — is partly
answered. Between the two PRs there are Testcontainers tests for authentication, the connection, the
generated INSERT against real table metadata, and table-definition resolution. There is still no
end-to-end test driving either new sink through a running sink instance; that is called out in #137
as worth doing rather than left implied.

Thanks for the review. It was the right call and the design is better for it.

david-streamlio added a commit to david-streamlio/pulsar-connectors that referenced this pull request Aug 18, 2026
### Motivation

The Cassandra sink can only write one column. `CassandraStringSink` treats the
message value as an opaque string and writes it to the single column named by
`columnName`, keyed by `keyname`. A topic carrying an Avro or JSON schema has
named fields and a table with matching columns, and there is no way to land them
in more than one of those columns. The same applies to raw JSON on a topic with
no registered schema.

This ports the schema-mapping half of apache/pulsar#16179, which predates both
the Gradle migration and PIP-465's move of the connectors to this repository, so
it could not be rebased there. The authentication half is split out separately.

### Modifications

Two new sinks, both mapping record fields onto table columns by name, with the
target table's own definition deciding what gets written:

| Connector | Input | Behaviour |
|---|---|---|
| `cassandra` *(unchanged)* | `Record<byte[]>` | opaque string to the one configured column |
| `cassandra-generic-record` *(new)* | `GenericRecord` | record fields to columns, matched by name |
| `cassandra-json` *(new)* | `String` | top-level JSON fields to columns, matched by name |

**The existing sink is untouched.** `CassandraStringSink`, `CassandraAbstractSink`
and the `cassandra` entry in `pulsar-io.yaml` are unchanged by this commit, so no
existing deployment can be affected. That is a direct response to @lhotari's
review on #16179, which asked that existing behaviour not change and that the new
behaviour be opt-in. The original delivered this by rewriting `CassandraStringSink`
in place — dropping its `@Connector` registration, changing the input from
`Record<byte[]>` to `Record<String>`, removing the key-falls-back-to-value
behaviour, and reinterpreting the message as JSON. Here the JSON behaviour is its
own connector instead, so there is no configuration path by which the `cassandra`
sink can start behaving differently.

New machinery, used only by the new sinks:

- `CassandraTableSink<T>` reads the table definition from cluster metadata at
  `open()` and binds each column it can find a matching field for.
- `util/CassandraConnector` owns cluster/session lifecycle and the prepared
  `INSERT` built from the table's actual column list.
- `util/TableMetadataProvider` resolves a keyspace/table into its columns,
  partition key and primary key.
- `util/BoundStatementProvider` binds a wrapped record against that column list.
- `util/RecordWrapper` and its `GenericRecordWrapper` / `StringRecordWrapper`
  implementations read a named field out of each record type and coerce it to the
  column's declared type.

Deviations from the original, all deliberate:

- `close()` no longer swallows every `Throwable` in an empty catch block.
- `StringRecordWrapper` uses a `TypeReference` instead of a raw `Map.class`,
  removing an unchecked-conversion warning.
- `AbstractCassandraTest` applies `init.cql` through the Datastax driver rather
  than the container's `withInitScript`. The script delegate in the
  `org.testcontainers.cassandra` module is compiled against shaded classes the
  resolved Testcontainers core no longer ships, and fails with
  `NoClassDefFoundError`; the pre-existing test avoids this by using the older
  `org.testcontainers.containers.CassandraContainer`, and this now matches it.
- `CassandraSinkExec` and `producers/**` are not ported. `CassandraSinkExec` is an
  IDE-only `main()` that expects a broker on localhost and then sleeps for ten
  minutes; it would add `pulsar-functions-local-runner-original` to the test
  classpath for something CI never runs, and the producers exist only to feed it.

### Verifying this change

`:cassandra:check` passes: `CassandraConnectorTest` asserts the generated `INSERT`
matches a real table's column list for both a two-column and a seventeen-column
table, `TableMetadataProviderTest` asserts the resolved table definition, and the
pre-existing `CassandraStringSinkTest` still passes unchanged.

**Not covered:** no end-to-end test drives `CassandraGenericRecordSink` or
`CassandraJsonStringSink` through a running sink — the coverage is of the
machinery underneath them. The original PR's answer to this was the IDE harness
described above. Happy to add a proper integration test here rather than defer it.

### Does this pull request potentially affect one of the following parts:

- [ ] Dependencies (add or upgrade a dependency)
- [ ] The public API
- [ ] The schema
- [ ] The default values of configurations
- [ ] The threading model
- [ ] The binary protocol
- [ ] The REST endpoints
- [ ] The admin CLI options
- [ ] The metrics
- [x] Anything that affects deployment

**Deployment**: two new connectors, `cassandra-generic-record` and
`cassandra-json`, ship in the existing Cassandra NAR. Nothing changes for a
deployment using the `cassandra` sink. The module gains `pulsar-client-api` and
`commons-beanutils`, both already in the version catalog.

### Documentation

- [ ] `doc-required`
- [x] `doc-not-needed`
- [ ] `doc`
- [ ] `doc-complete`

Both new sinks are described by their `@Connector(help = ...)` text.
david-streamlio added a commit to david-streamlio/pulsar-connectors that referenced this pull request Aug 18, 2026
### Motivation

The Cassandra sink can only write one column. `CassandraStringSink` treats the
message value as an opaque string and writes it to the single column named by
`columnName`, keyed by `keyname`. A topic carrying an Avro or JSON schema has
named fields and a table with matching columns, and there is no way to land them
in more than one of those columns. The same applies to raw JSON on a topic with
no registered schema.

This ports the schema-mapping half of apache/pulsar#16179, which predates both
the Gradle migration and PIP-465's move of the connectors to this repository, so
it could not be rebased there. The authentication half is split out separately.

### Modifications

Two new sinks, both mapping record fields onto table columns by name, with the
target table's own definition deciding what gets written:

| Connector | Input | Behaviour |
|---|---|---|
| `cassandra` *(unchanged)* | `Record<byte[]>` | opaque string to the one configured column |
| `cassandra-generic-record` *(new)* | `GenericRecord` | record fields to columns, matched by name |
| `cassandra-json` *(new)* | `String` | top-level JSON fields to columns, matched by name |

**The existing sink is untouched.** `CassandraStringSink`, `CassandraAbstractSink`
and the `cassandra` entry in `pulsar-io.yaml` are unchanged by this commit, so no
existing deployment can be affected. That is a direct response to @lhotari's
review on #16179, which asked that existing behaviour not change and that the new
behaviour be opt-in. The original delivered this by rewriting `CassandraStringSink`
in place — dropping its `@Connector` registration, changing the input from
`Record<byte[]>` to `Record<String>`, removing the key-falls-back-to-value
behaviour, and reinterpreting the message as JSON. Here the JSON behaviour is its
own connector instead, so there is no configuration path by which the `cassandra`
sink can start behaving differently.

New machinery, used only by the new sinks:

- `CassandraTableSink<T>` reads the table definition from cluster metadata at
  `open()` and binds each column it can find a matching field for.
- `util/CassandraConnector` owns cluster/session lifecycle and the prepared
  `INSERT` built from the table's actual column list.
- `util/TableMetadataProvider` resolves a keyspace/table into its columns,
  partition key and primary key.
- `util/BoundStatementProvider` binds a wrapped record against that column list.
- `util/RecordWrapper` and its `GenericRecordWrapper` / `StringRecordWrapper`
  implementations read a named field out of each record type and coerce it to the
  column's declared type.

`keyname` and `columnName` become `required = false` on `CassandraSinkConfig`.
`IOConfigUtils.loadWithSecrets` enforces `required = true` for every sink sharing
the config, so leaving them required makes a table-sink config that omits them
fail to load — rejected for missing two settings that sink has no use for, before
it can say so. They are still required by the `cassandra` sink, which checks both
itself in `CassandraAbstractSink.open()` and is unchanged; their help text now says
which sink needs them.

`CassandraTableSink.open()` calls `cassandraSinkConfig.validateCredentials()` and
`CassandraConnector` asks `hasCredentials()`, so the new sinks reject a
half-configured credential pair on the same terms as the existing one rather than
quietly connecting unauthenticated.

Deviations from the original, all deliberate:

- `close()` no longer swallows every `Throwable` in an empty catch block.
- `StringRecordWrapper` uses a `TypeReference` instead of a raw `Map.class`,
  removing an unchecked-conversion warning.
- `AbstractCassandraTest` applies `init.cql` through the Datastax driver rather
  than the container's `withInitScript`. The script delegate in the
  `org.testcontainers.cassandra` module is compiled against shaded classes the
  resolved Testcontainers core no longer ships, and fails with
  `NoClassDefFoundError`; the pre-existing test avoids this by using the older
  `org.testcontainers.containers.CassandraContainer`, and this now matches it.
- `CassandraSinkExec` and `producers/**` are not ported. `CassandraSinkExec` is an
  IDE-only `main()` that expects a broker on localhost and then sleeps for ten
  minutes; it would add `pulsar-functions-local-runner-original` to the test
  classpath for something CI never runs, and the producers exist only to feed it.

### Verifying this change

`:cassandra:check` passes: `CassandraConnectorTest` asserts the generated `INSERT`
matches a real table's column list for both a two-column and a seventeen-column
table, `TableMetadataProviderTest` asserts the resolved table definition, and the
pre-existing `CassandraStringSinkTest` still passes unchanged.

`CassandraSinkConfigValidationTest` covers what each sink accepts and rejects at
`open()` before reaching a cluster: that the new sinks load without `keyname` and
`columnName`, that the `cassandra` sink still refuses to open without either, and
that all of them reject a half-configured credential pair. It points `roots` at a
port nothing listens on, so a config that gets as far as failing to connect is one
that passed validation — which is what makes the accepting cases mean anything.

**Not covered:** no end-to-end test drives `CassandraGenericRecordSink` or
`CassandraJsonStringSink` through a running sink — the coverage is of the
machinery underneath them. The original PR's answer to this was the IDE harness
described above. Happy to add a proper integration test here rather than defer it.

### Does this pull request potentially affect one of the following parts:

- [ ] Dependencies (add or upgrade a dependency)
- [ ] The public API
- [ ] The schema
- [ ] The default values of configurations
- [ ] The threading model
- [ ] The binary protocol
- [ ] The REST endpoints
- [ ] The admin CLI options
- [ ] The metrics
- [x] Anything that affects deployment

**Deployment**: two new connectors, `cassandra-generic-record` and
`cassandra-json`, ship in the existing Cassandra NAR. Nothing changes for a
deployment using the `cassandra` sink. The module gains `pulsar-client-api` and
`commons-beanutils`, both already in the version catalog.

### Documentation

- [ ] `doc-required`
- [x] `doc-not-needed`
- [ ] `doc`
- [ ] `doc-complete`

Both new sinks are described by their `@Connector(help = ...)` text.
david-streamlio added a commit to david-streamlio/pulsar-connectors that referenced this pull request Aug 18, 2026
### Motivation

The Cassandra sink can only write one column. `CassandraStringSink` treats the
message value as an opaque string and writes it to the single column named by
`columnName`, keyed by `keyname`. A topic carrying an Avro or JSON schema has
named fields and a table with matching columns, and there is no way to land them
in more than one of those columns. The same applies to raw JSON on a topic with
no registered schema.

This ports the schema-mapping half of apache/pulsar#16179, which predates both
the Gradle migration and PIP-465's move of the connectors to this repository, so
it could not be rebased there. The authentication half is split out separately.

### Modifications

Two new sinks, both mapping record fields onto table columns by name, with the
target table's own definition deciding what gets written:

| Connector | Input | Behaviour |
|---|---|---|
| `cassandra` *(unchanged)* | `Record<byte[]>` | opaque string to the one configured column |
| `cassandra-generic-record` *(new)* | `GenericRecord` | record fields to columns, matched by name |
| `cassandra-json` *(new)* | `String` | top-level JSON fields to columns, matched by name |

**The existing sink is untouched.** `CassandraStringSink`, `CassandraAbstractSink`
and the `cassandra` entry in `pulsar-io.yaml` are unchanged by this commit, so no
existing deployment can be affected. That is a direct response to @lhotari's
review on #16179, which asked that existing behaviour not change and that the new
behaviour be opt-in. The original delivered this by rewriting `CassandraStringSink`
in place — dropping its `@Connector` registration, changing the input from
`Record<byte[]>` to `Record<String>`, removing the key-falls-back-to-value
behaviour, and reinterpreting the message as JSON. Here the JSON behaviour is its
own connector instead, so there is no configuration path by which the `cassandra`
sink can start behaving differently.

New machinery, used only by the new sinks:

- `CassandraTableSink<T>` reads the table definition from cluster metadata at
  `open()` and binds each column it can find a matching field for.
- `util/CassandraConnector` owns cluster/session lifecycle and the prepared
  `INSERT` built from the table's actual column list.
- `util/TableMetadataProvider` resolves a keyspace/table into its columns,
  partition key and primary key.
- `util/BoundStatementProvider` binds a wrapped record against that column list.
- `util/RecordWrapper` and its `GenericRecordWrapper` / `StringRecordWrapper`
  implementations read a named field out of each record type and coerce it to the
  column's declared type.

`keyname` and `columnName` become `required = false` on `CassandraSinkConfig`.
`IOConfigUtils.loadWithSecrets` enforces `required = true` for every sink sharing
the config, so leaving them required makes a table-sink config that omits them
fail to load — rejected for missing two settings that sink has no use for, before
it can say so. They are still required by the `cassandra` sink, which checks both
itself in `CassandraAbstractSink.open()` and is unchanged; their help text now says
which sink needs them.

`CassandraTableSink.open()` calls `cassandraSinkConfig.validateCredentials()` and
`CassandraConnector` asks `hasCredentials()`, so the new sinks reject a
half-configured credential pair on the same terms as the existing one rather than
quietly connecting unauthenticated.

Two defects the end-to-end test below found, both in code this commit adds:

- `GenericRecordWrapper.containsKey()` asked `getField(name)` and treated null as
  absent. `GenericAvroRecord.getField()` delegates to Avro, which throws
  `AvroRuntimeException: Not a valid schema field` for a name the schema does not
  carry rather than returning null, so `cassandra-generic-record` failed on any
  table holding a column the record has no field for — the ordinary case, and the
  opposite of what the sink promises. It now asks the record's schema, via
  `getFields()`.
- `RecordWrapper.getValueAsExpectedType()` had no null case, so a field present but
  null threw: `NullPointerException` on the `TEXT` branch's `toString()`, and a
  `ConversionException` on the numeric ones. A present-but-null field now binds as
  null.

Deviations from the original, all deliberate:

- `close()` no longer swallows every `Throwable` in an empty catch block.
- `StringRecordWrapper` uses a `TypeReference` instead of a raw `Map.class`,
  removing an unchecked-conversion warning.
- `AbstractCassandraTest` applies `init.cql` through the Datastax driver rather
  than the container's `withInitScript`. The script delegate in the
  `org.testcontainers.cassandra` module is compiled against shaded classes the
  resolved Testcontainers core no longer ships, and fails with
  `NoClassDefFoundError`; the pre-existing test avoids this by using the older
  `org.testcontainers.containers.CassandraContainer`, and this now matches it.
- `CassandraSinkExec` and `producers/**` are not ported. `CassandraSinkExec` is an
  IDE-only `main()` that expects a broker on localhost and then sleeps for ten
  minutes; it would add `pulsar-functions-local-runner-original` to the test
  classpath for something CI never runs, and the producers exist only to feed it.

### Verifying this change

`:cassandra:check` passes: `CassandraConnectorTest` asserts the generated `INSERT`
matches a real table's column list for both a two-column and a seventeen-column
table, `TableMetadataProviderTest` asserts the resolved table definition, and the
pre-existing `CassandraStringSinkTest` still passes unchanged.

`CassandraTableSinkIntegrationTest` drives both new sinks end to end — real sink,
real cluster, real rows — against `airquality.reading` from `init.cql`: seventeen
columns, a compound primary key, and `text` / `int` / `double` / `float` among them,
of which the records populate seven. It asserts what landed, that a column no field
matched is left null, and that a field no column matched is dropped. The
generic-record case builds its input the way the hbase and jdbc sink tests do —
encode a POJO with `AvroSchema`, decode with `GenericAvroSchema` — so the sink sees
a real Avro-backed record rather than a hand-written double, which is what surfaced
the `containsKey()` defect above. Both fixes are mutation-checked: reverting either
one fails a test, with `Not a valid schema field` and `NullPointerException`
respectively.

`CassandraSinkConfigValidationTest` covers what each sink accepts and rejects at
`open()` before reaching a cluster: that the new sinks load without `keyname` and
`columnName`, that the `cassandra` sink still refuses to open without either, and
that all of them reject a half-configured credential pair. It points `roots` at a
port nothing listens on, so a config that gets as far as failing to connect is one
that passed validation — which is what makes the accepting cases mean anything.

**Not covered:** no end-to-end test drives `CassandraGenericRecordSink` or
`CassandraJsonStringSink` through a running sink — the coverage is of the
machinery underneath them. The original PR's answer to this was the IDE harness
described above. Happy to add a proper integration test here rather than defer it.

### Does this pull request potentially affect one of the following parts:

- [ ] Dependencies (add or upgrade a dependency)
- [ ] The public API
- [ ] The schema
- [ ] The default values of configurations
- [ ] The threading model
- [ ] The binary protocol
- [ ] The REST endpoints
- [ ] The admin CLI options
- [ ] The metrics
- [x] Anything that affects deployment

**Deployment**: two new connectors, `cassandra-generic-record` and
`cassandra-json`, ship in the existing Cassandra NAR. Nothing changes for a
deployment using the `cassandra` sink. The module gains `pulsar-client-api` and
`commons-beanutils` at compile scope and `pulsar-client-original` at test scope,
all three already in the version catalog; the test-scope one is not on the NAR's
classpath.

### Documentation

- [ ] `doc-required`
- [x] `doc-not-needed`
- [ ] `doc`
- [ ] `doc-complete`

Both new sinks are described by their `@Connector(help = ...)` text.
david-streamlio added a commit to apache/pulsar-connectors that referenced this pull request Aug 18, 2026
* [feat][io] Cassandra sink: support authenticated clusters

### Motivation

The Cassandra sink cannot connect to a cluster that requires authentication.
`CassandraSinkConfig` has no credential fields and `CassandraAbstractSink`
builds its `Cluster` with contact points only:

    Cluster.Builder b = Cluster.builder();
    ...
    cluster = b.withoutJMXReporting().build();

Any cluster running `PasswordAuthenticator` — which is the normal production
setting — is therefore unusable with this connector, with no workaround short
of forking it.

This gap was first reported in apache/pulsar#16179 (June 2022), which bundled
the fix with a larger schema-mapping feature. That PR could not be rebased
after PIP-465 moved the connectors out of the core repo, and the
authentication half stands on its own, so it is split out here.

### Modifications

- `CassandraSinkConfig` gains `userName` and `password`, both optional and
  `sensitive = true`. No existing field is changed.
- `CassandraAbstractSink.createClient` calls `withCredentials(...)` only when
  both are supplied, so an unset pair leaves the connection exactly as it was.
- `open()` loads config through `IOConfigUtils.loadWithSecrets` instead of
  `CassandraSinkConfig.load`, so the password can be supplied as a Pulsar
  secret rather than sitting in plaintext config. This is the same helper the
  kinesis, redis, canal and azure-data-explorer connectors already use. Fields
  with no secret configured still come from the config map; the pre-existing
  `CassandraStringSinkTest`, which opens the sink with a mocked `SinkContext`,
  passes unchanged and covers that.

The sink's write path, the `cassandra` connector registration and
`CassandraStringSink` are untouched.

### Verifying this change

`:cassandra:check` passes. `CassandraSinkAuthTest` adds four cases: credentials
survive config loading, they are null when unconfigured, and the sink writes
successfully both with and without them.

Stated plainly, those do not prove the server rejects an unauthenticated
connection: the test container runs the default `AllowAllAuthenticator`, and
switching it to `PasswordAuthenticator` needs a full version-specific
`cassandra.yaml` override. Enforcement is the server's behaviour; what is this
connector's to get right is that credentials reach the driver and that an unset
pair changes nothing. Happy to add a container with authentication enabled if
reviewers would rather have it.

### Does this pull request potentially affect one of the following parts:

- [ ] Dependencies (add or upgrade a dependency)
- [ ] The public API
- [ ] The schema
- [x] The default values of configurations
- [ ] The threading model
- [ ] The binary protocol
- [ ] The REST endpoints
- [ ] The admin CLI options
- [ ] The metrics
- [x] Anything that affects deployment

**The default values of configurations**: ticked for visibility. No existing
default changes — two new keys are added, both defaulting to unset, where unset
means exactly today's behaviour.

**Deployment**: the module gains `pulsar-io-common`, already in the version
catalog and used by several other connectors. Nothing changes for a deployment
that does not set the new keys.

### Documentation

- [ ] `doc-required`
- [x] `doc-not-needed`
- [ ] `doc`
- [ ] `doc-complete`

The new settings are described by their `@FieldDoc` help text.

* [improve][test] Cassandra sink: assert the server rejects unauthenticated connections

### Motivation

The tests added with the `userName` / `password` settings assert that credentials
survive config loading and that supplying them still yields a working write. They
do not assert that the credentials are needed for anything: the test container
runs the image default `AllowAllAuthenticator`, so a connection with no
credentials is accepted too.

That leaves the settings covered only by their own plumbing. Deleting the
`withCredentials()` call from `CassandraAbstractSink.createClient()` leaves
`CassandraSinkAuthTest` entirely green — all four tests pass with the feature
removed.

The stated reason for stopping there was that enabling `PasswordAuthenticator`
needs a full version-specific `cassandra.yaml`. That is true of the container's
`withConfigurationOverride()`, which volume-maps a whole directory over
`/etc/cassandra` and so requires carrying every file Cassandra reads from there.
Editing the shipped `cassandra.yaml` in place does not.

### Modifications

`CassandraSinkAuthEnforcementTest` runs a container whose `cassandra.yaml` has
been `sed`-ed to `PasswordAuthenticator` before the entrypoint starts, and drives
`CassandraStringSink` against it:

- `serverRejectsUnauthenticatedConnection` connects with the driver alone, no sink
  involved, and requires a refusal. Every other assertion in the class is only
  worth something while this one holds.
- `sinkFailsToOpenWithoutCredentials` and `sinkFailsToOpenWithWrongPassword`
  require `open()` to be refused, unwrapping an `AuthenticationException` reported
  either directly or inside a `NoHostAvailableException`.
- `sinkWritesWhenCredentialsAreCorrect` requires the configured pair to get past
  that refusal and the row to land.

A `grep -q` after the `sed` makes a substitution that matches nothing fatal: the
container exits rather than starting under `AllowAllAuthenticator` and leaving the
assertions vacuous. Cassandra 5 nests the setting under `authenticator.class_name`,
so a later image tag would otherwise gut this class silently.

`CassandraSinkAuthTest` keeps its unauthenticated container and its tests
unchanged — that an unset credential pair leaves an existing deployment alone
needs a server that does not ask for one. Its scope note now says so and points
here instead of stating the gap.

### Verifying this change

`:cassandra:check` passes, nine tests. With `withCredentials()` disabled in
`CassandraAbstractSink`, `sinkWritesWhenCredentialsAreCorrect` fails while
`CassandraSinkAuthTest` stays green, which is the gap this closes. Breaking the
`sed` pattern on purpose fails container startup rather than any assertion. The
new class ran clean five consecutive times; it costs one additional container.

* [fix][io] Cassandra sink: reject a half-configured credential pair

### Motivation

`createClient()` applies credentials only when both `userName` and `password` are
set, so a pair with one half missing does not fail — it connects unauthenticated.
Against a cluster requiring authentication that surfaces as the driver reporting
that no authenticator was configured, which names neither setting and reads like
the credentials were never supplied at all rather than like the typo it is.
Against a cluster not requiring authentication it succeeds silently, and the
operator learns nothing until the cluster is locked down.

Half a pair is never a meaningful configuration. Whichever half is present, the
intent was to authenticate.

### Modifications

`CassandraSinkConfig.validateCredentials()` rejects the pair with an
`IllegalArgumentException` naming both settings, and `hasCredentials()` answers
whether to authenticate at all. The rule lives on the config rather than in a sink
because it is a property of the settings, not of any one sink, and the sinks added
on top of this one need the same answer.

`CassandraAbstractSink.open()` calls it alongside the existing required-property
validation, and so before any connection is attempted.

The `@FieldDoc` help on both fields now says they are set together.

Tests: `openRejectsUsernameWithoutPassword` and `openRejectsPasswordWithoutUsername`
in `CassandraSinkAuthTest`, and `sinkFailsToOpenWithUnknownUser` in
`CassandraSinkAuthEnforcementTest`, which completes the credential permutations
against an enforcing server — absent, wrong password, unknown user, correct.

### Verifying this change

`:cassandra:check` passes, twelve tests. The two new cases point `roots` at a port
nothing listens on, so they assert the rejection happens before connecting rather
than merely that it happens: with the validation removed they fail with
`NoHostAvailableException: ... Cannot connect` instead of passing for the wrong
reason.
david-streamlio added a commit to david-streamlio/pulsar-connectors that referenced this pull request Aug 18, 2026
### Motivation

The Cassandra sink can only write one column. `CassandraStringSink` treats the
message value as an opaque string and writes it to the single column named by
`columnName`, keyed by `keyname`. A topic carrying an Avro or JSON schema has
named fields and a table with matching columns, and there is no way to land them
in more than one of those columns. The same applies to raw JSON on a topic with
no registered schema.

This ports the schema-mapping half of apache/pulsar#16179, which predates both
the Gradle migration and PIP-465's move of the connectors to this repository, so
it could not be rebased there. The authentication half is split out separately.

### Modifications

Two new sinks, both mapping record fields onto table columns by name, with the
target table's own definition deciding what gets written:

| Connector | Input | Behaviour |
|---|---|---|
| `cassandra` *(unchanged)* | `Record<byte[]>` | opaque string to the one configured column |
| `cassandra-generic-record` *(new)* | `GenericRecord` | record fields to columns, matched by name |
| `cassandra-json` *(new)* | `String` | top-level JSON fields to columns, matched by name |

**The existing sink is untouched.** `CassandraStringSink`, `CassandraAbstractSink`
and the `cassandra` entry in `pulsar-io.yaml` are unchanged by this commit, so no
existing deployment can be affected. That is a direct response to @lhotari's
review on #16179, which asked that existing behaviour not change and that the new
behaviour be opt-in. The original delivered this by rewriting `CassandraStringSink`
in place — dropping its `@Connector` registration, changing the input from
`Record<byte[]>` to `Record<String>`, removing the key-falls-back-to-value
behaviour, and reinterpreting the message as JSON. Here the JSON behaviour is its
own connector instead, so there is no configuration path by which the `cassandra`
sink can start behaving differently.

New machinery, used only by the new sinks:

- `CassandraTableSink<T>` reads the table definition from cluster metadata at
  `open()` and binds each column it can find a matching field for.
- `util/CassandraConnector` owns cluster/session lifecycle and the prepared
  `INSERT` built from the table's actual column list.
- `util/TableMetadataProvider` resolves a keyspace/table into its columns,
  partition key and primary key.
- `util/BoundStatementProvider` binds a wrapped record against that column list.
- `util/RecordWrapper` and its `GenericRecordWrapper` / `StringRecordWrapper`
  implementations read a named field out of each record type and coerce it to the
  column's declared type.

`keyname` and `columnName` become `required = false` on `CassandraSinkConfig`.
`IOConfigUtils.loadWithSecrets` enforces `required = true` for every sink sharing
the config, so leaving them required makes a table-sink config that omits them
fail to load — rejected for missing two settings that sink has no use for, before
it can say so. They are still required by the `cassandra` sink, which checks both
itself in `CassandraAbstractSink.open()` and is unchanged; their help text now says
which sink needs them.

`CassandraTableSink.open()` calls `cassandraSinkConfig.validateCredentials()` and
`CassandraConnector` asks `hasCredentials()`, so the new sinks reject a
half-configured credential pair on the same terms as the existing one rather than
quietly connecting unauthenticated.

Two defects the end-to-end test below found, both in code this commit adds:

- `GenericRecordWrapper.containsKey()` asked `getField(name)` and treated null as
  absent. `GenericAvroRecord.getField()` delegates to Avro, which throws
  `AvroRuntimeException: Not a valid schema field` for a name the schema does not
  carry rather than returning null, so `cassandra-generic-record` failed on any
  table holding a column the record has no field for — the ordinary case, and the
  opposite of what the sink promises. It now asks the record's schema, via
  `getFields()`.
- `RecordWrapper.getValueAsExpectedType()` had no null case, so a field present but
  null threw: `NullPointerException` on the `TEXT` branch's `toString()`, and a
  `ConversionException` on the numeric ones. A present-but-null field now binds as
  null.

Deviations from the original, all deliberate:

- `close()` no longer swallows every `Throwable` in an empty catch block.
- `StringRecordWrapper` uses a `TypeReference` instead of a raw `Map.class`,
  removing an unchecked-conversion warning.
- `AbstractCassandraTest` applies `init.cql` through the Datastax driver rather
  than the container's `withInitScript`. The script delegate in the
  `org.testcontainers.cassandra` module is compiled against shaded classes the
  resolved Testcontainers core no longer ships, and fails with
  `NoClassDefFoundError`; the pre-existing test avoids this by using the older
  `org.testcontainers.containers.CassandraContainer`, and this now matches it.
- `CassandraSinkExec` and `producers/**` are not ported. `CassandraSinkExec` is an
  IDE-only `main()` that expects a broker on localhost and then sleeps for ten
  minutes; it would add `pulsar-functions-local-runner-original` to the test
  classpath for something CI never runs, and the producers exist only to feed it.

### Verifying this change

`:cassandra:check` passes: `CassandraConnectorTest` asserts the generated `INSERT`
matches a real table's column list for both a two-column and a seventeen-column
table, `TableMetadataProviderTest` asserts the resolved table definition, and the
pre-existing `CassandraStringSinkTest` still passes unchanged.

`CassandraTableSinkIntegrationTest` drives both new sinks end to end — real sink,
real cluster, real rows — against `airquality.reading` from `init.cql`: seventeen
columns, a compound primary key, and `text` / `int` / `double` / `float` among them,
of which the records populate seven. It asserts what landed, that a column no field
matched is left null, and that a field no column matched is dropped. The
generic-record case builds its input the way the hbase and jdbc sink tests do —
encode a POJO with `AvroSchema`, decode with `GenericAvroSchema` — so the sink sees
a real Avro-backed record rather than a hand-written double, which is what surfaced
the `containsKey()` defect above. Both fixes are mutation-checked: reverting either
one fails a test, with `Not a valid schema field` and `NullPointerException`
respectively.

`CassandraSinkConfigValidationTest` covers what each sink accepts and rejects at
`open()` before reaching a cluster: that the new sinks load without `keyname` and
`columnName`, that the `cassandra` sink still refuses to open without either, and
that all of them reject a half-configured credential pair. It points `roots` at a
port nothing listens on, so a config that gets as far as failing to connect is one
that passed validation — which is what makes the accepting cases mean anything.

**Not covered:** no end-to-end test drives `CassandraGenericRecordSink` or
`CassandraJsonStringSink` through a running sink — the coverage is of the
machinery underneath them. The original PR's answer to this was the IDE harness
described above. Happy to add a proper integration test here rather than defer it.

### Does this pull request potentially affect one of the following parts:

- [ ] Dependencies (add or upgrade a dependency)
- [ ] The public API
- [ ] The schema
- [ ] The default values of configurations
- [ ] The threading model
- [ ] The binary protocol
- [ ] The REST endpoints
- [ ] The admin CLI options
- [ ] The metrics
- [x] Anything that affects deployment

**Deployment**: two new connectors, `cassandra-generic-record` and
`cassandra-json`, ship in the existing Cassandra NAR. Nothing changes for a
deployment using the `cassandra` sink. The module gains `pulsar-client-api` and
`commons-beanutils` at compile scope and `pulsar-client-original` at test scope,
all three already in the version catalog; the test-scope one is not on the NAR's
classpath.

### Documentation

- [ ] `doc-required`
- [x] `doc-not-needed`
- [ ] `doc`
- [ ] `doc-complete`

Both new sinks are described by their `@Connector(help = ...)` text.
david-streamlio added a commit to david-streamlio/pulsar-connectors that referenced this pull request Aug 18, 2026
### Motivation

The Cassandra sink can only write one column. `CassandraStringSink` treats the
message value as an opaque string and writes it to the single column named by
`columnName`, keyed by `keyname`. A topic carrying an Avro or JSON schema has
named fields and a table with matching columns, and there is no way to land them
in more than one of those columns. The same applies to raw JSON on a topic with
no registered schema.

This ports the schema-mapping half of apache/pulsar#16179, which predates both
the Gradle migration and PIP-465's move of the connectors to this repository, so
it could not be rebased there. The authentication half is split out separately.

### Modifications

Two new sinks, both mapping record fields onto table columns by name, with the
target table's own definition deciding what gets written:

| Connector | Input | Behaviour |
|---|---|---|
| `cassandra` *(unchanged)* | `Record<byte[]>` | opaque string to the one configured column |
| `cassandra-generic-record` *(new)* | `GenericRecord` | record fields to columns, matched by name |
| `cassandra-json` *(new)* | `String` | top-level JSON fields to columns, matched by name |

**The existing sink is untouched.** `CassandraStringSink`, `CassandraAbstractSink`
and the `cassandra` entry in `pulsar-io.yaml` are unchanged by this commit, so no
existing deployment can be affected. That is a direct response to @lhotari's
review on #16179, which asked that existing behaviour not change and that the new
behaviour be opt-in. The original delivered this by rewriting `CassandraStringSink`
in place — dropping its `@Connector` registration, changing the input from
`Record<byte[]>` to `Record<String>`, removing the key-falls-back-to-value
behaviour, and reinterpreting the message as JSON. Here the JSON behaviour is its
own connector instead, so there is no configuration path by which the `cassandra`
sink can start behaving differently.

New machinery, used only by the new sinks:

- `CassandraTableSink<T>` reads the table definition from cluster metadata at
  `open()` and binds each column it can find a matching field for.
- `util/CassandraConnector` owns cluster/session lifecycle and the prepared
  `INSERT` built from the table's actual column list.
- `util/TableMetadataProvider` resolves a keyspace/table into its columns,
  partition key and primary key.
- `util/BoundStatementProvider` binds a wrapped record against that column list.
- `util/RecordWrapper` and its `GenericRecordWrapper` / `StringRecordWrapper`
  implementations read a named field out of each record type and coerce it to the
  column's declared type.

`keyname` and `columnName` become `required = false` on `CassandraSinkConfig`.
`IOConfigUtils.loadWithSecrets` enforces `required = true` for every sink sharing
the config, so leaving them required makes a table-sink config that omits them
fail to load — rejected for missing two settings that sink has no use for, before
it can say so. They are still required by the `cassandra` sink, which checks both
itself in `CassandraAbstractSink.open()` and is unchanged; their help text now says
which sink needs them.

`CassandraTableSink.open()` calls `cassandraSinkConfig.validateCredentials()` and
`CassandraConnector` asks `hasCredentials()`, so the new sinks reject a
half-configured credential pair on the same terms as the existing one rather than
quietly connecting unauthenticated.

Two defects the end-to-end test below found, both in code this commit adds:

- `GenericRecordWrapper.containsKey()` asked `getField(name)` and treated null as
  absent. `GenericAvroRecord.getField()` delegates to Avro, which throws
  `AvroRuntimeException: Not a valid schema field` for a name the schema does not
  carry rather than returning null, so `cassandra-generic-record` failed on any
  table holding a column the record has no field for — the ordinary case, and the
  opposite of what the sink promises. It now asks the record's schema, via
  `getFields()`.
- `RecordWrapper.getValueAsExpectedType()` had no null case, so a field present but
  null threw: `NullPointerException` on the `TEXT` branch's `toString()`, and a
  `ConversionException` on the numeric ones. A present-but-null field now binds as
  null.

Deviations from the original, all deliberate:

- `close()` no longer swallows every `Throwable` in an empty catch block.
- `StringRecordWrapper` uses a `TypeReference` instead of a raw `Map.class`,
  removing an unchecked-conversion warning.
- `AbstractCassandraTest` applies `init.cql` through the Datastax driver rather
  than the container's `withInitScript`. The script delegate in the
  `org.testcontainers.cassandra` module is compiled against shaded classes the
  resolved Testcontainers core no longer ships, and fails with
  `NoClassDefFoundError`; the pre-existing test avoids this by using the older
  `org.testcontainers.containers.CassandraContainer`, and this now matches it.
- `CassandraSinkExec` and `producers/**` are not ported. `CassandraSinkExec` is an
  IDE-only `main()` that expects a broker on localhost and then sleeps for ten
  minutes; it would add `pulsar-functions-local-runner-original` to the test
  classpath for something CI never runs, and the producers exist only to feed it.

### Verifying this change

`:cassandra:check` passes: `CassandraConnectorTest` asserts the generated `INSERT`
matches a real table's column list for both a two-column and a seventeen-column
table, `TableMetadataProviderTest` asserts the resolved table definition, and the
pre-existing `CassandraStringSinkTest` still passes unchanged.

`CassandraTableSinkIntegrationTest` drives both new sinks end to end — real sink,
real cluster, real rows — against `airquality.reading` from `init.cql`: seventeen
columns, a compound primary key, and `text` / `int` / `double` / `float` among them,
of which the records populate seven. It asserts what landed, that a column no field
matched is left null, and that a field no column matched is dropped. The
generic-record case builds its input the way the hbase and jdbc sink tests do —
encode a POJO with `AvroSchema`, decode with `GenericAvroSchema` — so the sink sees
a real Avro-backed record rather than a hand-written double, which is what surfaced
the `containsKey()` defect above. Both fixes are mutation-checked: reverting either
one fails a test, with `Not a valid schema field` and `NullPointerException`
respectively.

`CassandraSinkConfigValidationTest` covers what each sink accepts and rejects at
`open()` before reaching a cluster: that the new sinks load without `keyname` and
`columnName`, that the `cassandra` sink still refuses to open without either, and
that all of them reject a half-configured credential pair. It points `roots` at a
port nothing listens on, so a config that gets as far as failing to connect is one
that passed validation — which is what makes the accepting cases mean anything.

**Not covered:** no end-to-end test drives `CassandraGenericRecordSink` or
`CassandraJsonStringSink` through a running sink — the coverage is of the
machinery underneath them. The original PR's answer to this was the IDE harness
described above. Happy to add a proper integration test here rather than defer it.

### Does this pull request potentially affect one of the following parts:

- [ ] Dependencies (add or upgrade a dependency)
- [ ] The public API
- [ ] The schema
- [ ] The default values of configurations
- [ ] The threading model
- [ ] The binary protocol
- [ ] The REST endpoints
- [ ] The admin CLI options
- [ ] The metrics
- [x] Anything that affects deployment

**Deployment**: two new connectors, `cassandra-generic-record` and
`cassandra-json`, ship in the existing Cassandra NAR. Nothing changes for a
deployment using the `cassandra` sink. The module gains `pulsar-client-api` and
`commons-beanutils` at compile scope and `pulsar-client-original` at test scope,
all three already in the version catalog; the test-scope one is not on the NAR's
classpath.

### Documentation

- [ ] `doc-required`
- [x] `doc-not-needed`
- [ ] `doc`
- [ ] `doc-complete`

Both new sinks are described by their `@Connector(help = ...)` text.
david-streamlio added a commit to david-streamlio/pulsar-connectors that referenced this pull request Aug 18, 2026
### Motivation

The Cassandra sink can only write one column. `CassandraStringSink` treats the
message value as an opaque string and writes it to the single column named by
`columnName`, keyed by `keyname`. A topic carrying an Avro or JSON schema has
named fields and a table with matching columns, and there is no way to land them
in more than one of those columns. The same applies to raw JSON on a topic with
no registered schema.

This ports the schema-mapping half of apache/pulsar#16179, which predates both
the Gradle migration and PIP-465's move of the connectors to this repository, so
it could not be rebased there. The authentication half is split out separately.

### Modifications

Two new sinks, both mapping record fields onto table columns by name, with the
target table's own definition deciding what gets written:

| Connector | Input | Behaviour |
|---|---|---|
| `cassandra` *(unchanged)* | `Record<byte[]>` | opaque string to the one configured column |
| `cassandra-generic-record` *(new)* | `GenericRecord` | record fields to columns, matched by name |
| `cassandra-json` *(new)* | `String` | top-level JSON fields to columns, matched by name |

**The existing sink is untouched.** `CassandraStringSink`, `CassandraAbstractSink`
and the `cassandra` entry in `pulsar-io.yaml` are unchanged by this commit, so no
existing deployment can be affected. That is a direct response to @lhotari's
review on #16179, which asked that existing behaviour not change and that the new
behaviour be opt-in. The original delivered this by rewriting `CassandraStringSink`
in place — dropping its `@Connector` registration, changing the input from
`Record<byte[]>` to `Record<String>`, removing the key-falls-back-to-value
behaviour, and reinterpreting the message as JSON. Here the JSON behaviour is its
own connector instead, so there is no configuration path by which the `cassandra`
sink can start behaving differently.

New machinery, used only by the new sinks:

- `CassandraTableSink<T>` reads the table definition from cluster metadata at
  `open()` and binds each column it can find a matching field for.
- `util/CassandraConnector` owns cluster/session lifecycle and the prepared
  `INSERT` built from the table's actual column list.
- `util/TableMetadataProvider` resolves a keyspace/table into its columns,
  partition key and primary key.
- `util/BoundStatementProvider` binds a wrapped record against that column list.
- `util/RecordWrapper` and its `GenericRecordWrapper` / `StringRecordWrapper`
  implementations read a named field out of each record type and coerce it to the
  column's declared type.

`keyname` and `columnName` become `required = false` on `CassandraSinkConfig`.
`IOConfigUtils.loadWithSecrets` enforces `required = true` for every sink sharing
the config, so leaving them required makes a table-sink config that omits them
fail to load — rejected for missing two settings that sink has no use for, before
it can say so. They are still required by the `cassandra` sink, which checks both
itself in `CassandraAbstractSink.open()` and is unchanged; their help text now says
which sink needs them.

`CassandraTableSink.open()` calls `cassandraSinkConfig.validateCredentials()` and
`CassandraConnector` asks `hasCredentials()`, so the new sinks reject a
half-configured credential pair on the same terms as the existing one rather than
quietly connecting unauthenticated.

Packaging: a NAR's `pulsar-io.yaml` declares one `sinkClass`, so one NAR offers one
connector by name. The two new sinks therefore get a NAR each, and the classes they
share with `cassandra` move to a plain-jar `:cassandra-core` that all three NARs
depend on. They cannot depend on `:cassandra` directly — `nar-conventions` disables
the jar task and replaces the project's outgoing artifacts with the NAR itself, so a
consumer bundles a `.nar` inside `META-INF/bundled-dependencies` where the
classloader cannot reach the classes. This is the shape `jdbc/core` and the
per-database JDBC NARs already use. The Java package is unchanged throughout, so no
import moves and `sinkConfigClass` keeps its value.

Defects found by the end-to-end test below, all in code this commit adds:

- `GenericRecordWrapper.containsKey()` asked `getField(name)` and treated null as
  absent. `GenericAvroRecord.getField()` delegates to Avro, which throws
  `AvroRuntimeException: Not a valid schema field` for a name the schema does not
  carry rather than returning null, so `cassandra-generic-record` failed on any
  table holding a column the record has no field for — the ordinary case, and the
  opposite of what the sink promises. It now asks the record's schema, via
  `getFields()`.
- `RecordWrapper.getValueAsExpectedType()` had no null case, so a field present but
  null threw: `NullPointerException` on the `TEXT` branch's `toString()`, and a
  `ConversionException` on the numeric ones. A present-but-null field now binds as
  null.
- `BoundStatementProvider` bound an `Object[]` through `bind(Object...)`, which
  overwrites the UNSET marker with a real null at every position the record had no
  field for. In Cassandra a null is a deletion, so re-writing a row erased the columns
  the new record said nothing about, and every insert left a tombstone per absent
  column. Those positions are now unset, which is what "map the fields onto the columns
  they match" should mean. A field carried as null still binds as an explicit null.
- `CassandraTableSink.write()` let an exception from `wrapRecord`/`bindStatement`
  escape, so a record whose content could not be bound — malformed JSON, a null value,
  a value that will not coerce — was neither acked nor failed. The sink died, Pulsar
  redelivered the same message, and one poison message became a restart loop. Such a
  record is now failed and logged.
- `CassandraConnector.close()` called `getSession()`/`getCluster()`, which create what
  they return: closing after a failed `open()` re-attempted the connection and threw a
  second exception, hiding the original. It now guards on the fields.
- A bad `keyspace`/`columnFamily` produced a bare `NullPointerException` from
  `Metadata.getKeyspace(...).getTable(...)`. Both paths now name the setting and value,
  and `CassandraConnector.getTableFields()` resolves through `TableMetadataProvider` so
  the INSERT's column list and the binder's positions come from one place.
- `open()` now refuses a table holding a column type the wrapper cannot bind — the
  supported set is text/varchar/ascii, int, double, float and boolean. Types such as
  `timestamp`, `uuid`, `inet`, `decimal` and `varint` need Java objects neither Jackson
  nor Avro produces, and `bigint`/`smallint`/`tinyint` fail on value magnitude, since
  Jackson decodes a small number as an `Integer`. One named error at startup beats an
  `InvalidTypeException` on every record, or on some of them.

Deviations from the original, all deliberate:

- `close()` no longer swallows every `Throwable` in an empty catch block.
- `StringRecordWrapper` uses a `TypeReference` instead of a raw `Map.class`,
  removing an unchecked-conversion warning.
- `AbstractCassandraTest` applies `init.cql` through the Datastax driver rather
  than the container's `withInitScript`. The script delegate in the
  `org.testcontainers.cassandra` module is compiled against shaded classes the
  resolved Testcontainers core no longer ships, and fails with
  `NoClassDefFoundError`; the pre-existing test avoids this by using the older
  `org.testcontainers.containers.CassandraContainer`, and this now matches it.
- `CassandraSinkExec` and `producers/**` are not ported. `CassandraSinkExec` is an
  IDE-only `main()` that expects a broker on localhost and then sleeps for ten
  minutes; it would add `pulsar-functions-local-runner-original` to the test
  classpath for something CI never runs, and the producers exist only to feed it.

### Verifying this change

`:cassandra:check` passes: `CassandraConnectorTest` asserts the generated `INSERT`
matches a real table's column list for both a two-column and a seventeen-column
table, `TableMetadataProviderTest` asserts the resolved table definition, and the
pre-existing `CassandraStringSinkTest` still passes unchanged.

`CassandraTableSinkIntegrationTest` drives both new sinks end to end — real sink,
real cluster, real rows — against `airquality.reading` from `init.cql`: seventeen
columns, a compound primary key, and `text` / `int` / `double` / `float` among them,
of which the records populate seven. It asserts what landed, that a column no field
matched is left null, and that a field no column matched is dropped. The
generic-record case builds its input the way the hbase and jdbc sink tests do —
encode a POJO with `AvroSchema`, decode with `GenericAvroSchema` — so the sink sees
a real Avro-backed record rather than a hand-written double, which is what surfaced
the `containsKey()` defect above. The fixes are mutation-checked: reverting any one of them fails a
test — `Not a valid schema field`, `NullPointerException`, `expected [CA] but found
[null]` for the erased-column case, and an unacked record for the poison-pill case.

`CassandraSinkConfigValidationTest` covers what each sink accepts and rejects at
`open()` before reaching a cluster: that the new sinks load without `keyname` and
`columnName`, that the `cassandra` sink still refuses to open without either, and
that all of them reject a half-configured credential pair. It points `roots` at a
port nothing listens on, so a config that gets as far as failing to connect is one
that passed validation — which is what makes the accepting cases mean anything.

**Not covered:** no end-to-end test drives `CassandraGenericRecordSink` or
`CassandraJsonStringSink` through a running sink — the coverage is of the
machinery underneath them. The original PR's answer to this was the IDE harness
described above. Happy to add a proper integration test here rather than defer it.

### Does this pull request potentially affect one of the following parts:

- [ ] Dependencies (add or upgrade a dependency)
- [ ] The public API
- [ ] The schema
- [ ] The default values of configurations
- [ ] The threading model
- [ ] The binary protocol
- [ ] The REST endpoints
- [ ] The admin CLI options
- [ ] The metrics
- [x] Anything that affects deployment

**Deployment**: two new connectors, `cassandra-generic-record` and
`cassandra-json`, ship in the existing Cassandra NAR. Nothing changes for a
deployment using the `cassandra` sink. The module gains `pulsar-client-api` and
`commons-beanutils` at compile scope and `pulsar-client-original` at test scope,
all three already in the version catalog; the test-scope one is not on the NAR's
classpath.

### Documentation

- [ ] `doc-required`
- [x] `doc-not-needed`
- [ ] `doc`
- [ ] `doc-complete`

Both new sinks are described by their `@Connector(help = ...)` text.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/function doc-required Your PR changes impact docs and you will update later. ready-to-test release/4.0.14

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants