[feat][io] Cassandra sink: add generic-record and JSON-string sinks - #137
Open
david-streamlio wants to merge 1 commit into
Open
[feat][io] Cassandra sink: add generic-record and JSON-string sinks#137david-streamlio wants to merge 1 commit into
david-streamlio wants to merge 1 commit into
Conversation
david-streamlio
force-pushed
the
feat/cassandra-generic-record
branch
4 times, most recently
from
August 18, 2026 15:37
63f72dc to
e419819
Compare
david-streamlio
force-pushed
the
feat/cassandra-generic-record
branch
from
August 18, 2026 15:40
e419819 to
489365d
Compare
### 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.
david-streamlio
force-pushed
the
feat/cassandra-generic-record
branch
from
August 18, 2026 19:24
489365d to
4f7da37
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
The Cassandra sink can only write one column.
CassandraStringSinktreats themessage value as an opaque string and writes it to the single column named by
columnName, keyed bykeyname. A topic carrying an Avro or JSON schema hasnamed 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 went in separately as #136,
and this is now rebased onto it — the diff is schema-mapping-only, as promised
there.
Modifications
Two new sinks, both mapping record fields onto table columns by name, with the
target table's own definition deciding what gets written:
cassandra(unchanged)Record<byte[]>cassandra-generic-record(new)GenericRecordcassandra-json(new)StringThe existing sink is untouched.
CassandraStringSink,CassandraAbstractSinkand the
cassandraentry inpulsar-io.yamlare unchanged by this commit, so noexisting 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
CassandraStringSinkin place — dropping its
@Connectorregistration, changing the input fromRecord<byte[]>toRecord<String>, removing the key-falls-back-to-valuebehaviour, and reinterpreting the message as JSON. Here the JSON behaviour is its
own connector instead, so there is no configuration path by which the
cassandrasink can start behaving differently.
New machinery, used only by the new sinks:
CassandraTableSink<T>reads the table definition from cluster metadata atopen()and binds each column it can find a matching field for.util/CassandraConnectorowns cluster/session lifecycle and the preparedINSERTbuilt from the table's actual column list.util/TableMetadataProviderresolves a keyspace/table into its columns,partition key and primary key.
util/BoundStatementProviderbinds a wrapped record against that column list.util/RecordWrapperand itsGenericRecordWrapper/StringRecordWrapperimplementations read a named field out of each record type and coerce it to the
column's declared type.
keynameandcolumnNamebecomerequired = falseonCassandraSinkConfig.IOConfigUtils.loadWithSecretsenforcesrequired = truefor every sink sharingthe 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
cassandrasink, which checks both itself inCassandraAbstractSink.open()and is unchanged; their help text now says which sinkneeds them.
CassandraTableSink.open()callscassandraSinkConfig.validateCredentials()andCassandraConnectoraskshasCredentials(), so the new sinks reject ahalf-configured credential pair on the same terms as the existing one rather than
quietly connecting unauthenticated.
Packaging. A NAR's
pulsar-io.yamldeclares onesinkClass, so one NAR offers oneconnector by name. Each new sink therefore gets its own NAR module, and the classes they
share with
cassandramove to a plain-jar:cassandra-corethat all three NARs dependon. They cannot depend on
:cassandradirectly:nar-conventionsdisables the jar taskand replaces the project's outgoing artifacts with the NAR itself, so a consumer ends up
bundling a
.narinsideMETA-INF/bundled-dependencies, where the classloader cannotreach the classes. This is the shape
jdbc/coreand the per-database JDBC NARs alreadyuse. The Java package is unchanged throughout, so no imports move and
sinkConfigClasskeeps its value.
cassandra-corecassandracassandra(unchanged)cassandra-generic-recordcassandra-generic-recordcassandra-jsoncassandra-jsonWithout this, both new sinks were unreachable by
--sink-type— the classes shipped inthe Cassandra NAR but nothing declared them, so only
--archive+--classnamewouldhave worked. An earlier revision of this description claimed they simply "ship in the
existing Cassandra NAR", which was true of the class files and misleading about whether
anyone could select them.
Defects found by the end-to-end test, all in code this PR adds:
GenericRecordWrapper.containsKey()askedgetField(name)and treated null asabsent.
GenericAvroRecord.getField()delegates to Avro, which throwsAvroRuntimeException: Not a valid schema fieldfor a name the schema does notcarry rather than returning null, so
cassandra-generic-recordfailed on any tableholding 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 butnull threw:
NullPointerExceptionon theTEXTbranch'stoString(), and aConversionExceptionon the numeric ones. A present-but-null field now binds as null.BoundStatementProviderwas erasing data. It bound anObject[]throughbind(Object...), which overwrites the UNSET marker with a real null at every positionthe record had no field for — and in Cassandra a null is a deletion. Re-writing a row
wiped the columns the new record said nothing about, and every insert left a tombstone
per absent column for compaction. Those positions are now unset. A field carried as
null still binds as an explicit null, which is a different statement.
CassandraTableSink.write()let an exception from
wrapRecord/bindStatementescape, so a record that could not bebound — malformed JSON, a null value, a value that will not coerce — was neither acked
nor failed. The sink died, Pulsar redelivered the same message, repeat. Such a record is
now failed and logged.
CassandraConnector.close()calledgetSession()/getCluster(), which create whatthey return: closing after a failed
open()re-attempted the connection and threw asecond exception, hiding the original cause. It now guards on the fields.
keyspace/columnFamilyproduced a bareNullPointerException. Both paths nowname the setting and its value, and
getTableFields()resolves throughTableMetadataProviderso the INSERT's column list and the binder's positions come fromone place rather than two independent metadata reads.
open()now refuses a table holding a column type the wrapper cannot bind. Supported:text/varchar/ascii, int, double, float, boolean.
timestamp,uuid,inet,decimaland
varintneed Java objects neither Jackson nor Avro produces, andbigint/smallint/tinyintfail on magnitude, since Jackson decodes a small number asan
Integer. One named error at startup beats anInvalidTypeExceptionon every record— or, worse, on only some of them.
Deviations from the original, all deliberate:
close()no longer swallows everyThrowablein an empty catch block.StringRecordWrapperuses aTypeReferenceinstead of a rawMap.class,removing an unchecked-conversion warning.
AbstractCassandraTestappliesinit.cqlthrough the Datastax driver ratherthan the container's
withInitScript. The script delegate in theorg.testcontainers.cassandramodule is compiled against shaded classes theresolved Testcontainers core no longer ships, and fails with
NoClassDefFoundError; the pre-existing test avoids this by using the olderorg.testcontainers.containers.CassandraContainer, and this now matches it.CassandraSinkExecandproducers/**are not ported.CassandraSinkExecis anIDE-only
main()that expects a broker on localhost and then sleeps for tenminutes; it would add
pulsar-functions-local-runner-originalto the testclasspath for something CI never runs, and the producers exist only to feed it.
Verifying this change
:cassandra:checkpasses:CassandraConnectorTestasserts the generatedINSERTmatches a real table's column list for both a two-column and a seventeen-column
table,
TableMetadataProviderTestasserts the resolved table definition, and thepre-existing
CassandraStringSinkTeststill passes unchanged.CassandraSinkConfigValidationTestcovers what each sink accepts and rejects atopen()before reaching a cluster: that the new sinks load withoutkeynameandcolumnName, that thecassandrasink still refuses to open without either, andthat all of them reject a half-configured credential pair. It points
rootsat aport 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.
CassandraTableSinkIntegrationTestdrives both new sinks end to end — real sink,real cluster, real rows — against
airquality.readingfrominit.cql: seventeencolumns, a compound primary key, and
text/int/double/floatamong 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 withGenericAvroSchema— so the sink sees areal Avro-backed record rather than a hand-written double, which is what surfaced the
containsKey()defect above.Every fix is mutation-checked: reverting any one of them fails a test —
Not a valid schema field,NullPointerException,expected [CA] but found [null]for the erasedcolumn, an unacked record for the poison-pill case, and with
required = falserevertedneither sink opens at all. That all of these were sitting behind the coverage gap this PR
previously deferred is the argument for having closed it rather than shipping on the
machinery tests.
Does this pull request potentially affect one of the following parts:
Deployment: two new connectors,
cassandra-generic-recordandcassandra-json, ship in the existing Cassandra NAR. Nothing changes for adeployment using the
cassandrasink. The module gainspulsar-client-apiandcommons-beanutilsat compile scope andpulsar-client-originalat test scope, allthree already in the version catalog; the test-scope one is not on the NAR's
classpath.
Documentation
doc-requireddoc-not-neededdocdoc-completeBoth new sinks are described by their
@Connector(help = ...)text.