DEV Community

Dusan Malusev
Dusan Malusev

Posted on • Originally published at dusanmalusev.dev

ScyllaDB PHP Driver 1.4.0: the extension is pure C23 now

The ScyllaDB PHP driver is not a C++ extension anymore. As of 1.4.0 it's pure C23, the ZendCPP template layer we leaned on for the object embed and allocate pattern is deleted, and the build no longer needs a C++ compiler at all. Every hand-written .cpp file is a .c file now (71 of them), the descriptor generator emits .c, and CMake builds with c_std_23 and nothing else. That's the biggest change to how this extension is built since we forked it for PHP 8.0.

This is also the release where a plan I opened back in 2023 finally landed. PR #50 laid it out: rewrite the src/Cluster directory to be more maintainable, use Zend Fast Argument Parsing, remove some memory allocations, and add .stub.php files that generate the C headers so nobody has to hand-maintain Zend arginfo by hand. 1.4.0 is that plan finished, and a lot more that grew out of it.

The things you'll actually feel: persistent session connect() doesn't allocate a 200-character key string on every call anymore, and the minimum PHP is now 8.3 (8.2 is gone). Nothing in your application code changes, this is almost all under the surface.

The .stub.php Build

The idea from PR #50 was small. Instead of writing ZEND_BEGIN_ARG_INFO_EX blocks by hand and keeping them in sync with the actual method bodies, write the signature once in a .stub.php file and generate the C arginfo from it. In v1.4.0 that's the whole build. There are 75 .stub.php files now, and each one is just the PHP signature of the class:

// src/Keyspace.stub.php
interface Keyspace {
    public function name(): string;
    public function replicationClassName(): string;
    /** @return array<string, mixed> */
    public function replicationOptions(): array;
    public function hasDurableWrites(): bool;
    /** @return Table|false */
    public function table(string $name): Table|false;
    public function aggregate(string $name, mixed ...$types): Aggregate|false;
}
Enter fullscreen mode Exit fullscreen mode

