Port Laravel Pennant

Merged
BI
1d ago
+13390-60

This adds hypervel/pennant, a port of Laravel Pennant (ported from d9ea593). The public API is the same as Laravel's: feature definitions, class-based features, scopes, rich values, eager loading, the HasFeatures trait, the Blade directive, the middleware, events and the Artisan commands all work the way Laravel developers expect.

The internals needed more than a namespace change. Pennant assumes PHP's request lifecycle: it caches resolved values on a long-lived object and relies on Octane listeners to clear them between requests. In a Hypervel worker many requests run at the same time, so that cache would leak one user's flags into another user's request. Fixing that properly touched most of the package, and while I was in there I fixed several upstream bugs and added two features that matter for applications running on more than one server or for more than one tenant.

Coroutine-safe state

Pennant's in-memory cache and the array driver's values now live in coroutine context instead of on the store objects. Each request, queued job and command gets its own values, and nothing needs to be cleared between them. That's why Laravel's Octane and JobProcessed flush listeners aren't ported. Child coroutines that copy their parent's context get their own copy of the values.

The stores themselves, feature definitions and resolver metadata stay worker-lifetime, since they're the same for every request. Feature classes are resolved once per worker, like policies and listeners, so the docs ask for stateless feature classes.

Transactions

Upstream keeps a value in its in-memory cache even when the transaction that read or wrote it rolls back, so the rest of the request sees a value that was never stored. Values read or written inside a database transaction are now dropped from the in-memory cache when that transaction (or savepoint) rolls back, and they aren't copied into child coroutines while the transaction is still open.

Faster feature checks

A cached Feature::active('x') call in upstream creates many short-lived collections, scans the cache several times and always dispatches FeatureRetrieved. Feature checks run on hot paths, so the decorator and scoped interactions now work on plain arrays, normalize each feature and scope once per call and read the in-memory cache directly. Feature names, class-based feature metadata and before() hook signatures are looked up once per worker. Every event dispatch is skipped when nothing is listening.

Database driver

Batched reads. Loading several features for several scopes no longer means one query per pair. Pairs are grouped by their scope list, so loading F features for S scopes is one query with F + S bindings, and large batches split under SQLite's expression depth and binding limits.

Safe concurrent first resolution. When two requests resolve the same feature at once, upstream inserts, catches the unique constraint violation and retries. On PostgreSQL that violation aborts the surrounding transaction, so the retry fails too. First resolution now uses ON CONFLICT DO NOTHING on PostgreSQL, INSERT OR IGNORE on SQLite and a no-op upsert on MySQL and MariaDB. When some rows weren't inserted, the driver reads back the stored values so every request agrees on the winner. MySQL and MariaDB use a locking read for this, because a REPEATABLE READ snapshot can't see the competing row otherwise, and the driver also rereads when FOUND_ROWS may make the affected row count unreliable. A value that disappears between the insert and the reread, such as through a concurrent purge, is resolved again a bounded number of times before the driver gives up with an exception. The driver never returns a value it doesn't know was stored.

Exact identifiers on MySQL and MariaDB. The default utf8mb4_unicode_ci collation treats Team-A, team-a, Team-A and café/cafe as equal, so upstream can return another scope's stored value. The migration uses VARBINARY for name and scope on those databases. utf8mb4_bin isn't enough, since it still ignores trailing spaces. PostgreSQL and SQLite already compare exactly.

value stays a text column. It isn't jsonb: PostgreSQL's jsonb and MySQL's JSON reorder object keys, which breaks === comparisons between the first resolution and later reads of a rich value, and jsonb rejects \u0000. Pennant never queries inside values, so the column type guideline in AGENTS.md now allows text() for opaque serialized values.

Other fixes. Rows are matched exactly instead of with Collection's loose comparison, which matched scope '01' to '1'. Duplicate pairs in one batch no longer cause duplicate inserts, and setAll() keeps the last value for a duplicate pair instead of failing on PostgreSQL. Large setAll() calls run in one transaction.

