small / swoole-entity-manager-strates
Snapshot-style stratified persistence for Small Swoole Entity Manager with release pointers and garbage collection.
Package info
git.small-project.dev/lib/small-swoole-entity-manager-strates
pkg:composer/small/swoole-entity-manager-strates
Requires
- php: >=8.3
- small/swoole-entity-manager-core: 3.0.*
Requires (Dev)
- pestphp/pest: ^3.0
- phpstan/phpstan: ^2.0
This package is not auto-updated.
Last update: 2026-08-19 17:55:15 UTC
README


Snapshot-based persistence for small/swoole-entity-manager-core.
The package lets an application build a complete version of an entity graph, keep that version invisible while it is being written, then publish it by switching one release pointer. Readers always query the currently released strate for a scope and scope identifier.
Features
- Snapshot-style persistence grouped by
scopeandscopeId. - UUID v7 identifiers for build strates.
- Explicit build lifecycle:
building,released,failed, andgarbage_collected. - Constant-size release operation through one central pointer in
released_strates. - Idempotent release of an already released strate.
- Released-data finder and query-builder API.
- Multiple stratified managers in the same scope.
- Explicit root manager selection for released queries.
- One-to-one, one-to-many, and many-to-many relation graphs isolated by
buildStrate. - Preservation of non-null application-generated primary keys on inserts.
- MySQL 8 and PostgreSQL 16 metadata schemas.
- Microsecond precision for build and release metadata timestamps.
- Default garbage collection by
buildStratedeletion. - Optional partition-based garbage collection for MySQL and PostgreSQL.
- Custom garbage-collector strategies through a public interface.
- Cleanup of obsolete build metadata during garbage-collection runs.
- Configurable cleanup delay for failed builds.
- Validation of registered managers, required fields, entity contracts, scopes, and roots.
- Docker development environment with PHP 8.4, Swoole, MySQL, and PostgreSQL.
- Unit and database integration test suites with Pest.
- PHPStan level 9 analysis and PHP syntax validation.
- Git-tag-based release helper with semantic version sorting.
Requirements
- PHP 8.3 or later.
small/swoole-entity-manager-core2.7.x.- A MySQL or PostgreSQL connection supported by Small Swoole Entity Manager.
- Swoole or OpenSwoole as required by the underlying entity manager runtime.
Installation
composer require small/swoole-entity-manager-strates
Install the metadata schema matching the application database:
mysql < vendor/small/swoole-entity-manager-strates/schema/mysql.sql
psql < vendor/small/swoole-entity-manager-strates/schema/postgres.sql
The package creates two metadata tables:
| Table | Purpose |
|---|---|
stratified_build | Stores every build strate, its scope, scope identifier, status, and timestamps. |
released_strates | Stores the currently released strate for each (scope, scope_id) pair. |
Business tables remain owned by the application. Each stratified business table must contain its own buildStrate field.
Core concepts
Scope
A scope identifies one versioned business domain, for example booking, catalog, or configuration.
Scope identifier
A scope identifier selects one independent instance inside a scope, for example booking 42 or shop 8.
Build strate
A build strate is a UUID v7 identifying one complete candidate snapshot. New rows are written with that UUID in their buildStrate field.
Released strate
A released strate is the active build for a (scope, scopeId) pair. The active UUID is stored centrally in released_strates.validated_strate.
Scope root
A scope may contain several managers. One manager is the released-query root. Set scopeRoot: true explicitly when a scope contains more than one manager. For backward compatibility, the first registered manager is used when no explicit root is declared.
Entity definition
A stratified entity must:
- extend
AbstractEntity; - implement
StratifiedEntityInterface; - expose an
idprimary key; - expose a
buildStratefield; - expose the configured scope identifier field;
- use
StratifiedEntityTrait, or provide equivalent accessors manually.
<?php
declare(strict_types=1);
use Small\SwooleEntityManager\Entity\AbstractEntity;
use Small\SwooleEntityManager\Entity\Attribute\Field;
use Small\SwooleEntityManager\Entity\Attribute\OrmEntity;
use Small\SwooleEntityManager\Entity\Attribute\PrimaryKey;
use Small\SwooleEntityManager\Entity\Enum\FieldValueType;
use Small\SwooleEntityManager\Strates\Contract\StratifiedEntityInterface;
use Small\SwooleEntityManager\Strates\Entity\StratifiedEntityTrait;
#[OrmEntity]
final class BookingItemEntity extends AbstractEntity implements StratifiedEntityInterface
{
use StratifiedEntityTrait;
#[PrimaryKey]
private ?int $id = null;
#[Field(type: FieldValueType::int)]
private ?int $bookingId = null;
#[Field(type: FieldValueType::string)]
private ?string $sku = null;
}
StratifiedEntityTrait provides:
public function getBuildStrate(): ?string;
public function setBuildStrate(string $buildStrate): static;
It also includes ManualPrimaryKeyPersistenceTrait.
Application-generated primary keys
Small Swoole Entity Manager may remove primary keys from PostgreSQL insert payloads. Stratified entities commonly use application-generated UUIDs, so ManualPrimaryKeyPersistenceTrait restores non-null primary keys when an entity is inserted.
The trait does not change these cases:
- a null database-generated key remains omitted from an insert;
- primary keys remain omitted from update payloads;
- entities loaded from the database keep the normal core behavior.
When an entity declares buildStrate itself instead of using StratifiedEntityTrait, add the primary-key trait explicitly:
use Small\SwooleEntityManager\Strates\Entity\ManualPrimaryKeyPersistenceTrait;
final class CatalogEntity extends AbstractEntity implements StratifiedEntityInterface
{
use ManualPrimaryKeyPersistenceTrait;
#[PrimaryKey]
private ?string $id = null;
#[Field(type: FieldValueType::string)]
private ?string $buildStrate = null;
public function getBuildStrate(): ?string
{
return $this->buildStrate;
}
public function setBuildStrate(string $buildStrate): static
{
$this->buildStrate = $buildStrate;
return $this;
}
}
Manager definition
The entity manager remains a standard relational manager:
<?php
declare(strict_types=1);
use Small\SwooleEntityManager\EntityManager\AbstractRelationnalManager;
use Small\SwooleEntityManager\EntityManager\Attribute\Connection;
use Small\SwooleEntityManager\EntityManager\Attribute\Entity;
#[Connection(dbTableName: 'booking_item')]
#[Entity(BookingItemEntity::class)]
final class BookingItemManager extends AbstractRelationnalManager
{
}
Service configuration
Register every stratified manager with a StratifiedEntityConfig:
<?php
declare(strict_types=1);
use Small\SwooleEntityManager\Strates\Config\StratifiedEntityConfig;
use Small\SwooleEntityManager\Strates\Service\StratifiedPersist;
$stratifiedPersist = new StratifiedPersist(
$entityManagerFactory,
[
new StratifiedEntityConfig(
managerClass: BookingItemManager::class,
scope: 'booking',
scopeIdField: 'bookingId',
scopeRoot: true,
),
],
);
StratifiedEntityConfig validates that:
- the scope is not empty;
- the scope identifier field name is not empty;
- the manager extends
AbstractRelationnalManager; - the manager contains
id,buildStrate, and the configured scope identifier field; - the managed entity implements
StratifiedEntityInterface; - a manager is not registered twice;
- a scope has at most one explicit root manager.
The internal StratifiedBuildManager and ReleasedStrateManager use the default ORM connection. Metadata managers and business managers must therefore resolve to the intended database connection.
Creating, persisting, and releasing a snapshot
Create a build
$strate = $stratifiedPersist->createNewVersion(
[BookingItemManager::class],
'booking',
42,
);
createNewVersion():
- validates the scope and manager contracts;
- generates a UUID v7;
- creates a
stratified_buildrow; - assigns status
building; - stores creation and update timestamps with microsecond precision;
- returns the new strate UUID.
createNewStrate() is an equivalent domain-language alias:
$strate = $stratifiedPersist->createNewStrate(
[BookingItemManager::class],
'booking',
42,
);
Persist one entity
$item = $entityManagerFactory
->get(BookingItemManager::class)
->newEntity();
$item->setBookingId(42);
$item->setSku('BIKE-001');
$stratifiedPersist->persist($item, $strate);
persist() assigns the supplied UUID to buildStrate before persisting the entity.
Persist several entities
$stratifiedPersist->persistMany($items, $strate);
persistMany() accepts any iterable of entities implementing StratifiedEntityInterface.
Release the build
$stratifiedPersist->release('booking', 42, $strate);
The release operation:
- checks that the build exists for the supplied scope and scope identifier;
- accepts builds in
buildingorreleasedstate; - rejects failed, garbage-collected, unknown, or mismatched builds;
- creates or updates the unique release pointer for
(scope, scopeId); - changes the build status to
released; - persists the pointer and build status through one persistence thread.
Releasing the same strate again is supported and leaves the same active pointer in place.
For PDO-style connections, release persistence uses the entity-manager commit path. With the PostgreSQL connection from core 2.7.x, it uses a direct flush to avoid checking out an unused native connection during the core commit fallback.
Failure handling
Mark an incomplete build as failed when snapshot construction throws:
try {
$strate = $stratifiedPersist->createNewVersion(
[BookingItemManager::class],
'booking',
42,
);
// Persist the complete snapshot.
$stratifiedPersist->release('booking', 42, $strate);
} catch (Throwable $exception) {
if (isset($strate)) {
$stratifiedPersist->markBuildAsFailed($strate);
}
throw $exception;
}
markBuildAsFailed() finds the build by strate UUID, changes its status to failed, updates its timestamp, and persists it.
Failed-build metadata is removed by garbage collection after 24 hours by default.
Reading released data
Finder
$releasedItems = $stratifiedPersist->findReleasedByScopeId(
'booking',
42,
);
The finder returns an EntityCollection from the configured root manager. It filters by both the scope identifier and the currently released buildStrate.
When no release pointer exists, the generated query uses an impossible internal strate value and returns no released business rows.
Query builder
Use the released query builder to add business filtering or ordering:
$query = $stratifiedPersist->createReleasedQueryBuilder(
'booking',
'item',
42,
);
$query->addOrderBy('sku');
$releasedItems = $entityManagerFactory
->get(BookingItemManager::class)
->getResult($query);
The adapter first resolves the central release pointer, then creates a business query constrained by:
- the configured scope identifier field;
- the released
buildStrate.
A scope identifier value is mandatory for this adapter.
Stratified relation graphs
A scope can contain several versioned managers:
$configs = [
new StratifiedEntityConfig(
CatalogManager::class,
'catalog',
'shopId',
scopeRoot: true,
),
new StratifiedEntityConfig(
CatalogSettingsManager::class,
'catalog',
'shopId',
),
new StratifiedEntityConfig(
ProductManager::class,
'catalog',
'shopId',
),
new StratifiedEntityConfig(
TagManager::class,
'catalog',
'shopId',
),
new StratifiedEntityConfig(
ProductTagManager::class,
'catalog',
'shopId',
),
];
Every relation between stratified tables must include buildStrate in its key mapping. This prevents a relation loader from joining rows belonging to different snapshots.
One-to-one
#[ToOne(
CatalogSettingsManager::class,
['id' => 'catalogId', 'buildStrate' => 'buildStrate'],
)]
private ?CatalogSettingsEntity $settings = null;
One-to-many
#[ToMany(
ProductManager::class,
['id' => 'catalogId', 'buildStrate' => 'buildStrate'],
)]
private ?EntityCollection $products = null;
Many-to-many
Small Swoole Entity Manager represents many-to-many relations with a join entity. The join entity owns two ToOne relations, and each side exposes a ToMany relation to the join rows.
The join entity must also be stratified and must use the same buildStrate in both foreign-key mappings. This prevents links from crossing snapshot boundaries.
Persistence order
Persist parents before children so foreign keys can be validated inside the same build. Release only after the complete graph has been written:
$strate = $stratifiedPersist->createNewVersion(
[
CatalogManager::class,
CatalogSettingsManager::class,
ProductManager::class,
TagManager::class,
ProductTagManager::class,
],
'catalog',
$shopId,
);
$stratifiedPersist->persist($catalog, $strate);
$stratifiedPersist->persist($settings, $strate);
$stratifiedPersist->persistMany($products, $strate);
$stratifiedPersist->persistMany($tags, $strate);
$stratifiedPersist->persistMany($productTags, $strate);
$stratifiedPersist->release('catalog', $shopId, $strate);
Build statuses
StratifiedBuildStatus defines four values:
| Case | Stored value | Meaning |
|---|---|---|
Building | building | The snapshot is being constructed and is not visible to released queries. |
Released | released | The snapshot has been published at least once. |
Failed | failed | Snapshot construction failed. |
GarbageCollected | garbage_collected | Obsolete business rows were collected and the metadata is ready for removal. |
Garbage collection
Run garbage collection with all managers whose obsolete snapshot rows must be removed:
$stratifiedPersist->garbageCollector([
BookingItemManager::class,
]);
For relation graphs, pass every manager in the graph. Managers not registered in the service configuration are ignored.
A build is considered obsolete when:
- it belongs to the same scope and scope identifier as a current release;
- it is older than the currently released build;
- its status is
building,released, orfailed.
After business rows are collected, the build is marked garbage_collected. Garbage-collected build metadata is then deleted. Failed build metadata older than the configured retention period is also deleted.
Default delete strategy
The default DeleteByBuildStrategy issues one delete query per obsolete UUID and manager:
DELETE FROM business_table WHERE build_strate = :buildStrate
The actual column mapping is resolved by the entity manager field metadata.
Partition strategy
Use PartitionGarbageCollectorStrategy when each build is stored in a dedicated database partition or child table:
<?php
declare(strict_types=1);
use Small\SwooleEntityManager\Strates\Config\StratifiedPersistOptions;
use Small\SwooleEntityManager\Strates\GarbageCollector\PartitionGarbageCollectorStrategy;
use Small\SwooleEntityManager\Strates\Service\StratifiedPersist;
$stratifiedPersist = new StratifiedPersist(
$entityManagerFactory,
$configs,
new StratifiedPersistOptions(
garbageCollectorStrategy: new PartitionGarbageCollectorStrategy(),
failedBuildMaxAgeHours: 24,
),
);
Partition names are derived from the UUID:
strate: 0195f88b-43fd-7f2d-a8fc-3f1fa48f8d29
partition: p_0195f88b43fd7f2da8fc3f1fa48f8d29
Database behavior:
- MySQL:
ALTER TABLE <table> DROP PARTITION <partition>; - PostgreSQL:
DROP TABLE IF EXISTS <table>_<partition>.
The application is responsible for creating and routing rows to partitions using the same naming convention.
Custom strategy
Implement GarbageCollectorStrategyInterface:
<?php
declare(strict_types=1);
use Small\SwooleEntityManager\Strates\Contract\GarbageCollectorStrategyInterface;
use Small\SwooleEntityManager\Strates\GarbageCollector\GarbageCollectorContext;
final class CustomGarbageCollector implements GarbageCollectorStrategyInterface
{
public function collect(GarbageCollectorContext $context): void
{
$manager = $context->manager;
$config = $context->config;
$obsoleteStrates = $context->obsoleteBuildStrates;
// Implement the storage-specific cleanup operation.
}
}
GarbageCollectorContext exposes the manager, its StratifiedEntityConfig, and the list of obsolete strate UUIDs.
Options
StratifiedPersistOptions accepts:
| Option | Type | Default | Purpose |
|---|---|---|---|
garbageCollectorStrategy | ?GarbageCollectorStrategyInterface | null | Uses DeleteByBuildStrategy when omitted. |
failedBuildMaxAgeHours | int | 24 | Failed build metadata retention before cleanup. Must be greater than zero. |
UUID v7 support
UuidV7::generate() creates RFC-compatible version 7 UUIDs using a 48-bit Unix millisecond timestamp and cryptographically secure random bytes.
use Small\SwooleEntityManager\Strates\Support\UuidV7;
$uuid = UuidV7::generate();
A fixed Unix millisecond value can be supplied for deterministic tests:
$uuid = UuidV7::generate(1_718_000_000_000);
Negative timestamps and timestamps larger than 48 bits are rejected.
Database support
MySQL
- Tested with MySQL 8.4.
- Uses
DATETIME(6)for metadata timestamps. - Uses backtick identifier escaping for partition cleanup.
PostgreSQL
- Tested with PostgreSQL 16.
- Uses
TIMESTAMP(6) WITHOUT TIME ZONEfor metadata timestamps. - Uses double-quote identifier escaping for partition cleanup.
- Uses direct release-thread flushing with entity-manager core 2.7.x to avoid the native PostgreSQL connection-pool cleanup issue in the core commit fallback.
DialectFactory rejects unsupported connection types with UnsupportedDatabaseTypeException.
Exceptions and validation failures
The package exposes dedicated exceptions for common configuration and lifecycle errors:
| Exception | Trigger |
|---|---|
UnknownStratesScopeException | A method references an unregistered scope. |
InvalidStratifiedEntityException | A managed entity lacks required fields or does not implement the contract. |
BuildNotFoundException | A release references a build that does not match the supplied scope and scope identifier. |
UnsupportedDatabaseTypeException | Partition cleanup receives an unsupported database connection. |
Standard InvalidArgumentException, RuntimeException, and LogicException are used for invalid options, duplicate configuration, invalid lifecycle transitions, and inconsistent manager connections.
Public API summary
StratifiedPersist
| Method | Purpose |
|---|---|
createNewVersion() | Creates a building strate and returns its UUID. |
createNewStrate() | Alias of createNewVersion(). |
persist() | Assigns a strate to one entity and persists it. |
persistMany() | Persists an iterable of stratified entities. |
release() | Publishes a build by updating the central release pointer. |
markBuildAsFailed() | Marks a build as failed. |
createReleasedQueryBuilder() | Creates a query constrained to the active strate. |
findReleasedByScopeId() | Returns released root entities for one scope identifier. |
garbageCollector() | Removes obsolete business rows and metadata. |
Supporting public types
StratifiedEntityConfigStratifiedPersistOptionsStratifiedEntityInterfaceGarbageCollectorStrategyInterfaceGarbageCollectorContextDeleteByBuildStrategyPartitionGarbageCollectorStrategyStratifiedBuildStatusManualPrimaryKeyPersistenceTraitStratifiedEntityTraitUuidV7
Development environment
The repository includes a Docker Compose environment containing:
- PHP 8.4 CLI;
- Swoole;
- Composer 2;
- MySQL 8.4;
- PostgreSQL 16.
The project is mounted at:
/usr/lib/src/small-entity-manager-strates
Install dependencies and run commands through the wrapper:
./bin/composer install
./bin/composer validate --strict
./bin/composer lint
./bin/composer phpstan
./bin/composer test
The wrapper starts and builds the Compose stack when the package container is not already running. It forwards the host user and group identifiers so generated files remain writable by the host user.
Tests
Unit tests
./bin/composer test:unit
The unit suite covers:
- UUID v7 format, timestamp encoding, variant, and bounds;
- stratified entity metadata;
- manual primary-key persistence;
- relation metadata and manual keys;
- microsecond date metadata;
- PostgreSQL release persistence behavior;
- partition naming and SQL generation.
Integration tests
./bin/composer test:integration
Run one database engine only:
./bin/composer test:integration:mysql
./bin/composer test:integration:postgres
The integration suite recreates an empty schema and verifies the same relation and lifecycle behavior against MySQL and PostgreSQL, including:
- one-to-one loading in both directions;
- optional one-to-one relations;
- one-to-one uniqueness per parent and build;
- one-to-many loading in both directions;
- empty one-to-many collections;
- rejection of children without a parent in the same build;
- many-to-many loading through a join entity;
- join-row uniqueness;
- rejection of cross-build links;
- foreign-key cascade behavior;
- invisibility of unreleased builds;
- release-pointer switching;
- isolation between scope identifiers;
- idempotent release;
- one release pointer per
(scope, scopeId); - rejection of failed, unknown, and mismatched builds;
- garbage collection across the complete dependency graph.
Integration schemas are stored in:
tests/Fixture/Database/mysql.sql
tests/Fixture/Database/postgres.sql
Complete quality checks
./bin/composer qa
The Composer scripts provide:
| Script | Command |
|---|---|
lint | PHP syntax validation for src and tests. |
phpstan | PHPStan level 9 analysis. |
test:unit | Pest unit suite. |
test:integration | Pest integration suite. |
test | Complete Pest suite. |
qa / check | Lint, PHPStan, and complete tests. |
Release process
Releases are created from main or master:
./bin/release --patch
./bin/release --minor
./bin/release --major
The release helper uses Git tags as the only source for version calculation.
Accepted tag formats:
X.Y.Z
X.Y.Z-N
Tags are sorted with version-aware ordering, so 0.10.0 is newer than 0.9.9. When tags with numeric suffixes exist, the highest version-sorted tag is used and the suffix is removed before calculating the next semantic version.
When the repository has no Git tags, the first release is always:
0.1.0
Before creating a commit and tag, the script runs:
composer validate --strict
composer lint
composer phpstan
composer test
composer test:integration
A failed command aborts the release before the version commit and tag are created. On success, .version is updated as generated release metadata, then the commit and tags are pushed.
Operational constraints
- Write a complete snapshot before calling
release(). - Register every stratified manager used by a scope.
- Declare one explicit root when a scope contains several managers.
- Include
buildStratein every relation key mapping between stratified tables. - Persist relation parents before dependent rows.
- Pass every graph manager to garbage collection.
- Keep metadata and business managers on the intended database connection.
- Treat
released_stratesas the source of truth for active snapshots. - Configure database partitions before enabling partition-based garbage collection.
Contributing
Development requirements and contribution rules are documented in CONTRIBUTE.txt.
Author
Sébastien Kus
License
Copyright © 2026 Sébastien Kus.
This project is released under the MIT License. See LICENCE.txt.