At build time CMake runs gen_stub.php (vendored from PHP 8.5's build/gen_stub.php, with two small patches) and writes the _arginfo.h next to the source. We stopped committing the generated headers, there are none in git anymore (75 of them got deleted from tracking), they're regenerated against whatever PHP version you're compiling against. That last part matters, PHP 8.3 and 8.5 do not emit identical arginfo, and now we don't pretend they do.

The second half is class registration. The old MINIT had one giant hand-maintained block, 98 php_driver_define_*() calls in src/php_driver.cpp, order-dependent and a merge-conflict magnet:

// src/php_driver.cpp (v1.3.17) - the old registration block
php_driver_define_Value();
php_driver_define_Numeric();
php_driver_define_Bigint();
php_driver_define_Cluster();
php_driver_define_DefaultCluster();
php_driver_define_ClusterBuilder();
// ... 92 more, in exactly this order or it breaks
Enter fullscreen mode Exit fullscreen mode

All of that is one line now:

// src/php_scylladb.c - MINIT
php_scylladb_class_registry_minit();
Enter fullscreen mode Exit fullscreen mode

Every class self-registers with a descriptor via a __attribute__((constructor)) that appends it to a linked list when the .so loads, before MINIT runs. At MINIT we walk the list, topologically sort by parent class, resolve dependencies, and register in the right order. If a parent is missing or you've made a cycle, it fails loudly at MINIT and names the class that broke it. (I wrote the whole descriptor mechanism up on its own if you want the deep dive, self-registering class descriptors, deleting MINIT boilerplate in a PHP extension.)

A second generator, gen_class_descriptor.php (about 515 lines), reads the same .stub.php and emits the descriptor .c with the object handlers wired up. So adding a class is now: write the stub, write the method bodies, list both files in the module CMake, and that's it. No touching php_scylladb.c, no touching a central registration list.

And this also let us delete 16 pure-interface .cpp files (Cluster, Session, Statement, Future, Rows, and so on). They had no method bodies, just registration, so the generator makes them from the stub alone.

Persistent Sessions Don't Allocate Anymore

Here's the change most people running under PHP-FPM will actually feel. Persistent sessions cache the underlying CassCluster in EG(persistent_list) so it survives across requests. To find the cached entry, the old code built a key string out of every cluster setting, on every build(), hit or miss:

// src/Cluster/Builder.cpp (v1.3.17) - key built on every call
cluster->hash_key_len = spprintf(
    &cluster->hash_key, 0,
    PHP_DRIVER_NAME ":%s:%d:%d:%s:%d:%d:%d:%s:%s:%d:%d:%d:%d:%d:%d:%d:%d:%d:%d:%d:%d:%d",
    ZSTR_VAL(self->contact_points), self->port, self->load_balancing_policy,
    SAFE_ZEND_STRING(self->local_dc), /* ...28 fields... */);

zval *le;
if (PHP5TO7_ZEND_HASH_FIND(&EG(persistent_list), cluster->hash_key,
                           cluster->hash_key_len + 1, le) &&
    Z_TYPE_P(le) == IS_RESOURCE) {
    cluster->cluster = (CassCluster *)Z_RES_P(le)->ptr;
    return; /* cache hit - but we already paid for the string */
}
Enter fullscreen mode Exit fullscreen mode

That's a spprintf into emalloc'd memory, then a zend_string_init (another allocation), then a hash over a 200-plus character string and a memcmp to confirm. Every request. Even when the answer was sitting in the cache the whole time.

v1.4.0 mixes the same fields into a uint64_t FNV-1a fingerprint inline, no allocation, and looks it up as an integer key:

// src/Cluster/Builder.c (v1.4.0) - no allocation on the hot path
zend_ulong h = php_scylladb_cache_key_init();
h = php_scylladb_cache_key_mix_cstr(h, PHP_SCYLLADB_NAME);
h = php_scylladb_cache_key_mix_zstr(h, self->contact_points);
h = php_scylladb_cache_key_mix_int(h, self->port);
/* ...28 fields... */
cluster->cache_key = h;

zval *le = zend_hash_index_find(&EG(persistent_list), h);
if (le != nullptr && Z_TYPE_P(le) == IS_RESOURCE &&
    Z_RES_P(le)->type == php_le_php_scylladb_cluster()) {
    cluster->cluster = (CassCluster *)Z_RES_P(le)->ptr;
    return; /* cache hit - and we allocated nothing */
}
Enter fullscreen mode Exit fullscreen mode

The lookup is zend_hash_index_find with an integer compare instead of zend_hash_find walking a long string. The mix_zstr and mix_cstr helpers append a separator byte between fields so ("ab","c") and ("a","bc") can't collide into the same key. It's 64-bit FNV-1a, collisions across the few thousand entries a worker holds are not something you'll hit.

One C23 detour worth mentioning here, wave 2 tried making the FNV offset and prime (and the default consistency) static constexpr instead of #define, it reads nicer and it's typed. It didn't stick. CI clang-tidy runs without -std=c23 and choked on constexpr, so those went back to #define. C23 is only as usable as your lint toolchain lets it be, so keep an eye on that if you're modernizing a C codebase, the compiler saying yes doesn't mean the rest of your tooling does.

Net result, zero zend_string allocations on the persistent-cache hot path. There's a memory-safety bonus too, the old char* key was aliased between the cluster and the session, so unset($cluster) before $session->prepare() could efree it out from under the session (that was the use-after-free in issue #128). Integer keys don't alias, so that whole class of bug is just gone.

You can see the difference on your own box. The measurement is simple, loop build()+connect() with persistent sessions on and off, and watch the peak memory:

function bench(bool $persistent, int $n): array {
    $mem = memory_get_peak_usage(true);
    $t0  = hrtime(true);

    for ($i = 0; $i < $n; $i++) {
        $session = Cassandra::cluster()
            ->withContactPoints('127.0.0.1')->withPort(9042)
            ->withPersistentSessions($persistent)
            ->build()
            ->connect();
        $session->execute(new \Cassandra\SimpleStatement('SELECT now() FROM system.local'));
        $session->close();
    }

    return [
        'per_op'     => ((hrtime(true) - $t0) / 1e9) / $n,
        'mem_growth' => memory_get_peak_usage(true) - $mem,
    ];
}

$off = bench(false, 200);   // persistent OFF: fresh CassCluster + key string every build
$on  = bench(true, 200);    // persistent ON:  fingerprint lookup, no allocation on the hit
Enter fullscreen mode Exit fullscreen mode

The wall-clock moves a little, but the mem_growth column is where the fingerprint change actually shows. The persistent path stops allocating a key string per call, so its peak barely moves while the non-persistent one climbs. The full script (prepared vs simple statements and batch inserts too) is in the repo under benchmarks/persistent_sessions_bench.php.

Also while we were in there, every zend_parse_parameters call in the scalar types (Uuid, Inet, Blob, the numerics) moved to the FastZPP Z_PARAM_* macros, and getThis() became ZEND_THIS. 153 parse sites converted, zero old-style zend_parse_parameters left, CI greps for it so it stays that way.

The Audits

Two audits ran against the whole extension and both left tracking docs in docs/. The memory-leak one worked through the extension and fixed:

  • borrowed-pointer leaks in the Builder setters
  • missing cass_future_free on error-return paths in execute() and paging
  • a missing UNREGISTER_INI_ENTRIES() at shutdown
  • get_gc handlers that didn't delegate to zend_std_get_gc, so cycles through object properties never got collected

But the correctness and ZTS audit is the scarier list. One of them, in ExecutionOptions, was a && that should have been ||:

// src/ExecutionOptions.cpp - CORR-001
-  if (Z_TYPE_P(retry_policy) != IS_OBJECT &&
+  if (Z_TYPE_P(retry_policy) != IS_OBJECT ||
         !instanceof_function(Z_OBJCE_P(retry_policy), php_scylladb_retry_policy_ce))
Enter fullscreen mode Exit fullscreen mode

With the wrong operator a non-object retry_policy skipped the type check and walked straight into instanceof_function on garbage.

Another one was strncmp(name, "keyspace_name", name_length) used for schema lookups, which is a prefix match, so "keyspace_name_2" matched "keyspace_name". Fixed to an exact-length memcmp.

Then a batch of thread-safety fixes for people running ZTS. The log callback used php_localtime_r and PHP_EOL which aren't safe to touch from libuv's worker threads, those are POSIX localtime_r and a literal \n now, and the log lifecycle moved from per-thread init to process init.

We also marked the 29 classes that hold a raw Cass* pointer as @not-serializable, because the default unserialize would hand you an object with an uninitialized native handle, which is another use-after-free waiting to happen.

How Much of This Was AI

I'll be straight about this, a large part of 1.4.0 was written with AI (Claude Code), and it landed with an accuracy I did not expect going in. Not the design calls, those are mine, but the wide mechanical work is exactly what these tools are good at. Renaming 667 identifiers and 58 macros from php_driver_* to php_scylladb_* across 146 files without missing one. Converting 153 zend_parse_parameters sites to FastZPP. Porting 71 files from C++ to C23. Doing that by hand is a week of tedium and a real chance of a typo that compiles fine and then breaks at runtime.

The audits are the part that surprised me most. That correctness list up above was run as a checklist against the whole extension, every finding numbered, every one with a tracking doc in docs/, a fix, and a test. The &&-that-should-be-|| in ExecutionOptions, a human doing a tired review at the end of the day walks straight past that. A pass that reads every single conditional and asks "is this the operator you meant" does not.

But none of it shipped on trust, and that's the part that matters. Every change is behind the test suite, and the suite runs under ASan, UBSan and TSan in CI across 8.3, 8.4 and 8.5, NTS and ZTS. AI hallucinates, I've watched it confidently do something that makes no sense, so the rule was simple, if it can't pass the sanitizers and the Pest tests it doesn't merge, doesn't matter who or what wrote it. The accuracy is real, but it's accuracy inside a setup that catches the misses, not accuracy you take on faith.

Issue #108, Two Years Late

Issue #108 sat open for nearly two years. The report was simple, passing an explicit null as the $options argument to prepare(), execute(), executeAsync() or prepareAsync() threw an InvalidArgumentException, even though the signature says array|ExecutionOptions|null. Under the hood Z_PARAM_ZVAL hands you a non-null IS_NULL zval, so the if (options) guard walked into the type check and rejected it. The fix is one macro, Z_PARAM_ZVAL_OR_NULL, so an explicit null collapses to nullptr and behaves like leaving the argument off. That's the whole thing.

Two years, for that. And while I was validating the fix under AddressSanitizer, a worse bug fell out of it, a cycle-collector use-after-free. The collection and value types keep their data in the C struct, but their get_gc handlers fell back to zend_std_get_gc, which rebuilds object->properties on every call. The collector runs get_gc several times per pass, so it decremented one set of child refcounts and re-incremented a freshly rebuilt set, corrupting the counts and freeing a shared CassDataType early (a crash on teardown). Set, Map, Collection, Tuple, UserTypeValue, the Cassandra\Type\* classes, Decimal, Varint and the cluster Builder all got a real zend_get_gc_buffer over their actual zvals. There's a test-asan job now that runs the whole suite under ASan, red before the fix and green after, because a plain run doesn't reliably crash (the fault is heap-layout dependent).

I'm not proud that #108 took me almost two years. A one-macro parameter fix should not sit open that long, and that is not the behavior of a maintainer who wants his project to survive and keep moving. I'm the only one on it, mostly in my free time, and for a long stretch that meant the backlog just sat there. People who hit #108 worked around it or walked.

So that changes now, and AI is how. The same tooling that made this release possible, the mechanical refactors, the audits that found the operator bug, the ASan job that caught this GC crash, is what lets one person actually keep up with issues and features instead of drowning in them. The trunk-and-backport setup below is the other half of it. I'm going to be addressing things more than I have been.

A Third Backend

v1.4.0 adds a third driver backend, scylla-rust, which is ScyllaDB's cpp-rs-driver (the Rust core with a C-compatible API). You pick the backend at configure time:

# cmake/FindCPPDriver.cmake
set(PHP_SCYLLADB_BACKEND "scylla-cpp" CACHE STRING
    "C/C++ driver backend: cassandra | scylla-cpp | scylla-rust")
Enter fullscreen mode Exit fullscreen mode

The default is still scylla-cpp (the ScyllaDB cpp-driver), so nothing changes unless you ask for it. scylla-rust is experimental and opt-in, it's in the CI matrix but allowed to fail, and it's not in the released binaries. A few things don't work on it yet, withMaxConnectionsPerHost() is a no-op and some schema-introspection calls come back empty (there's a compat shim that null-returns the six symbols the Rust driver doesn't implement, and the call sites null-check for it). If you want to try it, build it yourself with scripts/compile-cpp-rs-driver.sh. If you don't, you'll never notice it exists.

But don't ship it yet. scylla-rust is not production-ready, it's a preview, treat it as something you benchmark and file bugs against, not something you point a live app at. The default stays scylla-cpp for exactly this reason.

PHP Version Lifecycle

Minimum PHP is 8.3 now, 8.2 is dropped. composer.json went from ^8.2|^8.3|^8.4|^8.5 to ^8.3|^8.4|^8.5. If you're stuck on 8.2, pin to a 1.3.x release (the tags aren't going anywhere), that's your line.

The test matrix runs every supported version, both thread modes, against multiple backends:

php thread modes backends tested
8.3 NTS, ZTS scylladb, cassandra, scylla-rust*
8.4 NTS, ZTS scylladb, cassandra, scylla-rust*
8.5 NTS, ZTS scylladb, cassandra, scylla-rust*

* scylla-rust is allowed to fail and is not in the release artifacts.

PHP 8.5 is a first-class target, the lint and clang-tidy reference build is DebugPHP8.5NTS and the vendored gen_stub.php comes from 8.5. For PHP 8.4 forward we shim zend_register_internal_class_with_flags on older versions so the stub-generated registration compiles everywhere. The plan going forward is boring on purpose, track the current PHP release, drop the oldest once it goes security-only, and keep the arginfo generated per-version so a new PHP doesn't turn into a scramble.

Where the Branches Go Now

The repo reorganized around this release, and if you track a branch instead of a release tag it'll affect you. The old 1.3.x branch is gone. There are three long-lived branches now: trunk is the default and where all new work lands first, v1.x is the maintenance line for the released 1.x series, and v2.x is the next major, under active development and not shipped yet.

Every PR opens against trunk. Fixes reach the release lines by backport, not by targeting them directly. It's automated, you put a backport/1.x or backport/2.x label on the PR and the bot (korthout/backport-action, SHA-pinned) cherry-picks the merged commit onto v1.x or v2.x and opens a [Backport ...] PR. Clean cherry-pick and it's ready for review, a conflict and it still opens the PR with the markers and comments the manual git steps. This #108 fix is the first thing going down that path to v1.x.

If your CI or your composer.json points at the 1.3.x branch (the dev-1.3.x style constraint), move it to a tagged release (^1.4) or to v1.x. The tags don't move, v1.3.17 and everything before it is still right where it was, it's only the branch that's gone. So this is the one breaking change in 1.4.0 that isn't code, it's where you pull from.

Where to Start

On PHP 8.3 or newer:

pie install codelieutenant/scylla-driver
# then build the extension for your PHP (CMake preset, or pecl/pie)
Enter fullscreen mode Exit fullscreen mode

Turn on persistent sessions if you're running under FPM (->withPersistentSessions(true)), that's the path that got faster. Point the benchmark from earlier at your 127.0.0.1:9042 and watch the peak-memory column more than the clock, the fingerprint change is about not allocating, not about raw CPU, so that's where it shows...

Top comments (0)