Read connections are rejected. First resolution writes, and every statement on a ::read connection goes to a replica, so a store configured with a read connection (or used inside DB::usingConnection('mysql::read', ...)) throws before touching the database.

Config is read once. Upstream rereads the database store's connection and table on every query so a store kept across Octane requests follows config changes. Config is shared by every request in a Hypervel worker, so it should only change during boot or in tests. Stores read their config when they're built, and the docs explain how to use DB::usingConnection() for per-request connections and partitioning for shared tables.

Partitioning

Applications with tenants, workspaces or teams often want each one's feature values kept apart in a single table. Putting the tenant into every scope gets part of the way there, but global flags, purges and cached values would still cross tenants. Partitioning makes it part of the store:

Feature::resolvePartitionUsing('team_id', fn (string $store) => Context::get('team_id'));

Once a partition is registered, every operation applies it: reads, first resolutions, updates, bulk updates, purges (including pennant:purge), the in-memory cache, the array driver and the shared cache. A null or empty partition value throws FeaturePartitionNotResolved instead of reading or writing outside a partition, and registering a partition after a store has been resolved throws, since that store would already be working across partitions. A store with 'partitioned' => false opts out, for flags shared by every partition. The partitioned schema is up to the application; the docs show the migration change.

Shared cache

Each new request starts with an empty in-memory cache, so its first check of every feature is a database query. A database store can now share stored values between servers through a cache store:

'database' => [
    'driver' => 'database',
    'table' => 'features',
    'cache' => ['store' => 'redis', 'ttl' => 3600],
],

A cache hit runs no query and doesn't take a connection from the pool. The database stays the source of truth: only stored values are cached, never before() results or the false of unknown features, and an evicted value is read from the database again rather than resolved again, so the cache can never change a stored assignment.

Invalidation uses random tokens per partition and per scope. A cached value is only valid while the tokens it was saved under are current, and every update replaces the affected tokens after its transaction commits. A request that read the database before an update and saves the old value afterwards saves it under replaced tokens, so nobody reads it. That avoids locks and compare-and-swap. Updates through any database store on the same table and connection invalidate every cached store that reads it, even when the store making the change has no cache of its own. The shared cache isn't used inside a transaction, so values that might roll back are never published.

If invalidation fails after a commit, Pennant throws, and other servers may read the old value until the TTL expires. The TTL must be a positive integer for that reason, and the docs recommend a Redis store when changes must reach every server promptly.

Other upstream fixes

Each of these has a regression test.

  • The array driver treated a stored null as missing and resolved it again.
  • Scope type checks rejected valid scopes for object, iterable, callable, self/parent, standalone true/false, and int scopes for float parameters.
  • loadAll() used the first scope's features for every scope, so mixed User/Team scopes passed a Team to user-only resolvers.
  • Constant values that look like callables or class names, such as 'next' or SomeClass::class, were treated as resolvers in all(), loadAll(), before() hook detection and instance().
  • Eloquent models without a key were all stored under the same scope identifier, so every unsaved model shared one value. They now throw; save the model or pass an explicit scope.

Differences for subclasses

The built-in drivers, the store decorator and FeatureManager::createArrayDriver() take different constructor arguments, and a few protected methods changed, because values live in coroutine state and stores can be partitioned. Custom drivers that implement the Driver contract are unaffected. The package README lists these differences, and the porting guide covers keyless model scopes, the migration and runtime config changes.

Changes outside Pennant

  • Cache stacks batch many() and putMany() per layer. Both used to loop over keys, so a cold Pennant batch on a Swoole table + Redis stack cost one Redis round trip per key. Each layer is now called once, with lower-layer hits written back to the layers above, keeping their expiration. When a layer's putMany() fails, the batch keys are removed from that layer and the ones already written, so the stack doesn't serve values that only reached some layers.
  • Sentry records feature flag evaluations. PennantPackageIntegration is ported from sentry-laravel and enabled in the shipped config, so Sentry events include the flags checked during the request.
  • Test cleanup. AfterEachTestSubscriber flushes Pennant's static state when the package is installed.

Not ported

  • Laravel's Octane and JobProcessed flush listeners, since coroutine state replaces them.
  • Boost resources, which are excluded for every package.
  • Repository tooling such as Pennant's own PHPUnit, PHPStan and Testbench config.

Documentation

pennant.md is adapted from Laravel's docs, with new sections for partitioning and the shared cache, and notes on the per-coroutine in-memory cache, transactions, stateless feature classes, keyless model scopes and store configuration. Pennant is added to the docs index and the porting guide, and the Sentry docs mention feature flags.

Tests

The upstream suite is ported in full. Upstream tests that used unsaved models as incidental scopes now give them ids, with assertions unchanged, and a separate test covers the keyless model exception. The tests that simulated unique constraint violations now cover what can actually happen with the new insert: a concurrent winner's value is returned, and a winner that vanishes before the reread ends in an exception after the bounded retries.

New tests cover coroutine isolation, transaction rollback, partitioning and the shared cache. Integration tests run on MySQL, MariaDB and PostgreSQL for exact identifiers and each engine's conflict paths, and on Redis for the shared cache. The Redis directory is added to all three jobs in the Redis workflow.

Validation

  • Formatting and static analysis passed.
  • The full parallel suite passed, except for the Typesense tests, which failed because the local Typesense service was down. They pass against Typesense 27.1.
  • Testbench and dogfood checks passed.
  • The Pennant integration tests passed on PostgreSQL, MySQL, MariaDB and Redis.

Summary by cubic

Ports Laravel Pennant as hypervel/pennant with the same public API, adapted to Hypervel's shared worker processes. Feature values are now kept per coroutine instead of being flushed between requests, and values read inside a transaction are forgotten on rollback.

  • The database driver batches reads, resolves first reads safely under concurrency, supports tenant partitioning and an optional shared cache store, and uses fixed-length keys for cached values.
  • Eloquent models without a key now throw as scopes; stores read their configuration once when built, and the migration honors the connection chosen by migrate --database.
  • Subclassers of the built-in drivers and the decorator must update constructor arguments; custom Driver implementations are unaffected.
  • The cache StackStore batches many() and putMany() per layer with per-layer expiration; Sentry events include checked feature flags but skip boot-time checks.
  • Feature names are returned as strings so numeric names like "1" stay distinct from "01", and pennant:purge --except matches exclusions by registered name.
  • Empty purges and bulk writes no-op, keyed scopes are read by position, and non-coroutine tasks run in an empty context.

[!NOTE]

Port Laravel Pennant to Hypervel as the hypervel/pennant package

  • Adds the full Pennant feature-flag package: FeatureManager, ArrayDriver, DatabaseDriver, Decorator, PendingScopedFeatureInteraction, Blade conditionals, EnsureFeaturesAreActive middleware, pennant:feature and pennant:purge commands, a feature() helper, and the features table migration.
  • Feature state is stored per coroutine via PennantState (PennantState.php); memoized values are cleared on transaction rollback and are not copied into child coroutines when created inside an open transaction.
  • Database stores support partitions and a shared stored-value cache with token-validated invalidation (StoredValueCache.php).
  • Adds a Sentry integration that records Pennant feature checks as Sentry flags (PennantPackageIntegration.php), plus full test suites for drivers, transactions, coroutine isolation, partitions, caching, and MySQL/MariaDB/Postgres.
  • Also rewrites StackStore::many and StackStore::putMany in StackStore.php to use batched reads/writes per cache layer, and fixes non-coroutine task callbacks in TaskCallback.php to run with a cleared context and restore the worker context afterward.
  • Behavioral Change: Pennant store configuration is resolved per manager and model scopes require explicit serialization; see the porting guide at porting-from-laravel.md. Bulk cache-layer writes and reads now use many/putMany instead of per-key calls.

Macroscope summarized 4982ecc.