diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3d324a..c44c524 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,13 +113,75 @@ jobs: STARDUST_TEST_PASS: "root" run: vendor/bin/phpunit --testsuite Smoke + mariadb-smoke: + name: Smoke against MariaDB ${{ matrix.mariadb-version }} on PHP ${{ matrix.php-version }} (must pass) + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php-version: ["8.1", "8.2", "8.3", "8.4"] + mariadb-version: ["10.11", "11"] + + services: + mariadb: + image: mariadb:${{ matrix.mariadb-version }} + env: + MARIADB_ROOT_PASSWORD: root + MARIADB_DATABASE: stardust_test + ports: + - 3307:3306 + options: >- + --health-cmd="mariadb-admin ping -h 127.0.0.1 -uroot -proot" + --health-interval=5s + --health-timeout=5s + --health-retries=20 + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: pdo, pdo_mysql + coverage: none + + - name: Cache Composer + uses: actions/cache@v4 + with: + path: ~/.composer/cache + key: ${{ runner.os }}-php${{ matrix.php-version }}-composer-${{ hashFiles('composer.json') }} + restore-keys: ${{ runner.os }}-php${{ matrix.php-version }}-composer- + + - name: Composer install + run: composer install --no-progress --no-interaction --prefer-dist + + - name: Wait for MariaDB + run: | + for i in $(seq 1 30); do + if mariadb-admin ping -h 127.0.0.1 -P 3307 -uroot -proot --silent 2>/dev/null \ + || mysqladmin ping -h 127.0.0.1 -P 3307 -uroot -proot --silent 2>/dev/null; then + echo "MariaDB is up"; exit 0 + fi + sleep 1 + done + echo "MariaDB did not become ready in time"; exit 1 + + - name: Run smoke suite + env: + STARDUST_TEST_DSN: "mysql:host=127.0.0.1;port=3307;dbname=stardust_test" + STARDUST_TEST_USER: "root" + STARDUST_TEST_PASS: "root" + run: vendor/bin/phpunit --testsuite Smoke + mariadb-rejection: - name: Smoke against MariaDB (must fail) + name: Smoke against below-floor MariaDB (must fail) runs-on: ubuntu-latest services: mariadb: - image: mariadb:11 + image: mariadb:10.6 env: MARIADB_ROOT_PASSWORD: root MARIADB_DATABASE: stardust_test @@ -162,14 +224,14 @@ jobs: done echo "MariaDB did not become ready in time"; exit 1 - - name: Smoke suite must reject MariaDB + - name: Smoke suite must reject below-floor MariaDB env: STARDUST_TEST_DSN: "mysql:host=127.0.0.1;port=3307;dbname=stardust_test" STARDUST_TEST_USER: "root" STARDUST_TEST_PASS: "root" run: | if vendor/bin/phpunit --testsuite Smoke; then - echo "::error::Smoke suite unexpectedly passed against MariaDB" + echo "::error::Smoke suite unexpectedly passed against below-floor MariaDB" exit 1 fi - echo "Smoke suite rejected MariaDB as expected" + echo "Smoke suite rejected below-floor MariaDB as expected" diff --git a/AGENTS.md b/AGENTS.md index 80c791b..a94255a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,8 +25,9 @@ here. this codebase resolves to `SDDPG/adrs/`. Search there before treating a design question as open — most already have a ruling, and the record wins over any doc that disagrees with it. -- **MySQL 8.0.13+ only.** MariaDB is deliberately rejected, and a CI job asserts - the suite fails against it. +- **MySQL 8.0.13+ or MariaDB 10.11+, detected not configured.** MariaDB 10.6 and + older, and MySQL 5.7 and older, are rejected and a CI job asserts the suite + fails against a below-floor MariaDB. - **PHP 8.1 is the floor**, even though CI also tests up to 8.4. - **Run the three checks** in CONTRIBUTING.md before claiming a change is done. Several conventions are enforced by tests and will tell you when you break them. diff --git a/CLAUDE.md b/CLAUDE.md index f9abeb4..995b12b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,7 +160,7 @@ bin/stardust --help The smoke suite **skips** (does not fail) when `STARDUST_TEST_DSN` / `STARDUST_TEST_USER` are unset. CI provides them via the MySQL service container. `phpunit.xml.dist` sets `failOnWarning`, `failOnRisky`, `beStrictAboutOutputDuringTests`, and `beStrictAboutTestsThatDoNotTestAnything` — keep tests strict-clean. -CI ([.github/workflows/ci.yml](.github/workflows/ci.yml)) runs four jobs: `static-analysis` (PHPStan, no DB), `markdown-lint` (markdownlint, no DB), `mysql-smoke` (the suite across the full PHP matrix), and `mariadb-rejection` (below). +CI ([.github/workflows/ci.yml](.github/workflows/ci.yml)) runs five jobs: `static-analysis` (PHPStan, no DB), `markdown-lint` (markdownlint, no DB), `mysql-smoke` (the suite across the full PHP matrix), `mariadb-smoke` (the suite across the full PHP matrix x MariaDB 10.11 and 11), and `mariadb-rejection` (below). ## PHP floor and static analysis @@ -170,10 +170,11 @@ CI ([.github/workflows/ci.yml](.github/workflows/ci.yml)) runs four jobs: `stati - **Level 8 is the deliberate ceiling.** Measured before the raise: level 9 → 163 errors, level 10 → 259, against 31 for level 8. Level 9's cost is concentrated in `offsetAccess.nonOffsetAccessible` / `cast.int` / `cast.string` at PDO and `json_decode` boundaries, where ADR 0013 makes values `mixed` by design — `(int) $row['tenant_id']` is already a total operation, and satisfying the analyser there means narrowing ceremony rather than safety. Reaching 9 honestly means typed row hydration per repository, which is an architectural decision, not a config change. Don't raise the level without making that decision first. - **`PDO::query()` goes through `Support\PdoQuery::run()`**, which throws when the driver returns `false` instead of raising. The engine takes an injected PDO, so a consumer on `ERRMODE_SILENT` really can get `false` back. Two older sites (`GcSweeper`, `SchemaVersionCache`) handle it inline with different semantics and are deliberately left alone. -## Database support is intentionally narrow +## Database support -- **Supported:** MySQL 8.0.13+ or Percona 8.0.13+. The floor is non-negotiable — Phase 1 relies on functional/conditional unique indexes (8.0.13). -- **Unsupported and actively rejected:** MariaDB and MySQL ≤ 5.7. The CI workflow runs a second job (`mariadb-rejection`) that **expects the smoke suite to fail** against MariaDB; if you change environment checks, this job must still be tripped. `EnvironmentTest::testServerIsMySql` is the primary rejection gate. +- **Supported:** MySQL 8.0.13+ / Percona 8.0.13+, and — since ADR 0054/0055, landed 2026-09-20 — **MariaDB 10.11+**. The engine's target is *detected* from the live connection (`Support\ServerEngineDetector`), never configured; `StarDust::serverEngine()` is the public accessor. `Support\Dialect` branches the two constructs that diverge (table collation, the ADR 0017 live-slot invariant — a functional index on MySQL, a generated-column substitute on MariaDB). **One documented behavioural caveat on MariaDB**: range filters and field sorts order supplementary-plane characters at the opposite end from MySQL (ADR 0041's Consequences section, ADR 0054 §3) — a real divergence, accepted rather than engineered around. +- **Unsupported and actively rejected:** MariaDB ≤ 10.6 (the JSON-fallback collation divergence ADR 0054 found has no configuration-only fix) and MySQL/Percona ≤ 8.0.12. `ServerEngineDetector::detect()` enforces both floors at the runtime level, failing closed with `Exception\UnsupportedServerException` — verified directly against a real MariaDB 10.6 container. **`EnvironmentTest::testServerIsASupportedEngine`** (renamed from `testServerIsMySql`) is the smoke-suite mirror of that gate, and the full 937-test smoke suite is verified green on both MySQL 8.0.13 and MariaDB 10.11 as of this line. +- **CI now covers both engines (ROADMAP.md Stage 5, done):** `mariadb-smoke` runs the full PHP matrix against both MariaDB 10.11 and 11 (must pass), and `mariadb-rejection` was retargeted at `mariadb:10.6` — genuinely below the floor — so its "must fail" assertion stays meaningful (it no longer proves anything against `mariadb:11`, which the suite now passes). - `EXPLAIN ANALYZE` is an 8.0.18+ runbook tool only (ADR 0019/0023) — do **not** add it to the smoke suite. ## Architecture @@ -239,7 +240,7 @@ The engine ships as a framework-neutral Composer library. Zero framework / ORM / - **Schema builder — [src/Schema/SchemaBuilder.php](src/Schema/SchemaBuilder.php):** Convenience helper (NOT a phase deliverable, NOT the first-class definition API — that is still unbuilt) added for onboarding ergonomics so the first-run experience doesn't require hand-written `stardust_models` / `stardust_fields` SQL. `createModel(int $tenantId, string $name, list $fields = []): ModelDefinition`, `defineModel()`, and `defineField()` are all get-or-create (idempotent — a name that already exists returns its id unchanged, so a seed/setup script is safe to re-run) and follow the transaction+log discipline: one transaction per `createModel()` call that bumps `stardust_schema_version` exactly once iff a row was actually inserted (matches schema_reference §5.1 "field metadata changes increment version"). It registers registry rows ONLY — it does not provision pages or reserve slots, so making a field genuinely filterable still goes through `PageProvisioner` + `SlotReserver` (or the Watcher). Surfaced via `StarDust::schemaBuilder()`. It deliberately stays narrow; do not grow it into the full definition API. **Read-side introspection lives in a separate class for exactly that reason** — `Schema\SchemaReader` (`listModels()`, `describeModel()`) is lock-free, mutation-free, version-bump-free, and safe per request, so keeping it off `SchemaBuilder` lets the write-side stopgap be replaced without dragging the read side with it. Its DTOs are `ModelSummary`, `ModelDescription`, and `FieldDescription`; the last carries **both** `isFilterable` (registry intent) and `isIndexed` (a live `assigned|ready` slot, i.e. a filter works right now). Those diverge for the whole of a promotion or retype backfill — `SchemaReader::QUERYABLE_STATUSES` is deliberately narrower than `LiveSlotMap::LIVE_STATUSES`, excluding `backfilling`, because ADR 0004 rejects filters against it. Smoke coverage: `tests/Smoke/Schema/SchemaReaderTest`. `FieldDefinition` (input DTO: `name`, `declaredType`, `isFilterable`) and `ModelDefinition` (result: `modelId` + `fieldName → id` map, `fieldId()` accessor) live alongside it. Smoke coverage: `tests/Smoke/Schema/SchemaBuilderTest`. - **Support — [src/Support/RetryableLockFailure.php](src/Support/RetryableLockFailure.php):** The one definition of "InnoDB refused this over a lock and retrying is safe" (errno 1213 / 1205 / SQLSTATE 40001). Shared by `SlotSweeper`, `ModelPurgeWorkSource` and the five other Reconciler work sources; **do not re-inline it**, on the `LiveSlotTombstoner` precedent. It has already had to be widened once — ADR 0038 found the 1205 case the original deadlock-only predicate missed — and a copy that misses the next widening fails silently, as a daemon that exits under contention. What callers do *after* a match is deliberately not standardised: the Liberator has a gap path, `ModelPurgeWorkSource` rethrows, and the other five return `TickOutcome::LOCK_WAIT`. - **Support — [src/Support/ArtifactDirectory.php](src/Support/ArtifactDirectory.php):** The one definition of the race-tolerant `is_dir() || @mkdir(recursive) || is_dir()` idiom for an artifact directory. Three callers across two packages — `Chronicler\ArtifactStreamFactory`, `Write\BulkIngestSubmitter`, and ADR 0051's `Chronicler\DiskPressureGate` — so it lives in `Support/` on the `RetryableLockFailure` precedent rather than beside any one of them; **do not re-inline it**, since the trailing `|| is_dir()` is the race tolerance and a copy that drops it turns two workers starting together into a spurious failure for whichever loses the `mkdir`. **Returns `bool` and deliberately does not throw** — what a caller does with `false` is caller policy, the same reason `RetryableLockFailure` declines to standardise what happens after a match: the two older callers keep their own `RuntimeException` messages verbatim, while the gate turns it into a `probe_stage: 'mkdir'` trip and never throws. Covered by `tests/Smoke/ArtifactDirectoryTest`. -- **Support — [src/Support/Dialect.php](src/Support/Dialect.php):** The one definition of the engine's MySQL-specific DDL/SQL constructs — the `utf8mb4_0900_ai_ci` table-level collation clause (`tableOptionsClause()`) and the ADR 0017 live-slot functional unique index DDL (`liveSlotUniqueIndexDdl()`). Two callers across two packages (`Bootstrap\Bootstrapper`, `Page\PageProvisioner`), so it lives in `Support/` on the `RetryableLockFailure` precedent rather than beside either one. **Do not re-inline either literal** — naming each once is what would make a future second-engine substitution a one-file edit instead of a grep. A third construct, the collation of a hypothetical JSON-fallback SQL comparison (ADR 0013), is named on the class docblock but given no method: no such comparison exists in shipped code today, since ADR 0004's pre-flight rejects a filter against a non-filterable field before compilation and the JSON-fallback read path is pure PHP `json_decode()`. Add a method here, not an inline literal, the day one ships. Every method returns MySQL syntax unconditionally — this class is the seam a future dialect switch would need, not the switch itself. Covered by `tests/Smoke/DialectTest.php`. +- **Support — [src/Support/Dialect.php](src/Support/Dialect.php):** The one definition of the engine's MySQL-specific DDL/SQL constructs — the `utf8mb4_0900_ai_ci` table-level collation clause (`tableOptionsClause()`) and the ADR 0017 live-slot functional unique index DDL (`liveSlotUniqueIndexDdl()`). Two callers across two packages (`Bootstrap\Bootstrapper`, `Page\PageProvisioner`), so it lives in `Support/` on the `RetryableLockFailure` precedent rather than beside either one. **Do not re-inline either literal.** Since ADR 0054/0055, both methods take a `ServerEngine` parameter and branch: `tableOptionsClause()` returns MySQL's `utf8mb4_0900_ai_ci` or MariaDB's `utf8mb4_unicode_520_nopad_ci`, and `liveSlotUniqueIndexDdl()` returns the MySQL functional unique index or, on MariaDB (which rejects that syntax with errno 1064), a `PERSISTENT` generated column carrying the identical `CASE` expression plus a plain `UNIQUE KEY` — `Dialect` is the one file naming both engines' literals side by side, which is what made the substitution a one-file edit instead of a grep. A third construct, the collation of a hypothetical JSON-fallback SQL comparison (ADR 0013), is named on the class docblock but given no method: no such comparison exists in shipped code today, since ADR 0004's pre-flight rejects a filter against a non-filterable field before compilation and the JSON-fallback read path is pure PHP `json_decode()`. Add a method here, not an inline literal, the day one ships. Covered by `tests/Smoke/DialectTest.php`. - **Support — [src/Support/UuidV4.php](src/Support/UuidV4.php):** The one shared utility. `UuidV4::generate(): string` is the source of every `correlation_id` / `chunk_correlation_id` in the daemons and of the uniqueness suffix in `WorkerIdentity::mint()`. Covered by `tests/Smoke/UuidV4Test`. - **Clock — [src/Clock/SystemClock.php](src/Clock/SystemClock.php):** `psr/clock` default; UTC `DateTimeImmutable`. Always inject this rather than calling `new DateTime` directly, so tests can swap in a frozen clock. @@ -252,7 +253,7 @@ The engine ships as a framework-neutral Composer library. Zero framework / ORM / ### Schema invariants worth knowing before editing DDL - **`stardust_slot_assignments.status`** is a closed five-state ENUM: `free | assigned | tombstoned | backfilling | ready`. Out-of-band values must be rejected at the database level (relies on default 8.0 `STRICT_TRANS_TABLES`). Live states are `assigned | backfilling | ready` — Phase 3's write path materializes into all three. -- **`ux_slot_assignments_field_live`** is a functional partial UNIQUE index implementing **ADR 0017**'s "at most one live slot per field" invariant via a `CASE … END` over `status`. MySQL has no `CREATE INDEX IF NOT EXISTS`, so `Bootstrapper::ensureSlotAssignmentFieldLiveUniqueIndex()` probes `information_schema.STATISTICS` first to stay idempotent. Do not collapse this back into the `CREATE TABLE`. The DDL text itself lives in `Support\Dialect::liveSlotUniqueIndexDdl()` — see the `Dialect` bullet below. +- **`ux_slot_assignments_field_live`** implements **ADR 0017**'s "at most one live slot per field" invariant via a `CASE … END` over `status`, the same index name on both supported engines. On MySQL it is a functional partial UNIQUE index. On MariaDB (ADR 0054/0055), which has no functional-index syntax (errno 1064), the identical `CASE` expression instead lives on a `PERSISTENT` generated column (`stardust_slot_assignments.live_field_id`, added by `Bootstrapper::ensureSlotAssignmentLiveFieldIdColumn()`) with a plain `UNIQUE KEY` on it — nothing downstream branches on which shape is live. MySQL has no `CREATE INDEX IF NOT EXISTS`, so `Bootstrapper::ensureSlotAssignmentFieldLiveUniqueIndex()` probes `information_schema.STATISTICS` (MariaDB's generated column is probed via `information_schema.COLUMNS` instead, same idempotency pattern as every other `ensureXxx()`). Do not collapse this back into the `CREATE TABLE`. The DDL text itself lives in `Support\Dialect::liveSlotUniqueIndexDdl()` — see the `Dialect` bullet below. - **`sweep_cursor_id` and `sweep_gap_count` are per-*sweep* annotations, not per-column ones, and ADR 0045 is what makes that true.** `sweep_gap_count` (Phase 6a, `INT NOT NULL DEFAULT 0`) is incremented inside the Liberator's gap path (3rd consecutive deadlock on the same chunk) so operators can spot slots whose sweep skipped rows; `sweep_cursor_id` (Phase 1, `BIGINT NULL`) is the sweep's high-water mark on `entry_id`. `Bootstrapper::ensureSlotAssignmentSweepGapColumn()` probes `information_schema.COLUMNS` and runs `ALTER TABLE … ADD COLUMN` only when missing — same idempotency pattern as the functional unique index above; the cursor needed no probe, it has been in `createSlotAssignments()` since Phase 1. **Both are cleared by the UPDATE that flips a slot to `tombstoned`, and both are preserved across the `tombstoned → free` reclaim** — so they describe the sweep that produced the current state and die when the next one starts. The reclaim-preserves half is Phase 6a's original intent; the tombstone-clears half is ADR 0045, and until it landed *nothing anywhere* reset either column, so a recycled slot's second sweep resumed from its previous occupant's final cursor and returned to `free` still holding that field's values (measured on 8.0.13: twenty rows survived, with `sweep_chunk` and `sweep_complete` both honest for the empty range they walked). **`SlotReserver` is deliberately not the reset point** — an older comment in `SlotSweeper` proposed `free → assigned` and 0045 withdrew it: tombstone time fails closed (a sweep that starts too early costs one idempotent pass; one that starts too late loses data), needs no new caller, and leaves the annotations readable on a reclaimed slot. **There are two tombstone sites and both carry the clause** — `Slot\LiveSlotTombstoner` and `Delete\ModelPurgeWorkSource`'s final-chunk re-assertion — and both are guarded `AND status IN ('assigned','backfilling','ready')`, which is what stops either from restarting a sweep already in flight. - **`stardust_fields.previous_name`** (ADR 0036) is the `VARCHAR(128) NULL` column that bridges a rename window. `entry_data.fields` is keyed by field name, so a rename flips `name` immediately and rewrites payloads asynchronously — leaving a window where some rows carry the old key. A non-null `previous_name` means exactly "a rename is in flight for this field", and both `SlotResolver` (read path) and `LiveSlotMap` (write path) pick it up for free because they already SELECT that table with no join. Cleared in the same transaction that completes the backfill, alongside the version bump — **those must commit together**, or a reader refreshing between them loses the fallback while un-migrated rows still exist. `Bootstrapper::ensureFieldsPreviousNameColumn()` uses the same idempotent `information_schema.COLUMNS` probe as Phase 6a's `sweep_gap_count`. Note this is deliberately *not* the `source_declared_type` pattern below: that column exists only because retype destructively overwrites `declared_type`. - **`stardust_fields.deleted_at`** (ADR 0037) is the `DATETIME NULL` column that bridges a deletion window, and is deliberately the same shape as `previous_name` above for the same hot-path reason. **A non-null value means exactly "a deletion is in flight for this field"**, and every registry reader carries `deleted_at IS NULL` from that moment. It is a drain-window marker, **not a soft-delete tier** — there is no undelete, and the purge's final chunk hard-deletes the row. The row outlives the purge only because the work source needs the field's name, model and tenant to build its JSON path and `backfill_checkpoints` has no column for any of them; the FK does *not* force the deferral, since the two-step tombstone releases it inline. `Bootstrapper::ensureFieldsDeletedAtColumn()` uses the same idempotent `information_schema.COLUMNS` probe. Note the initiator also clears `is_filterable` in the same UPDATE — load-bearing, or `PendingDemandReader` and `UnmappedFieldReserver` both read the field as demand and the latter re-takes the FK. @@ -271,7 +272,7 @@ The engine ships as a framework-neutral Composer library. Zero framework / ORM / - **`stardust_import_jobs`** (Phase 3) enforces ADR 0011 idempotency via `UNIQUE (tenant_id, idempotency_key)`. MySQL UNIQUE allows multiple NULL `idempotency_key` rows, so unkeyed submissions never collide. Phase 5's `ImportJobWorkSource` transitions `pending → processing → completed | failed` and populates `manifest`, `worker_identity`, `claimed_at`, `heartbeat_at`, `failed_reason`, `completed_at`. The `manifest` is now written **chunk-by-chunk** (not only at completion): it doubles as the resume checkpoint. The `INDEX (status, heartbeat_at)` backs the abandoned-claim sweep — `ImportJobWorkSource` re-claims a `processing` job whose `heartbeat_at` lapsed past `Config::$reconcilerImportLeaseTimeoutSeconds` (default 30 s) and resumes from `manifest.entries_written`, with the prior worker self-aborting on a `worker_identity` mismatch (Gap 5 resolution, 2026-06-18 — mirrors the Chronicler). - **`stardust_export_jobs`** (provisioned in Phase 1, consumed in Phase 7) needs no Phase 7 DDL — every column and index the Chronicler reads or writes (`status`, `filter` JSON, `format`, `last_cursor`, `artifact_path`, `failed_reason`, `skip_count`, `worker_identity`, `claimed_at`, `heartbeat_at`, `completed_at`, plus four indexes: `(status, created_at)` for pending claim, `(tenant_id, status)` for the submission cap check, `(status, heartbeat_at)` for the abandoned-claim sweep, `(completed_at)` for GC) was already present in Phase 1's `createExportJobs()`. The Chronicler claims with `SELECT … FOR UPDATE SKIP LOCKED` against the existing indexes — no new DDL means no Phase 7 entry in any `ensureXxxColumn()` probe. Phase 7's `model_id` lives at the top level of a `{model_id, filter}` envelope stored in the `filter` JSON column (stamped by `ExportJobSubmitter::submit()` and read by `ExportJobClaimer::extractModelId()`); the consumer's original QueryFilter is preserved verbatim under `.filter`, so Phase 8's QueryFilter validator does not have to peel out the engine's stamping. A future workload-driven ADR can materialise `model_id` as a separate column. - **String slots are `TEXT` with a 766-char prefix index** (ADR 0030, resolving Gap 4): `i_str_NN` columns hold the full 4096-char QueryFilter string bound (`FilterLimits::DEFAULT_MAX_STRING_LENGTH`); a filterable string slot's composite index is `(tenant_id, i_str_NN(766))` — 766 utf8mb4 chars × 4 bytes + 8-byte tenant_id = 3072 bytes, exactly the InnoDB DYNAMIC key limit. **`VARCHAR(4096)` is physically impossible** — 25 such columns exceed MySQL's 65,535-byte row-definition limit (errno 1118), which counts every VARCHAR in full while TEXT counts only ~12 bytes. The page DDL pins `ROW_FORMAT=DYNAMIC` (load-bearing: COMPACT/REDUNDANT cap index keys at 767 bytes → errno 1071). MySQL rechecks the full value behind every prefix-index access so all 12 filter operators stay exact; never add `ORDER BY`/`GROUP BY` on a string slot without revisiting ADR 0030 (`max_sort_length` truncates TEXT sorts at 1024 bytes). `PageProvisioner::STRING_INDEX_PREFIX` is the single source of the 766; `tests/Smoke/Page/StringSlotWidthTest` locks the DDL shape and the >255-char write/filter behavior. Forward-only — existing `VARCHAR(255)` pages are not altered (ADR 0012). -- **An extension page carries exactly the columns it indexes, and its inventory names the same set** (ADR 0043). `PageProvisioner::buildPageDdl()` and `insertSlotInventory()` take one validated list, so `free` and `claimable` became the same set with no new column, no new predicate and no migration — the fix for a `CapacityReporter` that reported `usable_free_slots: 177` where zero slots were claimable. `provision()` therefore **requires a non-empty list**: a page with no columns has no inventory rows, so it adds nothing to the totals, never clears the low-capacity trigger, and would be provisioned again every tick (`ProvisioningPlanner` declines to plan one; the provisioner rejects one). **There is no `SLOTS_PER_PAGE`** — a page's capacity is whatever it was provisioned with, readable only from its own inventory; the 25/15/10/10 constants survive as per-family upper bounds. **Two page shapes now exist permanently:** pages provisioned before this keep sixty columns and their unindexed inventory rows (ADR 0012 is forward-only), which is what `tests/Smoke/Support/LegacyPage.php` exists to reproduce — its DDL is frozen and must not be updated to track `PageProvisioner`. +- **An extension page carries exactly the columns it indexes, and its inventory names the same set** (ADR 0043). `PageProvisioner::buildPageDdl()` and `insertSlotInventory()` take one validated list, so `free` and `claimable` became the same set with no new column, no new predicate and no migration — the fix for a `CapacityReporter` that reported `usable_free_slots: 177` where zero slots were claimable. `provision()` therefore **requires a non-empty list**: a page with no columns has no inventory rows, so it adds nothing to the totals, never clears the low-capacity trigger, and would be provisioned again every tick (`ProvisioningPlanner` declines to plan one; the provisioner rejects one). **There is no `SLOTS_PER_PAGE`** — a page's capacity is whatever it was provisioned with, readable only from its own inventory; the 25/15/10/10 constants survive as per-family upper bounds. **Two page shapes now exist permanently:** pages provisioned before this keep sixty columns and their unindexed inventory rows (ADR 0012 is forward-only), which is what `tests/Smoke/Support/LegacyPage.php` exists to reproduce — its column layout is frozen and must not be updated to track `PageProvisioner`. Its collation is a narrower exception: `provision()` / `ddl()` take a `ServerEngine` parameter and pick the collation via a local `match`, not a call to `Dialect::tableOptionsClause()` — deliberately, since a hardcoded `utf8mb4_0900_ai_ci` literal fails outright on MariaDB (errno 1273), and this fixture's job is to stay frozen against production-code evolution, which now applies to the collation source as much as it always did to the column layout. - **Which slot columns are indexed is not persisted anywhere.** `PageProvisioner` emits `filterable_slots` to the log but stores it in no registry column, so both consumers that need the answer derive it at runtime from `information_schema.STATISTICS` via `IndexedSlotPredicate`. Correct but not free — the Watcher pays one data-dictionary lookup per slot per poll. ADR 0043 did **not** retire this: on a current-shape page the predicate is true of every column, but legacy pages still need it, so it is permanent rather than transitional. Persisting it (`stardust_slot_assignments.is_indexed`, written at inventory-insert time behind a new `Bootstrapper::ensureXxxColumn()` probe) was considered and rejected in ADR 0043 — it annotates the lie rather than removing it — but `IndexedSlotPredicate` remains the seam that would keep it a one-file migration. - `entry_data` carries two tenant-scoped composite indexes — `(tenant_id, model_id)` and `(tenant_id, deleted_at, created_at)` — verified by `testEntryDataCompositeIndexesPresent`. - Every `entry_slots_page_N` write goes through `INSERT … ON DUPLICATE KEY UPDATE` keyed on `entry_id` (Architecture Blueprint §5). The slot column list is built from `stardust_slot_assignments.slot_column` whose universe is `i_{str|int|num|dt}_NN` — interpolating those into SQL is safe. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5fc11b1..a39318c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,11 +8,14 @@ run, and which conventions check themselves so you don't have to memorise them. - **PHP 8.1 or newer.** 8.1 is the floor, and CI runs the test suite on 8.1, 8.2, 8.3, and 8.4. Do not use syntax newer than 8.1 — it will compile on your machine and fail on the oldest matrix job. -- **MySQL 8.0.13+ or Percona 8.0.13+.** The floor is non-negotiable; the schema - registry depends on functional partial unique indexes introduced in 8.0.13. -- **MariaDB is actively rejected**, and a CI job exists specifically to assert that - the suite *fails* against it. That is a feature, not a bug — see the README's - Requirements section for why. +- **MySQL 8.0.13+ or Percona 8.0.13+, or MariaDB 10.11+.** Whichever engine you + point it at is detected, not configured. MySQL's floor is non-negotiable — the + schema registry depends on functional partial unique indexes introduced in + 8.0.13; MariaDB has no such index type at any version and gets a generated-column + substitute instead, which is why its own floor (10.11) was set independently. +- **MariaDB 10.6 and older is actively rejected**, and a CI job exists specifically + to assert that the suite *fails* against it. That is a feature, not a bug — see + the README's Requirements section for why. - Composer, and Node (only if you want to run the markdown linter locally). ## Setup @@ -44,8 +47,9 @@ npx --yes markdownlint-cli2@0.23.2 "*.md" "src/**/*.md" ".agent/**/*.md" "docs/* vendor/bin/phpunit --testsuite Smoke ``` -CI runs four jobs: PHPStan, markdownlint, the suite across the full PHP matrix, -and the MariaDB rejection check. +CI runs five jobs: PHPStan, markdownlint, the suite across the full PHP matrix +against MySQL, the same matrix against MariaDB 10.11+ (must pass, same as MySQL), +and the MariaDB-below-floor rejection check (targets 10.6, must fail). Two notes on static analysis. PHPStan runs at level 8 over `src/` and `bin/`, and it is pinned to analyse the whole supported PHP range rather than your local diff --git a/README.md b/README.md index a72725c..515ff27 100644 --- a/README.md +++ b/README.md @@ -142,13 +142,13 @@ Some vocabulary here is specific to StarDust — *slot*, *page*, *spread*, *back **A good fit if you:** - Need user-defined or per-tenant dynamic fields that are still **filterable at native SQL index speed**, without standing up a separate search cluster. -- Already run **MySQL 8.0.13+ (or Percona)**, either as persistent background processes (systemd, supervisor, or containers) or as a scheduled `bin/stardust tick` on a host with no persistent-process capability. +- Already run **MySQL 8.0.13+ (or Percona)**, or **MariaDB 10.11+**, either as persistent background processes (systemd, supervisor, or containers) or as a scheduled `bin/stardust tick` on a host with no persistent-process capability. - Want a **framework-neutral** engine you can drop into any PHP app via Composer — no ORM, query builder, or framework pulled in. - Can tolerate a newly defined or retyped filterable field becoming queryable **shortly after** the fact rather than instantly. **Probably not a fit if you:** -- Are tied to **MariaDB or MySQL ≤ 5.7** — both are actively rejected (see [Requirements](#requirements)). +- Are tied to **MariaDB ≤ 10.6 or MySQL ≤ 5.7** — both are actively rejected (see [Requirements](#requirements)). MariaDB 10.11+ is supported, with one caveat: range filters and field sorts order supplementary-plane characters (rare outside emoji) at the opposite end from MySQL. - Need **strong read-after-write consistency on filters immediately after a retype or filterability promotion.** The field is served from the JSON payload (and is not filterable) until its backfill completes. - Need **full-text, fuzzy, or substring search** out of the box. The default MySQL driver ships exact-match, comparison, range, set-membership, and *anchored*-prefix (`LIKE 'x%'`) operators — but no substring/suffix matching, no fuzzy matching, and no relevance ranking. Fuzzy/full-text is a capability you'd supply via a custom driver. - Need **page numbers, jump-to-page navigation, or a total result count.** Reads are cursor-paginated and forward-sequential: every page hands you an opaque cursor for the next one, and the absence of a cursor means you have reached the end. There is no offset parameter and no total count, and that is deliberate rather than pending — both require the database to read the entire matching set, so a query that is quick today would slow down purely because the tenant grew. Infinite scroll and a Next button work naturally; a Back button means holding on to the cursors you have already used, and "Page 7 of 214" or a deep link to an arbitrary page cannot be served at all. A driver backed by an external search service can maintain its own index and supply them. @@ -166,7 +166,7 @@ Some vocabulary here is specific to StarDust — *slot*, *page*, *spread*, *back - **Writes** — single-entry, synchronous chunked bulk (≤ 1 000 per call), and async submission for larger batches. Writes stay available even when slot capacity is exhausted: the value still lands in the JSON payload and is queued for backfill. - **Entry updates and deletes** — `updateEntry()` replaces an entry's fields wholesale, rewriting both the JSON payload and the indexed slot columns, and clearing the slot of any field the new payload omits so a filter can never match a stale value. `deleteEntry()` soft-deletes: one timestamp, after which the entry is gone from reads, filters, point-reads, and exports alike. - **Reads** — cursor-paginated, two-query bounded read; tenant-isolated SQL on every `WHERE` and `JOIN`; an in-process schema-version cache. -- **Search** — a unified `search()` surface; JSON wire format decoded into a closed filter AST (twelve operators, full AND/OR/NOT); three-stage pre-flight validation on the filter tree (field resolution, capability, value type) plus a fourth stage that validates the sort key and cursor agreement; a swappable driver (MySQL-native default keeps pure-AND filters on indexed joins and switches to `EXISTS` subqueries for OR/NOT — inject your own to delegate to an external search service). +- **Search** — a unified `search()` surface; JSON wire format decoded into a closed filter AST (twelve operators, full AND/OR/NOT); three-stage pre-flight validation on the filter tree (field resolution, capability, value type) plus a fourth stage that validates the sort key and cursor agreement; a swappable driver (the built-in native driver keeps pure-AND filters on indexed joins and switches to `EXISTS` subqueries for OR/NOT — inject your own to delegate to an external search service). - **Background daemons** (all runnable via `bin/stardust`): the **Watcher** keeps slot capacity provisioned and indexes each new page for the fields currently waiting on one, the **Reconciler** drains six work sources (sync queue, async imports, retype backfills, rename rewrites, field-deletion purges, and model-deletion purges), claiming a slot for any filterable field still waiting on one, with a dead-letter queue and operator replay, and auto-recovery of import jobs abandoned by a crashed worker — resumed from the last committed checkpoint — the **Liberator** reclaims tombstoned slots, and the **Chronicler** streams CSV/JSON exports to disk. - **Field lifecycle** — online field retype, and filterability promotion and demotion, through a type-coercion matrix, with JSON-payload fallback throughout the backfill window. Demotion is registry-only and takes effect immediately: the slot is tombstoned for the Liberator to reclaim, and reads fall straight back to the payload. - **Model rename** — `renameModel()` is immediate and complete when it returns: a model's name is a label, not an identity, so entries, slots, filters and exports all keep working untouched and there is no background catch-up to wait for. One caveat: `schemaBuilder()`'s `createModel()` / `defineModel()` find a model by name, so a setup or seed script still using the old name will create a **second** model rather than finding the renamed one — update those scripts in step with the rename. @@ -191,15 +191,19 @@ If you need a working library today, stay on `^0.2.0-alpha.x`. - **PHP:** 8.1 or later - **PHP extensions:** `ext-pdo`, `ext-pdo_mysql` -- **Database:** MySQL 8.0.13+ **or** Percona Server 8.0.13+ +- **Database:** MySQL 8.0.13+ **or** Percona Server 8.0.13+ **or** MariaDB 10.11+ -The 8.0.13 floor is firm: StarDust leans on functional/conditional unique indexes, which don't exist below 8.0.13. We'd rather refuse to start than corrupt your registry on an engine that silently does the wrong thing. +The engine detects which one it's talking to at boot — there is no configuration flag to set. MySQL's floor is firm: StarDust leans on functional/conditional unique indexes, which don't exist below 8.0.13. MariaDB has no equivalent index type at any version; the same "at most one live slot per field" invariant is instead enforced there by a generated column plus a plain unique index, which is why MariaDB's own floor (10.11) was chosen independently rather than by mirroring MySQL's. We'd rather refuse to start than corrupt your registry on an engine that silently does the wrong thing. + +**One documented behavioral difference on MariaDB:** range filters (`lt`, `lte`, `gt`, `gte`, and a `between` whose bounds straddle it) and field sorts order supplementary-plane Unicode characters — mostly emoji, well outside everyday text — at the opposite end from MySQL. Every other comparison, and ordinary text in any language, is unaffected. **Not supported:** -- **MariaDB** — its partial-index syntax diverges from MySQL's in a way that would break the slot registry. StarDust detects this and refuses to run, and CI keeps us honest with a dedicated job that *expects* the smoke suite to fail on MariaDB. You find out at boot, not in production. +- **MariaDB 10.6 and older** — a JSON-column collation divergence found below the 10.11 floor has no configuration-only fix. - **MySQL 5.7 and older** — no partial-unique-index feature, which the schema registry depends on. +Either unsupported engine is detected and refused at boot, not discovered in production. + --- ## Deployment Requirements @@ -266,7 +270,7 @@ use StarDust\Slot\SlotReserver; // Provision a page carrying the two slots the filterable fields will use. // The page is created with exactly these columns, each with its own // composite (tenant_id, slot) index — the list may not be empty. -(new PageProvisioner($pdo, $engine->config()->clock, $engine->logger())) +(new PageProvisioner($pdo, $engine->config()->clock, $engine->logger(), $engine->serverEngine())) ->provision(filterableSlots: ['i_str_01', 'i_int_01']); // Reserve one slot per field (free → assigned). Reservation takes the @@ -420,7 +424,7 @@ The framework-neutral `bin/stardust` entry point — bootstrap, the four daemons ## Testing -StarDust is covered by a smoke suite that runs against a **real MySQL** — no mocked databases. It skips cleanly when no test database is configured, so a fresh clone runs green out of the box: +StarDust is covered by a smoke suite that runs against a **real MySQL or MariaDB** — no mocked databases. It skips cleanly when no test database is configured, so a fresh clone runs green out of the box: ```bash composer install @@ -430,7 +434,7 @@ vendor/bin/phpunit --testsuite Smoke A handful of the suite's tests need no database at all (e.g. the wire-format decoder, the event-vocabulary guard, and the schema-conformance cross-check), so they run even on a bare clone. -GitHub Actions runs the same suite on every push, plus a second job that asserts the suite **fails** against MariaDB. +GitHub Actions runs the same suite on every push against MySQL and against MariaDB 10.11+ (both **must pass**), plus a job that asserts the suite **fails** against MariaDB 10.6, which is below the supported floor. For the full setup guide and a phase-by-phase breakdown of exactly what each behaviour the suite proves, see **[TESTING.md](TESTING.md)**. diff --git a/TESTING.md b/TESTING.md index fdadf1e..730d521 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,6 +1,6 @@ # Testing StarDust -StarDust is verified by a **smoke suite that runs against a real MySQL** — there are no mocked databases. Every behavioural guarantee below is checked end-to-end on a live 8.0.13+ server (the CI service container, or your local one). +StarDust is verified by a **smoke suite that runs against a real MySQL or MariaDB** — there are no mocked databases. Every behavioural guarantee below is checked end-to-end on a live 8.0.13+ MySQL server or a live 10.11+ MariaDB server (the CI service containers, or your local one). If you just want to *use* StarDust, you don't need this file — start with the [README](README.md). This is for contributors and anyone who wants to understand exactly what the engine promises and how each promise is proven. @@ -29,11 +29,11 @@ vendor/bin/phpunit --testsuite Smoke --filter SchemaBuilderTest vendor/bin/phpunit --testsuite Smoke --filter testCreateModelIsIdempotentAndDoesNotBumpVersionOnReRun ``` -GitHub Actions runs the same suite on every push, plus a second job that asserts the suite **fails** against MariaDB (the rejection is a feature, not a bug — see the README's Requirements section). +GitHub Actions runs the same suite on every push against MySQL and against MariaDB 10.11+ (both **must pass**), plus a job that asserts the suite **fails** against MariaDB 10.6 — below the supported floor, rejected on purpose, not a bug (see the README's Requirements section). ## What the suite covers, phase by phase -- **Phase 0 — environment.** Server is MySQL (not MariaDB), version is 8.0.13+, and functional unique indexes enforce the partial-uniqueness invariant the schema registry depends on. (`EXPLAIN ANALYZE` is an 8.0.18+ operator-runbook tool and is deliberately **not** smoke-tested.) +- **Phase 0 — environment.** Server is a supported engine at or above its floor — MySQL/Percona 8.0.13+ or MariaDB 10.11+, detected rather than configured — and below either floor the suite fails closed with a typed exception. On MySQL the "at most one live slot per field" invariant is enforced by a functional unique index; on MariaDB, which has no such syntax, the identical invariant is enforced by a generated column plus a plain unique index. (`EXPLAIN ANALYZE` is an 8.0.18+ MySQL-only operator-runbook tool and is deliberately **not** smoke-tested.) - **Phase 1 — bootstrap.** The migration runner creates every data plane, registry, and operational table on a blank database; re-runs are non-destructive; the `stardust_schema_version` singleton is seeded with `id = 1`; the `stardust_slot_assignments` status ENUM rejects out-of-band values; the partial unique index on `field_id` is enforced at the database level; and the tenant-scoped composite indexes on `entry_data` are present. - **Phase 2 — slot & page system.** Page provisioning emits composite `(tenant_id, slot_column)` indexes only for the filterable slots named by the caller; **the page is created with exactly those columns and its slot inventory names the same set**, one `free` row each, in the same registry transaction as the `stardust_schema_version` bump — so every free slot the capacity reporter counts is one a reservation could take, checked both against a directly provisioned page and against one a real Watcher tick produced; an empty column list is rejected, since a page with no slots would never clear the low-capacity trigger; a page provisioned in the pre-0043 sixty-column shape still reports its unindexed inventory as unclaimable, which stays true for as long as such pages exist; a forced failure rolls the registry transaction back without leaking partial inventory; sequential calls assign monotonic page numbers; the slot reserver performs the `free → assigned` transition atomically and returns `null` when no free slot of the requested family exists; and the `EmptyTableGuard` rejects DDL against populated pages before any metadata lock is acquired. Reservation is also model-affine: it prefers a page already hosting a live slot of the same model (a `backfilling` sibling counts, a `tombstoned` one does not, and another model's slots never do), spills to global-oldest when that page has no free slot of the family so a reservation that would succeed never fails, yields to `requireIndexed` because eligibility outranks ordering, stays deterministic across repeats, and reports the outcome on the existing `slot_reserved` event. Two further properties are proven rather than assumed: a held reservation leaves the model's live sibling slots writable from a second connection on a 1 s lock timeout (the ADR-normative non-locking invariant), and a model already living on a newer page stays at `excess_pages = 0` when grown while the oldest page still has capacity — measured with the spread sampler, and confirmed to fail when affinity is disabled. - **Phase 3 — write path.** Single-entry writes commit `entry_data` + every live-slot row + (optionally) a `stardust_sync_queue` enqueue in one transaction; the exhaustion-fallback path keeps the write succeeding when slots are missing; uncoercible payload values roll the whole entry back; bulk ingest chunks transactions per `BulkIngestOptions::$chunkSize`, applies the inter-chunk delay only between chunks, and rolls each failed chunk back atomically while later chunks continue; the 1 000-entity synchronous threshold throws `PayloadTooLargeException`; async submission writes a payload artifact under `Config::$artifactDir`, inserts a `stardust_import_jobs` row, and returns an `ImportJobId`; retrying with the same `(tenant_id, idempotency_key)` returns the existing job ID; `tenant_id <= 0` is rejected before any SQL. `getImportJob()` resolves that ID back to a job: it is tenant-isolated (returns `null` for a cross-tenant or missing ID rather than throwing), its `entriesWritten` / `chunks` are checked against a **real** work-source drain rather than a hand-seeded row, a failed job still reports the last committed `entriesWritten` as the replay boundary, and a job that failed before any chunk committed reports `null` rather than `0` — the distinction an operator needs to tell "nothing ran" from "ran and wrote nothing". @@ -46,7 +46,7 @@ GitHub Actions runs the same suite on every push, plus a second job that asserts - **Phase 7 — async exports (Chronicler).** `submitExport()` enforces the per-tenant active-job cap atomically (`SELECT … FOR UPDATE` + `INSERT` in one transaction) and emits `export_accepted` (source `export_api`); a 4th concurrent submission for the same tenant throws `ExportJobActiveCapExceededException`. A sibling-session test holds `SELECT … FOR UPDATE` on the tenant's active range while a second submitter runs with `innodb_lock_wait_timeout = 1` — the second submission blocks, surfaces `SQLSTATE 1205`, and proves the cap check is genuinely serialised across sessions (no phantom inserts past the cap). `getExportJob()` is tenant-isolated — returns `null` for cross-tenant or missing job ids. The Chronicler's per-tenant round-robin claim orders pending jobs by `MIN(created_at) GROUP BY tenant_id`, computed at claim time without a materialised column — a single tenant's burst cannot starve another tenant's oldest job. Two-session `SKIP LOCKED` tests prove the claimer skips rows held by a sibling `FOR UPDATE` and routes to the next available row; two parallel claimers never double-claim the same id. Abandoned-claim sweep detects stranded `processing` rows with `heartbeat_at < UTC_TIMESTAMP() - INTERVAL leaseTimeoutSeconds SECOND` and preserves `claimed_at` while overwriting `worker_identity`/`heartbeat_at`. Per ADR 0047 it no longer deletes the prior partial: a worker crashing mid-chunk (simulated by a reflection-bypass PDO that throws on the chunk-commit UPDATE after a prior chunk has genuinely committed) leaves the artifact holding uncommitted bytes past the last verified anchor, and the re-claimer's stream `ftruncate`s back to that anchor and resumes cleanly — proven end to end by asserting the *final* artifact contains every row exactly once (both CSV and JSON), not merely that the job reached `completed`. Every anchor-rejection cause is covered individually — a missing file, one shorter than the claimed byte count, a stale CSV header (a field renamed between attempts), and a path another handle still holds an exclusive lock on — each falling back to a fresh artifact at a different path rather than reusing (and risking corrupting) the contested one, with `artifact_resumed{restart_cause}` naming which happened. A two-worker interaction test drives a real lease-loss (a second `worker_identity` write racing the first worker's own chunk-commit) and asserts the loser's artifact survives untouched for the winner to adopt and complete. `bytesWritten()` is proven to seed from the anchor rather than reset, so the cumulative artifact-size cap stays honest across a resume. Lease-loss self-detection at every chunk commit (`WHERE worker_identity = self`, `rowCount() == 0` ⇒ `lease_lost`, releases the file lock, does NOT delete, no terminal-state mutation). End-to-end CSV happy path covers RFC 4180 quoting (comma, double-quote, CR, LF), `\r\n` line terminator, header derived alphabetically from `stardust_fields`, and embedded-NUL → `row_skipped{format_invalid}`. End-to-end JSON happy path validates the streamed single-document array (leading `[`, `,`-prefix for subsequent rows, trailing `]`, exactly `n-1` commas for `n` rows, round-trip through `json_decode`). Three-deadlock budget per chunk → `chunk_skipped{cause:deadlock_budget_exhausted}` + cursor advance + `skip_count += pageSize` (simulated by a reflection-bypass `DeadlockInjectingPdo` that wraps the test connection and throws `SQLSTATE 40001` on the `entry_data` SELECT); skip-cap (1 000) trip → `failed:excessive_skips`; ENOSPC short-write during artifact append (exercised via a `failwrite://` stream wrapper that returns 0 on every `fwrite`) → `failed:disk_full` + `job_failed{reason:disk_full}` — also covers the header-write disk-full path on `stream->open()`; partial-artifact bytes > 5 GB cap → `artifact_oversized` (distinct event) + `failed:artifact_size_exceeded`; idle ticks GC TTL'd completed artifacts (24 h) and orphaned failed-job partials (1 h); pre-claim disk gate emits `low_disk` and skips new claims while in-flight jobs continue. **The gate's ADR 0051 write probe is covered by thirteen tests in `ChroniclerDiskPressureGateTest`**, isolated using the same `failwrite://` wrapper (now shared from `tests/Smoke/Support/`, with a `$failAt` stage selector) — `disk_free_space()` is not stream-wrapper aware, so it returns false for such a path and the ratio check reads `null`, meaning any trip must have come from the probe and the previously-uncovered null-ratio fail-open branch is exercised at the same time. Covered: each failure stage (`mkdir`/`open`/`write`/`flush`) naming itself in `probe_stage` and failing closed; the ratio short-circuit proven by asserting `scandir()` is unchanged and no probe file was ever created; `probeBytes: 0` restoring ratio-only fail-open *and* taking no filesystem side effect (the directory is not created); the gate naming the directory it actually measured rather than a `sys_get_temp_dir()` fallback; a passing probe leaving no file behind; and two consecutive samples using distinct paths, which is the honest testable form of the multi-worker uniqueness property (a same-name collision cannot be interleaved single-threaded). `ChroniclerGcSweepTest` adds the third GC bucket: a stale probe file is swept and reported as `probes_deleted`, a fresh one belonging to another worker mid-tick survives, an `export_*.csv` decoy is untouched, and `artifacts_deleted` never absorbs a probe. `tests/Smoke/ArtifactDirectoryTest` (DB-free) covers the extracted `Support\ArtifactDirectory`. Tenant isolation is enforced by the pager's `WHERE tenant_id = ? AND model_id = ? AND deleted_at IS NULL` predicate; soft-deleted rows never appear in artifacts. The closed-vocabulary guard scans `src/Chronicler/` and `src/Export/` for `'event' => '...'` literals — adding an unallowlisted name fails CI. - **Cooperative yield (ADR 0050), `tests/Smoke/Chronicler/ChroniclerYieldTest` — 8 tests.** An always-yielding signal commits exactly one chunk and returns the row to `pending` with `worker_identity` NULL, `completed_at` NULL, and cursor/bytes/path/`skip_count` all reflecting that one committed chunk — no more, no less. The load-bearing pair: a job driven to completion by yielding at every chunk boundary, one independent re-claim at a time, produces an artifact **byte-identical** to a control run of the same data that never yielded — proven for both CSV and JSON, since a weaker "reached `completed`" assertion would pass even against a resume that restarted from byte zero, and only the JSON case would have caught the capture-before-`close()` ordering bug (`JsonArtifactStream::close()` writes the trailing `]` through the same `writeRaw()` that increments `bytesWritten()`, so capturing the anchor after `close()` would have committed a byte count that included the terminator). A real re-claim of a yielded job reports `ClaimKind::Resumed` (not `Pending`) and, once completed, an `artifact_resumed` with a positive `resumed_from_byte` and a **null** `restart_cause` — the yield's stronger-than-abandoned guarantee, since the lock is released before the row becomes claimable and a genuine resumer therefore never sees `restart_cause: 'locked'`. A single-chunk job proves the yield signal is never even consulted when the only chunk is also the final one (`ScriptedYieldSignal::callCount() === 0`). A yield racing a genuine concurrent re-claim (the same `ReclaimSimulatingPdo`-style side-channel trick `ChroniclerResumeTest` uses, landing on the yield-commit UPDATE specifically) resolves to `JobOutcome::LeaseLost` with the artifact left on disk — proving the yield commit's `WHERE worker_identity = self` predicate is the SAME lease-loss detector every other chunk commit already has, not a new one. Deleting the anchor file between two yields (simulating disk cleanup or an operator mistake) still converges to a complete, exactly-once artifact via the fresh-restart fallback, with `restart_cause: 'missing'`. And `job_yielded`'s `correlation_id` is proven to match the submission's own id, driven through the real `Chronicler::tick()` rather than a direct `process()` call, so the claim-to-event id threading is exercised end to end rather than merely asserted at the call site. -- **Sort ordering.** A read with no sort returns insertion order and emits a byte-identical pre-sort cursor, so nothing that paginated before changes. Ordering works on every slot family — strings lexically, ints numerically rather than lexicographically, numerics and datetimes by value — plus the two intrinsic targets, entry id and creation time, in both directions. Ties fall back to entry id *in the sort's own direction*, which is what makes each ordering total. Entries with no value for the sort field sort first ascending and last descending and are never dropped from the page, including entries that have no extension-page row at all — the sort's page join is `LEFT`, and that test was validated by switching it to `INNER` and confirming it alone goes red. Sorts on unknown, non-filterable, and `backfilling` fields are rejected pre-flight exactly as the equivalent filters are, and sorting stays tenant-isolated. Walking a sorted result set two rows at a time reproduces the single-page ordering exactly — no row skipped, none repeated — across ties that span a page boundary and across a NULL block wider than one page; deleting the keyset predicate's NULL branch makes an ascending walk stop dead at the end of that block, which is how the branch is known to be load-bearing. Strings that differ only past MySQL's 1024-byte `max_sort_length` still order and paginate correctly, since ordering on a TEXT slot proved exact on 8.0.13 rather than truncated. A cursor replayed under a different sort key, or the same key reversed, is refused with a typed error instead of silently walking a different sequence. SQL-shape tests pin the rest: the sort join appears under the `EXISTS` strategy too, reuses a page the filter already joined, and the anchor lookup plus both keyset directions carry the tenant predicate. +- **Sort ordering.** A read with no sort returns insertion order and emits a byte-identical pre-sort cursor, so nothing that paginated before changes. Ordering works on every slot family — strings lexically, ints numerically rather than lexicographically, numerics and datetimes by value — plus the two intrinsic targets, entry id and creation time, in both directions. Ties fall back to entry id *in the sort's own direction*, which is what makes each ordering total. Entries with no value for the sort field sort first ascending and last descending and are never dropped from the page, including entries that have no extension-page row at all — the sort's page join is `LEFT`, and that test was validated by switching it to `INNER` and confirming it alone goes red. Sorts on unknown, non-filterable, and `backfilling` fields are rejected pre-flight exactly as the equivalent filters are, and sorting stays tenant-isolated. Walking a sorted result set two rows at a time reproduces the single-page ordering exactly — no row skipped, none repeated — across ties that span a page boundary and across a NULL block wider than one page; deleting the keyset predicate's NULL branch makes an ascending walk stop dead at the end of that block, which is how the branch is known to be load-bearing. Strings that differ only past the 1024-byte `max_sort_length` default still order and paginate correctly on both engines, since ordering on a TEXT slot proved exact on MySQL 8.0.13 at the default setting, and MariaDB — which genuinely truncates there, collation-independently — gets that setting raised to the full string-slot bound at connection time; `MysqlNativeDriverServerTuningTest` pins the mechanism per engine, and the truncation-then-fix was neutered and confirmed to reproduce the original scan-order defect before being trusted. MariaDB carries one separate, accepted divergence no test asserts a fix for: a field sort orders supplementary-plane characters (emoji, mainly) at the opposite end from MySQL — see the README's Requirements section. A cursor replayed under a different sort key, or the same key reversed, is refused with a typed error instead of silently walking a different sequence. SQL-shape tests pin the rest: the sort join appears under the `EXISTS` strategy too, reuses a page the filter already joined, and the anchor lookup plus both keyset directions carry the tenant predicate. - **Phase 8 — search driver & JSON query filter.** The `JsonFilterDecoder` turns a JSON wire payload into the closed filter AST (twelve leaf operators; full AND/OR/NOT composition), deduplicates `in` / `nin` arrays at decode time, and enforces the closed 13-code error taxonomy with RFC 6901 JSON Pointers plus payload-size, nesting-depth, node-count, argument-count, and string-length bounds — the decoder never consults the registry, so it is reusable in offline tooling. The three-stage pre-flight pipeline (field resolution → driver capability → value-type validation) rejects unknown, non-filterable, unsupported-operator, and type-mismatched filters before any SQL is issued, emitting `pre_flight_rejected` / `capability_unsupported`. The adaptive `SqlFilterCompiler` keeps pure-AND trees on the Phase 4 INNER-JOIN-per-page execution shape and switches to `EXISTS` / `NOT EXISTS` subqueries for any subtree containing OR or NOT, with tenant isolation enforced on both strategies; OR/NOT filters round-trip end-to-end against real data. A custom `EntrySearchInterface` driver injected through `Config` is invoked in place of the default `MysqlNativeDriver`, and every `search()` call emits one `search_request` event carrying `latency_ms`, `rows_returned`, `has_more`, `tree_node_count`, and `compile_strategy`. The closed-vocabulary guard extends to scan `src/Search/` and `src/Filter/`. - **Datetime filter bounds.** The UTC offset a `datetime` bound is required to carry is now applied rather than discarded: an instant written with a non-zero offset matches a filter for that same literal (it did not before — the write path normalised and the filter path did not), and `10:00+07:00` selects the row holding `03:00Z` rather than the one holding `10:00Z`. The `Z` form, which was correct only because dropping a zero offset changes nothing, still is. `between`, `in`, and `lt` are covered separately from `eq` because they reach SQL through the compiler's list path rather than its scalar one. A fractional bound is honoured rather than floored, so `lt '…10:00:00.5Z'` still matches a stored `10:00:00`; and the naive form stays rejected with `value_type_mismatch`. Normalisation happens in two layers and **either one alone fixes the MySQL result set**, so the behavioural cases were validated by neutering *both* (four go red) and the fractional case by flooring the bound. Because of that, each layer carries its own pin: the tree leaving pre-flight is asserted to hold canonical UTC RFC 3339 rather than a MySQL literal, since that is what a third-party driver receives, and the compiler's fragment is asserted to bind the MySQL literal rather than the RFC 3339 form — the conversion that keeps `Warning 1292` out of the emitted SQL. That second one asserts on the binding rather than on `SHOW WARNINGS`, which the suite's native-prepare connection cannot read back: the statement is unsupported in that protocol and replaces the list it was going to read. The warning's absence was verified directly against 8.0.13 instead. diff --git a/composer.json b/composer.json index 8607704..fe3b56c 100644 --- a/composer.json +++ b/composer.json @@ -1,11 +1,12 @@ { "name": "damarbob/stardust", - "description": "MySQL-native, framework-neutral Vertical Schema Partitioning engine for dynamic data models. Zero runtime framework dependencies.", + "description": "MySQL & MariaDB-native, framework-neutral Vertical Schema Partitioning engine for dynamic data models. Zero runtime framework dependencies.", "type": "library", "license": "MIT", "keywords": [ "stardust", "mysql", + "mariadb", "schema-partitioning", "dynamic-fields", "json", diff --git a/docker/seed.php b/docker/seed.php index 3d7bc33..7432989 100644 --- a/docker/seed.php +++ b/docker/seed.php @@ -61,7 +61,7 @@ )->fetchColumn()) > 0; if (! $alreadySeeded) { - (new PageProvisioner($pdo, $engine->config()->clock, $engine->logger())) + (new PageProvisioner($pdo, $engine->config()->clock, $engine->logger(), $engine->serverEngine())) ->provision(filterableSlots: ['i_str_01', 'i_str_02', 'i_int_01', 'i_dt_01']); $reserver = new SlotReserver($pdo, $engine->config()->clock, $engine->logger()); diff --git a/docs/deployment.md b/docs/deployment.md index 08742d9..df19892 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -7,7 +7,7 @@ StarDust ships two ways to keep its slot machinery healthy: four persistent back A supported persistent-process deployment target MUST provide all of the following. 1. **Persistent background processes or long-running containers** — systemd, supervisor, Docker / Kubernetes / ECS, or equivalent. -2. **MySQL 8.0.13+ or Percona 8.0.13+** (also covered by [Requirements](../README.md#requirements)). MariaDB is not supported at any version — StarDust detects it and refuses to run. +2. **MySQL 8.0.13+ or Percona 8.0.13+, or MariaDB 10.11+** (also covered by [Requirements](../README.md#requirements)). MariaDB 10.6 and older is rejected — StarDust detects the server and version at boot and refuses to run below either floor. 3. **PHP 8.x with CLI access** for the `bin/stardust` entry point. 4. **Local filesystem write access** for the Chronicler's async export artifacts (a mounted volume in container deployments). 5. **PID-file or orchestrator-level singleton enforcement for the Watcher** — the in-database advisory lock is a safety net, not the primary enforcement mechanism. The Liberator is multi-worker (page-table-granularity `GET_LOCK` exclusion, not a process singleton) — run as many `bin/stardust liberator` processes as you want reclaim throughput. @@ -18,14 +18,14 @@ A supported persistent-process deployment target MUST provide all of the followi | :--- | :--- | | Free shared hosting (no shell, no cron, no persistent processes, no scheduled URL fetch) | Unsupported at any level. | | Free shared hosting with a scheduled URL fetch (no shell, no cron) | See **Cron-only / shared hosting** below — drive `StarDust::tick()` from the URL fetch instead of a crontab line. | -| Paid shared hosting, cron-only, MySQL 8 | See **Cron-only / shared hosting** below. | -| Paid shared hosting, cron-only, MariaDB | Unsupported — MariaDB is rejected regardless of deployment mode. | +| Paid shared hosting, cron-only, MySQL 8 or MariaDB 10.11+ | See **Cron-only / shared hosting** below. | +| Paid shared hosting, cron-only, MariaDB ≤ 10.6 | Unsupported — rejected regardless of deployment mode. | | VPS with systemd / supervisor | Supported — reference deployment. | | Containerized (Docker Compose, Kubernetes, ECS) | Supported — recommended for production at scale. | ## Cron-only / shared hosting -**The real exclusion is shell-less and cron-less hosting, not "shared hosting" as a category.** A cPanel-style account with a real MySQL 8 database and a crontab is a supported target, including async exports with `--exports` (below); the exclusion above only bites a host with none of those. And **shared hosting commonly means MariaDB**, which StarDust rejects outright at boot regardless of deployment mode — check with your host before anything else here. This section is written for the MySQL-8 slice of shared hosting; if your host only offers MariaDB, none of it applies to you. +**The real exclusion is shell-less and cron-less hosting, not "shared hosting" as a category.** A cPanel-style account with a real MySQL 8 (or MariaDB 10.11+) database and a crontab is a supported target, including async exports with `--exports` (below); the exclusion above only bites a host with none of those. **Shared hosting commonly means MariaDB** — check the version with your host: 10.11 or newer works exactly as described in this section, while 10.6 or older is rejected outright at boot regardless of deployment mode. `bin/stardust tick` runs the Watcher, Liberator and Reconciler as one bounded pass over a single database connection, stopping when it runs out of work, runs out of its time budget, or is asked to shut down. One cron line replaces the four persistent daemons above — which matters beyond convenience: four permanently-resident daemon processes hold four MySQL connections continuously, and shared hosts commonly cap the account's total connections in the low tens, shared with the site itself. diff --git a/docs/reading-entries.md b/docs/reading-entries.md index f599627..83d208f 100644 --- a/docs/reading-entries.md +++ b/docs/reading-entries.md @@ -98,10 +98,11 @@ SortSpec::byField('price', SortDirection::Desc); Sorting composes with filters and with cursor pagination — keep passing the `nextCursor` back as usual. -Three things worth knowing: +Four things worth knowing: - **Only indexed fields are sortable.** A field must be declared filterable and hold a live slot, the same requirement filtering has. Sorting on anything else raises `FieldNotSortableException`, and on an unregistered name `UnknownFieldException`. `describeModel()` reports which fields qualify right now via `ModelDescription::indexedFields()`. - **Entries with no value for the sort field sort first ascending, last descending** — they are not dropped from the page. - **A cursor belongs to the ordering that produced it.** Change the sort key or its direction and the old cursor is refused with `InvalidCursorException`; start again from the first page. This is a guard, not a limitation to work around — reusing it would silently walk a different sequence. +- **On MariaDB, a field sort orders supplementary-plane characters** — mostly emoji, well outside everyday text — **at the opposite end from MySQL.** Every other comparison, and ordinary text in any language, sorts identically on both engines. Sorting by `id` or by creation time costs nothing extra. Sorting by one of your own fields makes the database order the whole matching set on each page, so it is measurably more expensive on large models — prefer the built-in orderings when either will do. diff --git a/examples/README.md b/examples/README.md index 00addba..c06b1d3 100644 --- a/examples/README.md +++ b/examples/README.md @@ -14,8 +14,8 @@ people. ## Setup -Any MySQL 8.0.13+ database you do not mind writing to. The quickest is -the one from the repo's Compose file: +Any MySQL 8.0.13+ or MariaDB 10.11+ database you do not mind writing to. +The quickest is the one from the repo's Compose file: ```bash docker compose up mysql -d @@ -54,7 +54,10 @@ of rows and call `deleteModel()`, which physically deletes `entry_data` rows with no undo. Do not aim them at the smoke suite's database, or at anything you would miss. -MariaDB will not work — the engine detects it and refuses to boot. +MariaDB 10.11+ works too, with one caveat: range filters and field sorts +order supplementary-plane characters (mostly emoji) at the opposite end +from MySQL — see the README's Requirements section. Older MariaDB +(≤ 10.6) will not run at all — the engine detects it and refuses to boot. The scripts tick the daemons **in-process**, so you do not need to start any. That is a teaching device, not how you would deploy: in production diff --git a/src/Bootstrap/Bootstrapper.php b/src/Bootstrap/Bootstrapper.php index b92c271..8ed41fc 100644 --- a/src/Bootstrap/Bootstrapper.php +++ b/src/Bootstrap/Bootstrapper.php @@ -8,6 +8,7 @@ use PDOException; use StarDust\Support\Dialect; use StarDust\Support\PdoQuery; +use StarDust\Support\ServerEngine; /** * Phase 1 migration runner. @@ -17,16 +18,26 @@ * safe on an already-bootstrapped database (no-op, no duplicate-table * errors, no data destruction). * - * Normative references: registry contract (ADR 0017), MySQL 8.0.13+ floor - * (ADR 0023) — the partial unique index on stardust_slot_assignments uses - * 8.0.13+ functional-index syntax — and the schema reference (§1–§5), - * which is the source of truth for column shapes, indexes, and atomicity - * invariants implemented here. + * Normative references: registry contract (ADR 0017), MySQL 8.0.13+ / + * MariaDB 10.11+ floor (ADR 0023, ADR 0054) — the live-slot invariant on + * `stardust_slot_assignments` uses 8.0.13+ functional-index syntax on + * MySQL and a generated-column substitute on MariaDB — and the schema + * reference (§1–§5), which is the source of truth for column shapes, + * indexes, and atomicity invariants implemented here. + * + * `$engine` (ADR 0055) is required rather than defaulted: every + * `CREATE TABLE` in this class ends with {@see Dialect::tableOptionsClause()}, + * so silently assuming MySQL here is exactly the class of bug detection + * exists to rule out. `StarDust::bootstrap()` resolves it via + * `StarDust::serverEngine()`; nothing constructs a `Bootstrapper` without + * first knowing which engine it is talking to. */ final class Bootstrapper { - public function __construct(private readonly PDO $pdo) - { + public function __construct( + private readonly PDO $pdo, + private readonly ServerEngine $engine, + ) { } public function run(): void @@ -44,6 +55,7 @@ public function run(): void $this->createBackfillCheckpoints(); $this->createAdvisorySchedule(); + $this->ensureSlotAssignmentLiveFieldIdColumn(); $this->ensureSlotAssignmentFieldLiveUniqueIndex(); $this->ensureSlotAssignmentSweepGapColumn(); $this->ensureBackfillCheckpointsSourceTypeColumn(); @@ -76,7 +88,7 @@ private function createEntryData(): void KEY ix_entry_data_tenant_model (tenant_id, model_id), KEY ix_entry_data_tenant_lifecycle (tenant_id, deleted_at, created_at) ) - SQL . ' ' . Dialect::tableOptionsClause()); + SQL . ' ' . Dialect::tableOptionsClause($this->engine)); } private function createSyncQueue(): void @@ -99,7 +111,7 @@ private function createSyncQueue(): void created_at DATETIME NOT NULL, PRIMARY KEY (id) ) - SQL . ' ' . Dialect::tableOptionsClause()); + SQL . ' ' . Dialect::tableOptionsClause($this->engine)); } private function createModels(): void @@ -114,7 +126,7 @@ private function createModels(): void PRIMARY KEY (id), UNIQUE KEY ux_models_tenant_name (tenant_id, name) ) - SQL . ' ' . Dialect::tableOptionsClause()); + SQL . ' ' . Dialect::tableOptionsClause($this->engine)); } private function createFields(): void @@ -134,7 +146,7 @@ private function createFields(): void FOREIGN KEY (model_id) REFERENCES stardust_models (id) ON DELETE CASCADE ) - SQL . ' ' . Dialect::tableOptionsClause()); + SQL . ' ' . Dialect::tableOptionsClause($this->engine)); } private function createPages(): void @@ -148,7 +160,7 @@ private function createPages(): void PRIMARY KEY (id), UNIQUE KEY ux_pages_table_name (table_name) ) - SQL . ' ' . Dialect::tableOptionsClause()); + SQL . ' ' . Dialect::tableOptionsClause($this->engine)); } private function createSlotAssignments(): void @@ -179,7 +191,7 @@ private function createSlotAssignments(): void CONSTRAINT fk_slot_assignments_field FOREIGN KEY (field_id) REFERENCES stardust_fields (id) ) - SQL . ' ' . Dialect::tableOptionsClause()); + SQL . ' ' . Dialect::tableOptionsClause($this->engine)); } private function createSchemaVersion(): void @@ -200,7 +212,7 @@ private function createSchemaVersion(): void PRIMARY KEY (id), CONSTRAINT ck_schema_version_singleton CHECK (id = 1) ) - SQL . ' ' . Dialect::tableOptionsClause()); + SQL . ' ' . Dialect::tableOptionsClause($this->engine)); } private function createExportJobs(): void @@ -228,7 +240,7 @@ private function createExportJobs(): void KEY ix_export_jobs_status_heartbeat (status, heartbeat_at), KEY ix_export_jobs_completed (completed_at) ) - SQL . ' ' . Dialect::tableOptionsClause()); + SQL . ' ' . Dialect::tableOptionsClause($this->engine)); } /** @@ -266,7 +278,7 @@ private function createImportJobs(): void KEY ix_import_jobs_tenant_status (tenant_id, status), KEY ix_import_jobs_status_heartbeat (status, heartbeat_at) ) - SQL . ' ' . Dialect::tableOptionsClause()); + SQL . ' ' . Dialect::tableOptionsClause($this->engine)); } private function createReconcilerDlq(): void @@ -289,7 +301,7 @@ private function createReconcilerDlq(): void KEY ix_dlq_source_failed_at (source, failed_at), KEY ix_dlq_entry (entry_id) ) - SQL . ' ' . Dialect::tableOptionsClause()); + SQL . ' ' . Dialect::tableOptionsClause($this->engine)); } private function createBackfillCheckpoints(): void @@ -309,7 +321,7 @@ private function createBackfillCheckpoints(): void UNIQUE KEY ux_backfill_job_name (job_name), KEY ix_backfill_status_updated (status, updated_at) ) - SQL . ' ' . Dialect::tableOptionsClause()); + SQL . ' ' . Dialect::tableOptionsClause($this->engine)); } /** @@ -346,23 +358,83 @@ private function createAdvisorySchedule(): void PRIMARY KEY (id), CONSTRAINT ck_advisory_schedule_singleton CHECK (id = 1) ) - SQL . ' ' . Dialect::tableOptionsClause()); + SQL . ' ' . Dialect::tableOptionsClause($this->engine)); + } + + /** + * MariaDB-only. Creates `live_field_id`, the `PERSISTENT` generated + * column {@see Dialect::liveSlotUniqueIndexDdl()}'s MariaDB branch + * indexes (ADR 0054 §4). MySQL enforces the ADR 0017 invariant with + * a functional index directly over the `CASE` expression and needs + * no such column, so this is a no-op there. + * + * A single-caller literal rather than a `Dialect` method — unlike + * the index DDL, this construct has exactly one call site, below + * the more-than-one-package threshold {@see Dialect}'s own docblock + * sets for living there. Same idempotency shape as every other + * `ensureXxx` column in this class: probe `information_schema.COLUMNS` + * first, defensively swallow MySQL/MariaDB's shared `ER_DUP_FIELDNAME` + * (1060) if a stale connection cache lets the probe miss a column the + * engine still holds. + * + * Verified on MariaDB 10.6/10.11/11: the exact DDL text below creates + * cleanly, and the follow-up `UNIQUE` index + * {@see self::ensureSlotAssignmentFieldLiveUniqueIndex()} builds over + * it allows a second *tombstoned* slot for one field while refusing a + * second *live* one with SQLSTATE 23000 — matching MySQL's functional + * index exactly. + */ + private function ensureSlotAssignmentLiveFieldIdColumn(): void + { + if ($this->engine !== ServerEngine::MARIADB) { + return; + } + + $exists = (int) PdoQuery::run($this->pdo, <<<'SQL' + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE table_schema = DATABASE() + AND table_name = 'stardust_slot_assignments' + AND column_name = 'live_field_id' + SQL)->fetchColumn(); + + if ($exists > 0) { + return; + } + + try { + $this->pdo->exec(<<<'SQL' + ALTER TABLE stardust_slot_assignments + ADD COLUMN live_field_id BIGINT + GENERATED ALWAYS AS ( + CASE WHEN status IN ('assigned', 'backfilling', 'ready') + THEN field_id END + ) PERSISTENT + SQL); + } catch (PDOException $e) { + if (! $this->isDuplicateFieldName($e)) { + throw $e; + } + } } /** * Implements ADR 0017's "at most one live slot per field" invariant. - * The DDL text itself — a MySQL 8.0.13+ functional unique index — - * lives in {@see Dialect::liveSlotUniqueIndexDdl()}, which is also - * where its CASE-expression semantics are documented. - * - * MySQL has no CREATE INDEX IF NOT EXISTS, so we self-check via - * information_schema to stay idempotent across re-runs. The follow-up - * catch on SQLSTATE 42000 / 1061 is defense in depth: a stale - * information_schema cache on the connection can let the probe miss an - * index that the storage engine still holds — without the catch, an - * otherwise-correct re-bootstrap would explode on the duplicate-name - * collision. Treating it as "already there" matches the table-level - * `IF NOT EXISTS` semantics every other DDL in this runner uses. + * The DDL text itself — a MySQL 8.0.13+ functional unique index, or + * a plain `UNIQUE` index over `live_field_id` on MariaDB — lives in + * {@see Dialect::liveSlotUniqueIndexDdl()}, which is also where its + * CASE-expression semantics are documented. On MariaDB this runs + * after {@see self::ensureSlotAssignmentLiveFieldIdColumn()}, which + * the index depends on. + * + * Neither engine has `CREATE INDEX IF NOT EXISTS`, so we self-check + * via information_schema to stay idempotent across re-runs. The + * follow-up catch on SQLSTATE 42000 / 1061 is defense in depth: a + * stale information_schema cache on the connection can let the probe + * miss an index that the storage engine still holds — without the + * catch, an otherwise-correct re-bootstrap would explode on the + * duplicate-name collision. Treating it as "already there" matches + * the table-level `IF NOT EXISTS` semantics every other DDL in this + * runner uses. */ private function ensureSlotAssignmentFieldLiveUniqueIndex(): void { @@ -378,9 +450,10 @@ private function ensureSlotAssignmentFieldLiveUniqueIndex(): void } try { - $this->pdo->exec(Dialect::liveSlotUniqueIndexDdl()); + $this->pdo->exec(Dialect::liveSlotUniqueIndexDdl($this->engine)); } catch (PDOException $e) { - // MySQL ER_DUP_KEYNAME = 1061. We only swallow this one + // MySQL ER_DUP_KEYNAME = 1061; MariaDB reports the same code + // for the plain UNIQUE index branch. We only swallow this one // — anything else (permissions, syntax, connection) must // surface so the bootstrap genuinely fails fast. if (! $this->isDuplicateKeyName($e)) { diff --git a/src/Exception/UnsupportedServerException.php b/src/Exception/UnsupportedServerException.php new file mode 100644 index 0000000..f2a0842 --- /dev/null +++ b/src/Exception/UnsupportedServerException.php @@ -0,0 +1,24 @@ +provisionerIdentity = $provisionerIdentity @@ -314,7 +325,7 @@ private function buildPageDdl(string $tableName, array $filterableSlots): string // prefix needs the 3072-byte key limit; COMPACT/REDUNDANT cap at 767 // bytes and would fail CREATE TABLE with errno 1071 on servers whose // innodb_default_row_format is not dynamic. - $lines[] = ') ' . Dialect::tableOptionsClause() . ' ROW_FORMAT=DYNAMIC'; + $lines[] = ') ' . Dialect::tableOptionsClause($this->engine) . ' ROW_FORMAT=DYNAMIC'; return implode("\n", $lines); } diff --git a/src/Reconciler/CLAUDE.md b/src/Reconciler/CLAUDE.md index ed916bb..d77a27e 100644 --- a/src/Reconciler/CLAUDE.md +++ b/src/Reconciler/CLAUDE.md @@ -86,6 +86,8 @@ Each window's transaction writes the running `manifest` **and** `heartbeat_at` v This is reliable because `manifest.entries_written` strictly increases, so a matched row is always *changed* — `rowCount()===0` can only mean an identity mismatch, never a no-op update. +**`rowCount()===0` is not the only shape this takes.** Verified against a real MariaDB 11 container (2026-09-21): the checkpoint UPDATE can throw SQLSTATE HY000 errno 1020 ("Record has changed since last read in table") for the identical race instead of matching zero rows — root-caused to `manifest` being a JSON column, which MariaDB backs with an implicit `CHECK (json_valid(manifest))` that collides with InnoDB's semi-consistent read for this statement's `WHERE` clause under concurrent modification. MySQL and MariaDB 10.11 never raise it here; the Chronicler's equivalent checkpoints update no JSON column and don't hit it either. `ImportJobWorkSource::isRowChangedSinceLastRead()` gives errno 1020 the same lease-lost treatment as `rowCount()===0` rather than letting it fall through to the generic `catch (Throwable)` and `failJob()`. Dated addendum in ADR 0040's Consequences section. + ### The manifest (ADR 0011 §26, shaped by ADR 0040) `{chunks, entries_written}` is the resume checkpoint above. `chunk_manifest` is the per-chunk enumeration §26 requires: one record per chunk carrying `index`, `size`, `outcome`, and the chunk's `entry_id_first` / `entry_id_last`. The write path already had the ids — `writeWithinTransaction()` returns an `EntryWriteResult` — so the records cost no extra query. diff --git a/src/Reconciler/ImportJobWorkSource.php b/src/Reconciler/ImportJobWorkSource.php index 76df1cb..26eeb14 100644 --- a/src/Reconciler/ImportJobWorkSource.php +++ b/src/Reconciler/ImportJobWorkSource.php @@ -657,23 +657,37 @@ private function writeWindow( . ' SET heartbeat_at = ?, manifest = ?' . ' WHERE id = ? AND worker_identity = ?' ); - $checkpoint->execute([$this->utcNow(), $manifest, $jobId, $workerIdentity]); + try { + $checkpoint->execute([$this->utcNow(), $manifest, $jobId, $workerIdentity]); + } catch (PDOException $e) { + // MariaDB 11 only — verified against a real container. + // This exact statement can throw SQLSTATE HY000 errno + // 1020 ("Record has changed since last read in table") + // instead of matching zero rows. Root cause: `manifest` + // is a JSON column, and MariaDB attaches an implicit + // `CHECK (json_valid(manifest))` to every JSON column; + // that constraint's row re-check collides with InnoDB's + // semi-consistent read for this statement's `WHERE ... + // AND worker_identity = ?` when a sibling connection + // changes the row between read and write. MySQL, + // MariaDB 10.11, and the Chronicler's equivalent + // checkpoints (which update no JSON column) never raise + // this. The failure means exactly what `rowCount() === + // 0` below means — the row no longer matches our + // identity — so it gets the identical lease-lost + // treatment rather than falling through to failJob(). + if (self::isRowChangedSinceLastRead($e)) { + return $this->leaseLost($jobId, $tenantId, $chunkCorrelationId); + } + + throw $e; + } if ($checkpoint->rowCount() === 0) { // Lease lost — roll back this chunk's writes so the // re-claimer's copy is authoritative, and stop WITHOUT // failing the row (the re-claimer owns terminal state, // per schema_reference §5.5 / ADR 0025). - $this->pdo->rollBack(); - $this->logger->warning('import_job lease lost', [ - 'event' => 'lease_lost', - 'source' => 'reconciler', - 'correlation_id' => $chunkCorrelationId, - 'queue' => 'import_jobs', - 'job_id' => $jobId, - 'tenant_id' => $tenantId, - ]); - - return self::WINDOW_LEASE_LOST; + return $this->leaseLost($jobId, $tenantId, $chunkCorrelationId); } $this->pdo->commit(); @@ -746,6 +760,51 @@ private function writeWindow( } } + /** + * Shared tail of both lease-loss detection paths in {@see self::writeWindow()} + * — a `rowCount() === 0` match and the MariaDB-11-only errno 1020 case + * {@see self::isRowChangedSinceLastRead()} documents. Rolls back this + * chunk's writes so the re-claimer's copy is authoritative, and stops + * WITHOUT failing the row (the re-claimer owns terminal state, per + * schema_reference §5.5 / ADR 0025). + */ + private function leaseLost(int $jobId, int $tenantId, string $chunkCorrelationId): string + { + $this->pdo->rollBack(); + $this->logger->warning('import_job lease lost', [ + 'event' => 'lease_lost', + 'source' => 'reconciler', + 'correlation_id' => $chunkCorrelationId, + 'queue' => 'import_jobs', + 'job_id' => $jobId, + 'tenant_id' => $tenantId, + ]); + + return self::WINDOW_LEASE_LOST; + } + + /** + * MariaDB 11 only — verified against a real container on 2026-09-21; + * MySQL and MariaDB 10.11 never raise this for the same statement. + * Errno 1020 / SQLSTATE HY000, "Record has changed since last read + * in table". Root cause: `manifest` is a JSON column, and MariaDB + * attaches an implicit `CHECK (json_valid(manifest))` to every JSON + * column; that constraint's row re-check collides with InnoDB's + * semi-consistent read for the checkpoint UPDATE's `WHERE ... AND + * worker_identity = ?` when a sibling connection changes the row + * between read and write. The Chronicler's equivalent checkpoints + * update no JSON column and were verified not to hit this. + */ + private static function isRowChangedSinceLastRead(PDOException $e): bool + { + $info = $e->errorInfo; + if (! is_array($info) || ! isset($info[1])) { + return false; + } + + return (int) $info[1] === 1020; + } + /** * `$failedRecord` is the terminal chunk record to append, or null * when the job failed before producing any chunk at all. diff --git a/src/Rename/CLAUDE.md b/src/Rename/CLAUDE.md index 04f6e0f..8a1c565 100644 --- a/src/Rename/CLAUDE.md +++ b/src/Rename/CLAUDE.md @@ -64,7 +64,7 @@ The retype-side guard sits inside `RetypeInitiator::runTuple()`, **not** on the Nothing deletes a *rename* checkpoint, and `ux_backfill_job_name` is UNIQUE, so a plain INSERT makes the *second* lifecycle for a field throw a raw `PDOException` once the first completes — `existsRunningForField()` returns false for a `completed` row and offers no protection. This repository uses `INSERT … ON DUPLICATE KEY UPDATE` and does not have that defect. -**Update, 2026-08-27.** `RetypeCheckpointRepository` was the last holdout and has now been converted too, so all four `backfill_checkpoints` namespaces upsert. Its version is *not* a copy of this one: it also resets `source_declared_type`, a column no other namespace has, and it needed a `FOR UPDATE OF f` row lock in `RetypeInitiator` that this initiator does not take — see `src/Retype/CLAUDE.md`. A retype's lost race mis-coerces stored data; a rename's resets a cursor. +**Update, 2026-08-27.** `RetypeCheckpointRepository` was the last holdout and has now been converted too, so all four `backfill_checkpoints` namespaces upsert. Its version is *not* a copy of this one: it also resets `source_declared_type`, a column no other namespace has, and it needed a `FOR UPDATE` row lock on the field in `RetypeInitiator` that this initiator does not take — see `src/Retype/CLAUDE.md`. A retype's lost race mis-coerces stored data; a rename's resets a cursor. **Correction, 2026-08-24.** This section used to open "Nothing in the engine ever deletes from `backfill_checkpoints`". That is no longer true: ADR 0037's `DeleteCheckpointRepository` deletes terminal rename/retype rows at deletion initiation, and deletes its own row on the purge's final chunk (`src/Delete/CLAUDE.md`). The conclusion is unaffected — a rename checkpoint is still only ever cleared by a *field deletion*, which refuses to start while a rename is running, so the upsert remains necessary. diff --git a/src/Rename/RenameCheckpointRepository.php b/src/Rename/RenameCheckpointRepository.php index fe6cf3c..608f07d 100644 --- a/src/Rename/RenameCheckpointRepository.php +++ b/src/Rename/RenameCheckpointRepository.php @@ -104,9 +104,9 @@ public function existsRunningForField(int $fieldId): bool * {@see \StarDust\Retype\RetypeCheckpointRepository::insertOrReset()} * was the last convert and is deliberately not a copy of this one — * it must also reset `source_declared_type`, and its initiator holds - * a `FOR UPDATE OF f` row lock this one does not need, because a - * lost retype race mis-coerces stored data where a lost rename race - * only resets a cursor. + * a `FOR UPDATE` row lock on the field this one does not need, + * because a lost retype race mis-coerces stored data where a lost + * rename race only resets a cursor. */ public function insertOrReset(int $fieldId, string $now, ?string $correlationId = null): int { diff --git a/src/Retype/CLAUDE.md b/src/Retype/CLAUDE.md index 5778f56..4f248c0 100644 --- a/src/Retype/CLAUDE.md +++ b/src/Retype/CLAUDE.md @@ -10,9 +10,9 @@ Phase 6b field retype + filterability promotion (ADR 0016, ADR 0024). Five `fina - Rejects ADR 0024 categorical retypes (`int↔datetime`, `numeric↔datetime`) with `IncompatibleRetypeException`. - Refuses overlapping retypes via `RetypeCheckpointRepository::existsRunningForField()` with `RetypeInProgressException`. -**The field read and all four guards run inside the transaction, not ahead of it**, because `loadField()` takes `FOR UPDATE OF f` and that lock is only worth anything while a transaction holds it — in autocommit it would be dropped the instant the SELECT finished. Same structural reason `RenameInitiator::assertNameAvailable()` sits inside its caller's transaction. "Before any mutation" still holds: nothing writes until step 1, so a guard that throws rolls back an empty transaction. +**The field read and all four guards run inside the transaction, not ahead of it**, because `loadField()` takes `FOR UPDATE` on the field row and that lock is only worth anything while a transaction holds it — in autocommit it would be dropped the instant the SELECT finished. Same structural reason `RenameInitiator::assertNameAvailable()` sits inside its caller's transaction. "Before any mutation" still holds: nothing writes until step 1, so a guard that throws rolls back an empty transaction. -`OF f` and not a bare `FOR UPDATE`: the statement joins `stardust_models` only to resolve the tenant, and locking that row too would contend with `deleteModel()` for nothing. Measured on MySQL 8.0.13 — the `OF` clause parses, a concurrent `UPDATE stardust_models` on the joined row proceeds untouched, and a second initiator's identical SELECT serialises. +**Two single-table statements, not one `stardust_fields JOIN stardust_models` with `FOR UPDATE OF f`.** That was the original shape (through the MySQL-only Stage 2/3 MariaDB work, 2026-09-20): it locked only the field row while resolving `tenant_id` from the join, so a concurrent `UPDATE stardust_models` (e.g. `deleteModel()`) would proceed untouched. MariaDB has no `OF` clause at all (errno 1064, measured on 10.6/10.11/11), so the shape had to change rather than merely gain a branch. The replacement locks `stardust_fields` alone with a plain `FOR UPDATE`, then reads `stardust_models.tenant_id` unlocked in a second statement — the same two facts the join established, portably, since a plain `FOR UPDATE` never locks a table it doesn't target. Safe because `stardust_fields.model_id` is immutable once set and `fk_fields_model` guarantees the model row exists, so nothing can invalidate the second read between the two statements. ### Two triggers, three shapes @@ -129,6 +129,6 @@ It is now an upsert, matching `RenameCheckpointRepository::insertOrReset()` and **Two things about it that are not in the siblings:** - **`source_declared_type` is reset with the rest of the row.** No sibling repository has that column, so porting their `ON DUPLICATE KEY UPDATE` verbatim leaves the second lifecycle draining against the *first* one's source type — the wrong ADR 0024 matrix cell, with no event and no exception. Pinned by `RetypeInitiatorTest::testResetCheckpointCarriesTheNewSourceDeclaredType`, validated by neutering. -- **Only terminal rows are relaxed.** A genuinely `running` checkpoint still raises `RetypeInProgressException` from the caller's pre-check, which is why that pre-check now runs under the `FOR UPDATE OF f` lock above. The upsert is what makes that lock load-bearing: without it two initiators could each read `declared_type` from their own snapshot and the loser would silently reset the winner's live checkpoint. +- **Only terminal rows are relaxed.** A genuinely `running` checkpoint still raises `RetypeInProgressException` from the caller's pre-check, which is why that pre-check now runs under the `FOR UPDATE` lock on the field row above. The upsert is what makes that lock load-bearing: without it two initiators could each read `declared_type` from their own snapshot and the loser would silently reset the winner's live checkpoint. **Why the concurrency test asserts on an incompatible retype.** The obvious version — hold the field row from a sibling session, run a normal retype, expect a lock-wait timeout — cannot fail. Measured on 8.0.13: the reservation's `UPDATE stardust_slot_assignments SET field_id = ?` takes an FK shared lock on the parent `stardust_fields` row via `fk_slot_assignments_field`, so a sibling's `FOR UPDATE` blocks any filterable-target initiation at step 3 whether or not `loadField()` locks anything. An ADR 0024 categorical rejection is the one shape that resolves *between* the two points — it throws straight after the read and never reaches the reservation — so the two cases surface as different exception types. `RetypeInitiatorConcurrencyTest` documents this at length; do not "simplify" it back to a timeout assertion. diff --git a/src/Retype/RetypeCheckpointRepository.php b/src/Retype/RetypeCheckpointRepository.php index e3d5dae..75887d6 100644 --- a/src/Retype/RetypeCheckpointRepository.php +++ b/src/Retype/RetypeCheckpointRepository.php @@ -183,7 +183,7 @@ public function statusForField(int $fieldId): ?string * Only *terminal* rows are relaxed. A genuinely concurrent * lifecycle is still refused by the caller's * {@see self::existsRunningForField()} pre-check, which now runs - * under {@see RetypeInitiator}'s `FOR UPDATE OF f` lock on the field + * under {@see RetypeInitiator}'s `FOR UPDATE` lock on the field * row and so cannot interleave with a second initiator. That lock is * what keeps this upsert from silently resetting a live checkpoint. * diff --git a/src/Retype/RetypeInitiator.php b/src/Retype/RetypeInitiator.php index 9fdfc3e..75e7678 100644 --- a/src/Retype/RetypeInitiator.php +++ b/src/Retype/RetypeInitiator.php @@ -167,7 +167,7 @@ private function runTuple( // The field read and all four guards run INSIDE the transaction // that performs the mutation, not before it. `loadField()` takes - // `FOR UPDATE OF f` on the field row, and that lock is only + // `FOR UPDATE` on the field row, and that lock is only // worth anything while a transaction holds it — in autocommit it // would be dropped the instant the SELECT finished, which is the // same reason `RenameInitiator::assertNameAvailable()` sits @@ -376,12 +376,29 @@ private function runTuple( * Reads the field under a row lock, and rejects the three states no * shape may start from. * - * **`FOR UPDATE OF f`, not a bare `FOR UPDATE`.** The statement - * joins `stardust_models` only to resolve the tenant, and locking - * that row too would contend with `deleteModel()` for nothing. - * Verified on MySQL 8.0.13: the `OF` clause parses, a concurrent - * `UPDATE stardust_models` on the joined row proceeds untouched, and - * a second initiator's identical SELECT serialises behind this one. + * **Two single-table statements, not one join with `FOR UPDATE OF f`.** + * The original shape locked only `stardust_fields` in a + * `stardust_fields JOIN stardust_models` read via MySQL 8.0's + * `FOR UPDATE OF ` clause, specifically so a concurrent + * `UPDATE stardust_models` (e.g. `deleteModel()`) would proceed + * untouched. MariaDB has no `OF` clause at all — `SQLSTATE[42000]` + * errno 1064 on the `OF f` token, measured against real MariaDB + * 10.6/10.11/11 containers — so that shape is MySQL-only by + * construction, not merely untested there. + * + * The replacement achieves the identical locking property — lock + * `stardust_fields`, never lock `stardust_models` — without needing + * per-table lock syntax at all: a plain `FOR UPDATE` locks only the + * table it targets, so splitting the join into two statements and + * running the second (`stardust_models`, for `tenant_id` only) + * unlocked reproduces the same two facts the original query + * established, portably. This is safe because `stardust_fields.model_id` + * is immutable once set (no code path ever updates it) and + * `fk_fields_model` guarantees the referenced model row exists, so + * nothing can invalidate the second read between the two statements + * — there is no window for `model_id` to point somewhere else, and + * the field row is already locked against everything else that + * matters (a competing retype/promote/demote/delete initiation). * * The lock only holds because `runTuple()` calls this inside its * transaction — see the note there for what it is defending. @@ -391,11 +408,10 @@ private function runTuple( private function loadField(int $tenantId, int $fieldId): array { $stmt = $this->pdo->prepare( - 'SELECT f.declared_type, f.is_filterable, f.model_id, f.deleted_at, m.tenant_id' - . ' FROM stardust_fields f' - . ' JOIN stardust_models m ON m.id = f.model_id' - . ' WHERE f.id = ?' - . ' FOR UPDATE OF f' + 'SELECT declared_type, is_filterable, model_id, deleted_at' + . ' FROM stardust_fields' + . ' WHERE id = ?' + . ' FOR UPDATE' ); $stmt->execute([$fieldId]); $row = $stmt->fetch(PDO::FETCH_ASSOC); @@ -403,7 +419,12 @@ private function loadField(int $tenantId, int $fieldId): array if ($row === false) { throw new FieldNotFoundException("Field {$fieldId} does not exist."); } - if ((int) $row['tenant_id'] !== $tenantId) { + + $tenantStmt = $this->pdo->prepare('SELECT tenant_id FROM stardust_models WHERE id = ?'); + $tenantStmt->execute([(int) $row['model_id']]); + $modelTenantId = $tenantStmt->fetchColumn(); + + if ((int) $modelTenantId !== $tenantId) { throw new FieldNotFoundException( "Field {$fieldId} does not belong to tenant {$tenantId}." ); diff --git a/src/Search/Mysql/MysqlNativeDriver.php b/src/Search/Mysql/MysqlNativeDriver.php index e3af223..c50bce0 100644 --- a/src/Search/Mysql/MysqlNativeDriver.php +++ b/src/Search/Mysql/MysqlNativeDriver.php @@ -8,6 +8,7 @@ use DateTimeZone; use PDO; use Psr\Log\LoggerInterface; +use StarDust\Filter\Limits\FilterLimits; use StarDust\Filter\Operator; use StarDust\Read\BoundedFetch; use StarDust\Read\CursorCodec; @@ -20,6 +21,8 @@ use StarDust\Search\EntrySearchInterface; use StarDust\Search\SearchRequest; use StarDust\Search\SearchResult; +use StarDust\Support\ServerEngine; +use StarDust\Support\ServerEngineDetector; use StarDust\Support\UuidV4; /** @@ -50,6 +53,18 @@ final class MysqlNativeDriver implements EntrySearchInterface private readonly BoundedFetch $fetch; private readonly ResultAssembler $assembler; + /** + * `$engine` defaults to self-detection (`ServerEngineDetector::detect()`) + * rather than a required parameter — unlike `Bootstrapper` and + * `PageProvisioner`, this class is constructed by the legacy + * `Read\EntryReader` façade too, whose own constructor is + * deliberately frozen at `(PDO, LoggerInterface)` for Phase 4 + * backward compatibility, so it cannot thread one through. Passing + * it explicitly (as `StarDust::searchDriver()` does, via + * `StarDust::serverEngine()`) only avoids a redundant detection + * call; omitting it is safe, per ADR 0055 — detection, not a + * config declaration, so there is no wrong-guess risk to avoid. + */ public function __construct( private readonly PDO $pdo, // Kept for EntrySearchInterface implementation uniformity: a custom @@ -62,7 +77,37 @@ public function __construct( ?PaginatedProbe $probe = null, ?BoundedFetch $fetch = null, ?ResultAssembler $assembler = null, + ?ServerEngine $engine = null, ) { + $engine ??= ServerEngineDetector::detect($this->pdo); + + // MariaDB genuinely truncates ORDER BY comparison at + // max_sort_length (default 1024 bytes, same as MySQL) for TEXT + // columns — measured directly in SQL on real 10.6/10.11/11 + // servers: rows sharing an identical ≥1024-byte prefix come + // back in scan order, not sort order, collation-independent + // (reproduced with both utf8mb4_unicode_520_nopad_ci and + // utf8mb4_general_ci). MySQL 8.0.13 does not have this problem + // at all — verified exact even with the setting forced to 8 — + // which is the documented premise `src/Read/CLAUDE.md`'s + // "String slots sort exactly, on the full value" relies on and + // ADR 0041 assumes. Silently wrong here is worse than slow: the + // keyset pagination predicate in `SqlFilterCompiler` compares + // the FULL value, so a truncated ORDER BY that disagrees with + // it can skip or repeat rows across a page boundary. Raised to + // cover the full string-slot bound (4 bytes/char utf8mb4 worst + // case) rather than a guessed constant, so a future change to + // `FilterLimits::DEFAULT_MAX_STRING_LENGTH` keeps this correct + // without anyone having to remember it lives here. MySQL is + // left untouched — its correctness does not depend on this + // setting, so there is nothing to fix there and no reason to + // change its resource profile. + if ($engine === ServerEngine::MARIADB) { + $this->pdo->exec( + 'SET SESSION max_sort_length = ' . (FilterLimits::DEFAULT_MAX_STRING_LENGTH * 4) + ); + } + $this->compiler = $compiler ?? new SqlFilterCompiler(); $this->probe = $probe ?? new PaginatedProbe($this->pdo, $this->compiler); $this->fetch = $fetch ?? new BoundedFetch($this->pdo); diff --git a/src/StarDust.php b/src/StarDust.php index c857ffd..c95c0c8 100644 --- a/src/StarDust.php +++ b/src/StarDust.php @@ -84,6 +84,8 @@ use StarDust\Slot\IndexedFreeCapacityReader; use StarDust\Slot\LiveSlotTombstoner; use StarDust\Slot\SlotReserver; +use StarDust\Support\ServerEngine; +use StarDust\Support\ServerEngineDetector; use StarDust\Watcher\AdvisoryScheduleRepository; use StarDust\Watcher\CapacityReporter; use StarDust\Watcher\CardinalitySampler; @@ -141,6 +143,7 @@ final class StarDust private ?ExportJobSubmitter $exportSubmitter = null; private ?SchemaBuilder $schemaBuilder = null; private ?SchemaReader $schemaReader = null; + private ?ServerEngine $serverEngine = null; public function __construct(private readonly Config $config) { @@ -169,7 +172,27 @@ public function logger(): LoggerInterface */ public function bootstrap(): void { - (new Bootstrapper($this->config->pdo))->run(); + (new Bootstrapper($this->config->pdo, $this->serverEngine()))->run(); + } + + /** + * ADR 0055: the target engine, detected from the live connection — + * never configured. Memoised on the same {@see self::lockNamespace()} + * precedent (a PDO-derived fact resolved once, not a construction-time + * `Config` input), and it has to live here rather than as a local in + * {@see self::bootstrap()}: {@see PageProvisioner} emits DDL at + * Watcher time, arbitrarily later and possibly in a different + * process, so a value resolved only inside `bootstrap()` would never + * reach it. + * + * Throws {@see \StarDust\Exception\UnsupportedServerException} for a + * server this engine does not recognise, or one below its floor + * (MySQL/Percona 8.0.13, MariaDB 10.11) — detection fails closed + * rather than defaulting to MySQL. + */ + public function serverEngine(): ServerEngine + { + return $this->serverEngine ??= ServerEngineDetector::detect($this->config->pdo); } /** @@ -663,6 +686,7 @@ public function watcher(): Watcher pdo: $this->config->pdo, clock: $this->config->clock, logger: $this->config->logger, + engine: $this->serverEngine(), ), cardinalitySampler: $this->cardinalitySampler(), spreadSampler: $this->spreadSampler(), @@ -1109,6 +1133,7 @@ private function searchDriver(): EntrySearchInterface pdo: $this->config->pdo, logger: $this->config->logger, cache: $this->schemaVersionCache(), + engine: $this->serverEngine(), ); } diff --git a/src/Support/Dialect.php b/src/Support/Dialect.php index 97294a5..dbb35b8 100644 --- a/src/Support/Dialect.php +++ b/src/Support/Dialect.php @@ -5,20 +5,27 @@ namespace StarDust\Support; /** - * The one definition of every MySQL-specific DDL/SQL construct in the + * The one definition of every engine-specific DDL/SQL construct in the * engine. * - * StarDust's supported database is MySQL 8.0.13+ / Percona 8.0.13+ - * (ADR 0023), and two constructs in the current schema rely on syntax - * or behaviour that is not portable to a second engine: + * StarDust supports MySQL 8.0.13+ / Percona 8.0.13+ (ADR 0023) and, + * since ADR 0054/0055, MariaDB 10.11+. Two constructs in the current + * schema diverge between them: * * - {@see self::tableOptionsClause()} — the table-level charset/collation - * clause. `utf8mb4_0900_ai_ci` is a MySQL 8.0 collation; it does not - * exist on MariaDB (rejected, errno 1273). + * clause. `utf8mb4_0900_ai_ci` is a MySQL 8.0 collation and does not + * exist on MariaDB (rejected, errno 1273); the MariaDB branch uses + * `utf8mb4_unicode_520_nopad_ci` per ADR 0054 §2. * - {@see self::liveSlotUniqueIndexDdl()} — the ADR 0017 "at most one - * live slot per field" functional unique index. Its `CASE … END` - * expression inside a `CREATE UNIQUE INDEX` is 8.0.13+ functional-index - * syntax and does not parse on MariaDB (rejected, errno 1064). + * live slot per field" invariant. MySQL enforces it with a functional + * unique index; its `CASE … END` expression inside a + * `CREATE UNIQUE INDEX` is 8.0.13+ functional-index syntax and does + * not parse on MariaDB (rejected, errno 1064). The MariaDB branch + * instead names the plain `UNIQUE` index over the `live_field_id` + * generated column that {@see \StarDust\Bootstrap\Bootstrapper}'s + * MariaDB-only DDL creates alongside it (ADR 0054 §4) — this method + * only knows about the index; the column DDL is a single-caller + * literal there. * * A third construct is named for completeness but has no method here * yet: the collation of a JSON-fallback comparison @@ -30,7 +37,9 @@ * (`Read\ResultAssembler`, `Search\Mysql\MysqlNativeDriver::get()`) is * pure PHP `json_decode()`, not a SQL comparison. Add a method here, * not an inline literal elsewhere, the day a JSON-fallback SQL - * comparison first ships. + * comparison first ships — and per ADR 0054 §5 that day cannot arrive + * at the MariaDB 10.11+ floor, since 10.6 is where that divergence was + * found and 10.6 is out of scope. * * Each existing construct is used from more than one package * (`Bootstrap\Bootstrapper` and `Page\PageProvisioner` both need the @@ -41,9 +50,10 @@ * one-file edit rather than a grep. Enforced by * `tests/Smoke/DialectTest.php`. * - * Every method returns MySQL syntax unconditionally today — there is no - * dialect switch anywhere in the engine, and this class does not add - * one. It is only the seam a future switch would need. + * Every method takes the {@see ServerEngine} to branch on explicitly + * (ADR 0055 §2) — this class stays a pure function of + * (construct, engine), never resolving the engine itself, so both + * branches stay covered by a DB-free source scan. */ final class Dialect { @@ -55,31 +65,50 @@ private function __construct() * The table-level `ENGINE=... DEFAULT CHARSET=... COLLATE=...` * clause every `CREATE TABLE` in the engine ends with. */ - public static function tableOptionsClause(): string + public static function tableOptionsClause(ServerEngine $engine): string { - return 'ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci'; + return match ($engine) { + ServerEngine::MYSQL => 'ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci', + ServerEngine::MARIADB => 'ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_nopad_ci', + }; } /** - * The ADR 0017 "at most one live slot per field" functional unique - * index. + * The ADR 0017 "at most one live slot per field" invariant's index + * DDL. * - * The `CASE` expression yields `field_id` only while the row is live - * (`assigned`, `backfilling`, `ready`) and `NULL` otherwise — and - * NULLs are exempt from MySQL's UNIQUE constraint, so tombstoned and - * free rows never block reassignment. Caller + * **MySQL**: the `CASE` expression yields `field_id` only while the + * row is live (`assigned`, `backfilling`, `ready`) and `NULL` + * otherwise — NULLs are exempt from MySQL's UNIQUE constraint, so + * tombstoned and free rows never block reassignment. Caller * ({@see \StarDust\Bootstrap\Bootstrapper::ensureSlotAssignmentFieldLiveUniqueIndex()}) * owns the idempotency probe and the duplicate-key-name catch; this * method only names the DDL text. + * + * **MariaDB**: a plain `UNIQUE` index over `live_field_id`, a + * `PERSISTENT` generated column holding the identical `CASE` + * expression (ADR 0054 §4, verified on 10.6/10.11/11 — a second + * *tombstoned* slot for one field is allowed, a second *live* one + * refused with SQLSTATE 23000, matching the MySQL functional + * index's observable behaviour exactly). The column itself is + * created by a separate, MariaDB-only Bootstrapper method, since it + * has exactly one caller and does not meet this class's + * more-than-one-package threshold for living here. */ - public static function liveSlotUniqueIndexDdl(): string + public static function liveSlotUniqueIndexDdl(ServerEngine $engine): string { - return <<<'SQL' - CREATE UNIQUE INDEX ux_slot_assignments_field_live - ON stardust_slot_assignments ( - (CASE WHEN status IN ('assigned', 'backfilling', 'ready') - THEN field_id END) - ) - SQL; + return match ($engine) { + ServerEngine::MYSQL => <<<'SQL' + CREATE UNIQUE INDEX ux_slot_assignments_field_live + ON stardust_slot_assignments ( + (CASE WHEN status IN ('assigned', 'backfilling', 'ready') + THEN field_id END) + ) + SQL, + ServerEngine::MARIADB => <<<'SQL' + CREATE UNIQUE INDEX ux_slot_assignments_field_live + ON stardust_slot_assignments (live_field_id) + SQL, + }; } } diff --git a/src/Support/ServerEngine.php b/src/Support/ServerEngine.php new file mode 100644 index 0000000..53b6b4b --- /dev/null +++ b/src/Support/ServerEngine.php @@ -0,0 +1,24 @@ +getAttribute(PDO::ATTR_SERVER_VERSION); + if (is_string($attr) && $attr !== '') { + return $attr; + } + + $fallback = PdoQuery::run($pdo, 'SELECT VERSION()')->fetchColumn(); + return is_string($fallback) ? $fallback : ''; + } + + private static function containsMariaDbMarker(string $raw): bool + { + return stripos($raw, 'MariaDB') !== false; + } + + /** + * MariaDB has historically prefixed its reported version with + * `5.5.5-` for old-client compatibility. Stripped defensively even + * though it was not observed under mysqlnd (see class docblock) — + * cheap to handle, expensive to discover missing in production. + */ + private static function stripLegacyPrefix(string $raw): string + { + return str_starts_with($raw, '5.5.5-') ? substr($raw, strlen('5.5.5-')) : $raw; + } + + /** @return array{int, int, int}|null */ + private static function parseLeadingVersion(string $raw): ?array + { + if (preg_match('/^(\d+)\.(\d+)\.(\d+)/', $raw, $m) !== 1) { + return null; + } + return [(int) $m[1], (int) $m[2], (int) $m[3]]; + } + + /** + * @param array{int, int, int} $version + * @param array{int, int, int} $floor + */ + private static function isBelowFloor(array $version, array $floor): bool + { + return version_compare(implode('.', $version), implode('.', $floor), '<'); + } +} diff --git a/tests/Smoke/BootstrapTest.php b/tests/Smoke/BootstrapTest.php index de2eab3..d2326bc 100644 --- a/tests/Smoke/BootstrapTest.php +++ b/tests/Smoke/BootstrapTest.php @@ -10,6 +10,8 @@ use StarDust\Bootstrap\Bootstrapper; use StarDust\Config\Config; use StarDust\StarDust; +use StarDust\Support\ServerEngine; +use StarDust\Support\ServerEngineDetector; use StarDust\Tests\Smoke\Support\SchemaFixture; /** @@ -30,6 +32,7 @@ final class BootstrapTest extends TestCase { private PDO $pdo; + private ServerEngine $engine; protected function setUp(): void { @@ -51,6 +54,7 @@ protected function setUp(): void self::fail('Could not connect to test database: ' . $e->getMessage()); } + $this->engine = ServerEngineDetector::detect($this->pdo); $this->dropAllTables(); } @@ -81,10 +85,16 @@ private function dropAllTables(): void SchemaFixture::dropAll($this->pdo); } + /** `(new Bootstrapper($this->pdo, $this->engine))->run()`, spelled once. */ + private function bootstrap(): void + { + (new Bootstrapper($this->pdo, $this->engine))->run(); + } + /** Exit criterion 1: blank database → all tables present. */ public function testBootstrapCreatesEveryTableOnBlankDatabase(): void { - (new Bootstrapper($this->pdo))->run(); + $this->bootstrap(); foreach (SchemaFixture::CORE_TABLES as $table) { self::assertTrue( @@ -101,7 +111,7 @@ public function testBootstrapCreatesEveryTableOnBlankDatabase(): void */ public function testBootstrapIsIdempotentAndNonDestructive(): void { - $bootstrapper = new Bootstrapper($this->pdo); + $bootstrapper = new Bootstrapper($this->pdo, $this->engine); $bootstrapper->run(); $this->pdo->exec( @@ -128,7 +138,7 @@ public function testBootstrapIsIdempotentAndNonDestructive(): void /** Exit criterion 3: stardust_schema_version is seeded with exactly one row, id = 1. */ public function testSchemaVersionSingletonSeeded(): void { - (new Bootstrapper($this->pdo))->run(); + $this->bootstrap(); $rows = $this->pdo ->query('SELECT id, version FROM stardust_schema_version') @@ -139,7 +149,7 @@ public function testSchemaVersionSingletonSeeded(): void self::assertSame(0, (int) $rows[0]['version'], 'Initial version counter should be 0.'); // Re-running must not duplicate the singleton. - (new Bootstrapper($this->pdo))->run(); + $this->bootstrap(); $count = (int) $this->pdo->query('SELECT COUNT(*) FROM stardust_schema_version')->fetchColumn(); self::assertSame(1, $count, 'Bootstrap re-run must not duplicate the singleton row.'); } @@ -151,7 +161,7 @@ public function testSchemaVersionSingletonSeeded(): void */ public function testSlotAssignmentStatusEnumRejectsInvalidValue(): void { - (new Bootstrapper($this->pdo))->run(); + $this->bootstrap(); // Seed a page so the FK on stardust_slot_assignments.page_id is satisfied. $this->pdo->exec( @@ -170,7 +180,7 @@ public function testSlotAssignmentStatusEnumRejectsInvalidValue(): void /** Sanity: each of the five legitimate status values is accepted. */ public function testSlotAssignmentStatusEnumAcceptsAllFiveStates(): void { - (new Bootstrapper($this->pdo))->run(); + $this->bootstrap(); $this->pdo->exec( "INSERT INTO stardust_pages (table_name, provisioned_at, provisioned_by)" @@ -198,10 +208,21 @@ public function testSlotAssignmentStatusEnumAcceptsAllFiveStates(): void * Exit criterion 5: the partial unique index UNIQUE (field_id) * WHERE status IN ('assigned','backfilling','ready') is present. * Verified via SHOW INDEX per the criterion's literal wording. + * + * **MySQL** enforces it with a genuine functional index, so + * `SHOW INDEX` leaves `Column_name` NULL and populates `Expression` + * with the `CASE` text. **MariaDB** has no functional-index syntax + * at all (ADR 0054 §4), so the same invariant is enforced by a + * plain `UNIQUE` index over `live_field_id` — a `PERSISTENT` + * generated column holding the identical `CASE` expression — and + * `SHOW INDEX` reports that column name instead, with no + * `Expression` at all. The `CASE` text lives on the column's own + * `information_schema.COLUMNS.GENERATION_EXPRESSION` there, which + * is what the MariaDB branch checks instead. */ public function testPartialUniqueIndexOnSlotAssignmentsIsPresent(): void { - (new Bootstrapper($this->pdo))->run(); + $this->bootstrap(); $rows = $this->pdo ->query('SHOW INDEX FROM stardust_slot_assignments') @@ -223,13 +244,33 @@ public function testPartialUniqueIndexOnSlotAssignmentsIsPresent(): void 'Functional partial index must be UNIQUE (Non_unique = 0).', ); - // MySQL functional indexes leave Column_name NULL and populate Expression. - $expression = $matching[0]['Expression'] ?? ''; - self::assertNotSame('', (string) $expression, 'Functional index must expose its expression.'); - self::assertStringContainsString('assigned', (string) $expression); - self::assertStringContainsString('backfilling', (string) $expression); - self::assertStringContainsString('ready', (string) $expression); - self::assertStringContainsString('field_id', (string) $expression); + if ($this->engine === ServerEngine::MYSQL) { + // MySQL functional indexes leave Column_name NULL and populate Expression. + $expression = $matching[0]['Expression'] ?? ''; + self::assertNotSame('', (string) $expression, 'Functional index must expose its expression.'); + self::assertStringContainsString('assigned', (string) $expression); + self::assertStringContainsString('backfilling', (string) $expression); + self::assertStringContainsString('ready', (string) $expression); + self::assertStringContainsString('field_id', (string) $expression); + } else { + self::assertSame( + 'live_field_id', + $matching[0]['Column_name'] ?? null, + 'MariaDB must index the live_field_id generated column, not a functional expression.', + ); + + $generation = $this->pdo->query( + 'SELECT GENERATION_EXPRESSION FROM information_schema.COLUMNS' + . " WHERE table_schema = DATABASE()" + . " AND table_name = 'stardust_slot_assignments'" + . " AND column_name = 'live_field_id'" + )->fetchColumn(); + self::assertNotSame('', (string) $generation, 'Generated column must expose its expression.'); + self::assertStringContainsString('assigned', (string) $generation); + self::assertStringContainsString('backfilling', (string) $generation); + self::assertStringContainsString('ready', (string) $generation); + self::assertStringContainsString('field_id', (string) $generation); + } } /** @@ -239,7 +280,7 @@ public function testPartialUniqueIndexOnSlotAssignmentsIsPresent(): void */ public function testPartialUniqueIndexEnforcesAtMostOneLiveSlotPerField(): void { - (new Bootstrapper($this->pdo))->run(); + $this->bootstrap(); $this->pdo->exec( "INSERT INTO stardust_models (tenant_id, name, created_at)" @@ -290,7 +331,7 @@ public function testPartialUniqueIndexEnforcesAtMostOneLiveSlotPerField(): void */ public function testEntryDataCompositeIndexesPresent(): void { - (new Bootstrapper($this->pdo))->run(); + $this->bootstrap(); $rows = $this->pdo->query('SHOW INDEX FROM entry_data')->fetchAll(); @@ -331,7 +372,7 @@ public function testEntryDataCompositeIndexesPresent(): void */ public function testBootstrapAddsLiberatorSweepGapCountColumn(): void { - (new Bootstrapper($this->pdo))->run(); + $this->bootstrap(); $exists = (int) $this->pdo ->query( @@ -345,8 +386,8 @@ public function testBootstrapAddsLiberatorSweepGapCountColumn(): void // Idempotent: re-running must not error and must not duplicate // the column. - (new Bootstrapper($this->pdo))->run(); - (new Bootstrapper($this->pdo))->run(); + $this->bootstrap(); + $this->bootstrap(); $exists = (int) $this->pdo ->query( @@ -374,7 +415,7 @@ public function testBootstrapAddsLiberatorSweepGapCountColumn(): void */ public function testBootstrapAddsBackfillCheckpointsCorrelationIdColumn(): void { - (new Bootstrapper($this->pdo))->run(); + $this->bootstrap(); $column = $this->pdo ->query( @@ -399,8 +440,8 @@ public function testBootstrapAddsBackfillCheckpointsCorrelationIdColumn(): void 'correlation_id must hold a canonical hyphenated v4 UUID.', ); - (new Bootstrapper($this->pdo))->run(); - (new Bootstrapper($this->pdo))->run(); + $this->bootstrap(); + $this->bootstrap(); $exists = (int) $this->pdo ->query( @@ -434,7 +475,7 @@ public function testBootstrapAddsBackfillCheckpointsCorrelationIdColumn(): void */ public function testBootstrapAddsCorrelationColumns(string $table, string $column): void { - (new Bootstrapper($this->pdo))->run(); + $this->bootstrap(); $stmt = $this->pdo->prepare( 'SELECT IS_NULLABLE, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH' @@ -454,8 +495,8 @@ public function testBootstrapAddsCorrelationColumns(string $table, string $colum "{$table}.{$column} must hold a canonical hyphenated v4 UUID.", ); - (new Bootstrapper($this->pdo))->run(); - (new Bootstrapper($this->pdo))->run(); + $this->bootstrap(); + $this->bootstrap(); $count = $this->pdo->prepare( 'SELECT COUNT(*) FROM information_schema.COLUMNS' @@ -500,7 +541,7 @@ public static function correlationColumnProvider(): array */ public function testBootstrapAddsExportJobsArtifactBytesColumn(): void { - (new Bootstrapper($this->pdo))->run(); + $this->bootstrap(); $column = $this->pdo ->query( @@ -518,8 +559,8 @@ public function testBootstrapAddsExportJobsArtifactBytesColumn(): void // Idempotent: re-running must not error and must not duplicate // the column. - (new Bootstrapper($this->pdo))->run(); - (new Bootstrapper($this->pdo))->run(); + $this->bootstrap(); + $this->bootstrap(); $exists = (int) $this->pdo ->query( @@ -540,7 +581,7 @@ public function testBootstrapAddsExportJobsArtifactBytesColumn(): void */ public function testBootstrapAddsFieldsPreviousNameColumn(): void { - (new Bootstrapper($this->pdo))->run(); + $this->bootstrap(); $column = $this->pdo ->query( @@ -563,8 +604,8 @@ public function testBootstrapAddsFieldsPreviousNameColumn(): void // Idempotent: re-running must not error and must not duplicate // the column. - (new Bootstrapper($this->pdo))->run(); - (new Bootstrapper($this->pdo))->run(); + $this->bootstrap(); + $this->bootstrap(); $exists = (int) $this->pdo ->query( @@ -586,10 +627,17 @@ public function testBootstrapAddsFieldsPreviousNameColumn(): void * still has no `updated_at`, which is why `ModelRenamer` takes no * clock; that negative is asserted here so a future migration adding * one is a decision rather than a drift. + * + * **`COLUMN_DEFAULT` for an explicit `DEFAULT NULL` reports + * differently per engine.** MySQL reports SQL `NULL`; MariaDB + * reports the literal string `'NULL'` (four characters), measured + * directly against a real 10.11 server. Both mean the same thing — + * "no default, defaults to NULL" — so the assertion checks the + * fact rather than the driver-specific representation of it. */ public function testBootstrapAddsModelsDeletedAtColumn(): void { - (new Bootstrapper($this->pdo))->run(); + $this->bootstrap(); $column = $this->pdo ->query( @@ -604,7 +652,12 @@ public function testBootstrapAddsModelsDeletedAtColumn(): void self::assertIsArray($column, 'deleted_at column must be present on stardust_models.'); self::assertSame('YES', $column['IS_NULLABLE'], 'deleted_at must be nullable.'); self::assertSame('datetime', $column['DATA_TYPE']); - self::assertNull($column['COLUMN_DEFAULT'], 'deleted_at must default to NULL.'); + + if ($this->engine === ServerEngine::MYSQL) { + self::assertNull($column['COLUMN_DEFAULT'], 'deleted_at must default to NULL.'); + } else { + self::assertSame('NULL', $column['COLUMN_DEFAULT'], 'deleted_at must default to NULL.'); + } $updatedAt = (int) $this->pdo ->query( @@ -617,8 +670,8 @@ public function testBootstrapAddsModelsDeletedAtColumn(): void self::assertSame(0, $updatedAt, 'stardust_models still has no updated_at — ModelRenamer takes no clock.'); // Idempotent across re-runs. - (new Bootstrapper($this->pdo))->run(); - (new Bootstrapper($this->pdo))->run(); + $this->bootstrap(); + $this->bootstrap(); $exists = (int) $this->pdo ->query( @@ -645,7 +698,7 @@ public function testBootstrapAddsModelsDeletedAtColumn(): void */ public function testBootstrapAddsSyncQueueEntryIdIndex(): void { - (new Bootstrapper($this->pdo))->run(); + $this->bootstrap(); $rows = $this->pdo->query('SHOW INDEX FROM stardust_sync_queue')->fetchAll(\PDO::FETCH_ASSOC); $matching = array_values(array_filter( @@ -659,8 +712,8 @@ public function testBootstrapAddsSyncQueueEntryIdIndex(): void self::assertSame(1, (int) $matching[0]['Non_unique'], 'The index must not be unique — many rows may queue one entry.'); // Idempotent across re-runs. - (new Bootstrapper($this->pdo))->run(); - (new Bootstrapper($this->pdo))->run(); + $this->bootstrap(); + $this->bootstrap(); $again = $this->pdo->query('SHOW INDEX FROM stardust_sync_queue')->fetchAll(\PDO::FETCH_ASSOC); $count = count(array_filter( diff --git a/tests/Smoke/DialectTest.php b/tests/Smoke/DialectTest.php index 03bf3e1..b52344a 100644 --- a/tests/Smoke/DialectTest.php +++ b/tests/Smoke/DialectTest.php @@ -6,17 +6,21 @@ use PHPUnit\Framework\TestCase; use StarDust\Support\Dialect; +use StarDust\Support\ServerEngine; /** - * Anti-drift guard for the engine's MySQL-specific DDL/SQL constructs. + * Anti-drift guard for the engine's dialect-specific DDL/SQL constructs. * - * `Dialect` exists so a future second-engine change is a one-file edit - * rather than a grep across `Bootstrapper` and `PageProvisioner`. A - * stray inline copy of either literal defeats that, silently — nothing - * else would notice until someone tried to make the swap. + * `Dialect` exists so a second-engine change is a one-file edit rather + * than a grep across `Bootstrapper` and `PageProvisioner`. A stray + * inline copy of any literal defeats that, silently — nothing else + * would notice until someone tried to make the swap. * * DB-free by design; this is a source scan, in the same spirit as - * {@see \StarDust\Tests\Smoke\Slot\IndexedSlotPredicateTest}. + * {@see \StarDust\Tests\Smoke\Slot\IndexedSlotPredicateTest}. Since + * ADR 0055 both `Dialect` methods take a {@see ServerEngine}, so the + * scan and the behavioural assertions below cover both branches, not + * only the MySQL one. */ final class DialectTest extends TestCase { @@ -31,18 +35,31 @@ final class DialectTest extends TestCase public function testTableCollationIsDefinedInExactlyOnePlace(): void { foreach (self::MUST_NOT_INLINE_COLLATION as $relative) { + $source = (string) file_get_contents(self::SRC . $relative); self::assertStringNotContainsString( 'utf8mb4_0900_ai_ci', - (string) file_get_contents(self::SRC . $relative), + $source, "{$relative} must use Dialect::tableOptionsClause() rather than inlining the" - . ' collation literal — a second copy defeats the point of naming it once.', + . ' MySQL collation literal — a second copy defeats the point of naming it once.', + ); + self::assertStringNotContainsString( + 'utf8mb4_unicode_520_nopad_ci', + $source, + "{$relative} must use Dialect::tableOptionsClause() rather than inlining the" + . ' MariaDB collation literal — a second copy defeats the point of naming it once.', ); } + $dialectSource = (string) file_get_contents(self::SRC . 'Support/Dialect.php'); self::assertStringContainsString( 'utf8mb4_0900_ai_ci', - (string) file_get_contents(self::SRC . 'Support/Dialect.php'), - 'Dialect is the one place the table collation literal may live.', + $dialectSource, + 'Dialect is the one place the MySQL table collation literal may live.', + ); + self::assertStringContainsString( + 'utf8mb4_unicode_520_nopad_ci', + $dialectSource, + 'Dialect is the one place the MariaDB table collation literal may live.', ); } @@ -52,7 +69,7 @@ public function testLiveSlotUniqueIndexDdlIsDefinedInExactlyOnePlace(): void 'CREATE UNIQUE INDEX ux_slot_assignments_field_live', (string) file_get_contents(self::SRC . 'Bootstrap/Bootstrapper.php'), 'Bootstrapper must use Dialect::liveSlotUniqueIndexDdl() rather than inlining the' - . ' functional-index DDL — a second copy defeats the point of naming it once.', + . ' index DDL — a second copy defeats the point of naming it once.', ); self::assertStringContainsString( @@ -66,16 +83,49 @@ public function testTableOptionsClauseMatchesTheMySql80Collation(): void { self::assertSame( 'ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci', - Dialect::tableOptionsClause(), + Dialect::tableOptionsClause(ServerEngine::MYSQL), ); } - public function testLiveSlotUniqueIndexDdlNamesTheThreeLiveStatuses(): void + public function testTableOptionsClauseMatchesTheMariaDbCollation(): void { - $sql = Dialect::liveSlotUniqueIndexDdl(); + self::assertSame( + 'ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_nopad_ci', + Dialect::tableOptionsClause(ServerEngine::MARIADB), + ); + } + + public function testLiveSlotUniqueIndexDdlNamesTheThreeLiveStatusesOnMySql(): void + { + $sql = Dialect::liveSlotUniqueIndexDdl(ServerEngine::MYSQL); self::assertStringContainsString('ux_slot_assignments_field_live', $sql); self::assertStringContainsString('stardust_slot_assignments', $sql); self::assertStringContainsString("'assigned', 'backfilling', 'ready'", $sql); + self::assertStringContainsString('CASE WHEN status', $sql, 'MySQL branch is a functional index over the CASE expression itself.'); + } + + /** + * MariaDB has no functional-index syntax (errno 1064), so this + * branch names a plain index over `live_field_id` instead — the + * `PERSISTENT` generated column holding the identical `CASE` + * expression, created by `Bootstrapper::ensureSlotAssignmentLiveFieldIdColumn()`. + * Verified against real MariaDB 10.6/10.11/11 (2026-09-20): the + * combination allows a second tombstoned slot per field and refuses + * a second live one with SQLSTATE 23000, matching MySQL's + * observable behaviour. + */ + public function testLiveSlotUniqueIndexDdlIndexesTheGeneratedColumnOnMariaDb(): void + { + $sql = Dialect::liveSlotUniqueIndexDdl(ServerEngine::MARIADB); + + self::assertStringContainsString('ux_slot_assignments_field_live', $sql); + self::assertStringContainsString('stardust_slot_assignments', $sql); + self::assertStringContainsString('live_field_id', $sql); + self::assertStringNotContainsString( + 'CASE WHEN', + $sql, + 'The CASE expression lives in the generated column DDL, not the MariaDB index DDL.', + ); } } diff --git a/tests/Smoke/EmptyTableGuardTest.php b/tests/Smoke/EmptyTableGuardTest.php index e436fa1..cd42625 100644 --- a/tests/Smoke/EmptyTableGuardTest.php +++ b/tests/Smoke/EmptyTableGuardTest.php @@ -10,6 +10,8 @@ use PHPUnit\Framework\TestCase; use StarDust\Page\EmptyTableGuard; use StarDust\Page\PopulatedPageDDLException; +use StarDust\Support\ServerEngine; +use StarDust\Support\ServerEngineDetector; use StarDust\Tests\Smoke\Support\LegacyPage; use StarDust\Tests\Smoke\Support\SchemaFixture; @@ -24,6 +26,7 @@ final class EmptyTableGuardTest extends TestCase { private PDO $pdo; + private ServerEngine $engine; protected function setUp(): void { @@ -45,6 +48,7 @@ protected function setUp(): void self::fail('Could not connect to test database: ' . $e->getMessage()); } + $this->engine = ServerEngineDetector::detect($this->pdo); SchemaFixture::reset($this->pdo); } @@ -53,7 +57,7 @@ private function provisionPage1(): void // A legacy-shaped page: the guard is about page *rows*, not page // width, and this keeps the fixture independent of whatever // column set the provisioner currently emits. - LegacyPage::provision($this->pdo, 'phpunit/0'); + LegacyPage::provision($this->pdo, $this->engine, 'phpunit/0'); } public function testAssertEmptyAcceptsEmptyPage(): void diff --git a/tests/Smoke/EnvironmentTest.php b/tests/Smoke/EnvironmentTest.php index 964fb02..3a06c2a 100644 --- a/tests/Smoke/EnvironmentTest.php +++ b/tests/Smoke/EnvironmentTest.php @@ -8,28 +8,36 @@ use PDOException; use PHPUnit\Framework\TestCase; use StarDust\Config\Config; +use StarDust\Exception\UnsupportedServerException; use StarDust\Logging\StdoutNdjsonLogger; use StarDust\StarDust; +use StarDust\Support\ServerEngine; +use StarDust\Support\ServerEngineDetector; /** * Phase 0 smoke suite — verifies the operating environment satisfies - * the Phase 0 exit criteria (MySQL 8.0.13+ feature surface, MariaDB - * rejection, package boots with defaults). + * the Phase 0 exit criteria (MySQL 8.0.13+ / MariaDB 10.11+ feature + * surface per ADR 0055, unsupported-server rejection, package boots + * with defaults). * * Connection parameters are read from env vars: * STARDUST_TEST_DSN (required, e.g. "mysql:host=127.0.0.1;port=3306") * STARDUST_TEST_USER (required) * STARDUST_TEST_PASS (optional, defaults to "") * - * When pointed at a MariaDB instance, this suite is expected to fail - * (the version-string check and the partial-unique-index check both - * reject MariaDB). CI exploits that to satisfy the rejection criterion. + * When pointed at a server `ServerEngineDetector` does not recognise — + * MariaDB 10.6 or earlier, MySQL/Percona below 8.0.13, or anything + * else entirely — `testServerIsASupportedEngine` throws and the suite + * exits non-zero. CI's `mariadb-rejection` job exploits that against a + * below-floor MariaDB target to prove the floor is enforced rather + * than merely documented. */ final class EnvironmentTest extends TestCase { private const PARTIAL_INDEX_TABLE = 'stardust_smoke_partial_unique'; private PDO $pdo; + private ServerEngine $engine; protected function setUp(): void { @@ -50,6 +58,11 @@ protected function setUp(): void } catch (PDOException $e) { self::fail('Could not connect to test database: ' . $e->getMessage()); } + + // Deliberately NOT wrapped: testServerIsASupportedEngine is what + // proves this call fails closed on an unsupported server, and + // catching it here would swallow that for every other test too. + $this->engine = ServerEngineDetector::detect($this->pdo); } protected function tearDown(): void @@ -68,15 +81,29 @@ protected function tearDown(): void } } - /** Exit criterion 4: MariaDB must cause the suite to exit non-zero. */ - public function testServerIsMySql(): void + /** + * Exit criterion 4 (ADR 0055 §4): an unsupported server must cause + * the suite to exit non-zero. `setUp()` already ran detection to + * populate `$this->engine` — this method exists to name the + * criterion explicitly and to assert on the result, since a test + * with no assertion of its own is risky under + * `beStrictAboutTestsThatDoNotTestAnything`. + * + * Renamed from `testServerIsMySql`, which pre-dated MariaDB support + * and unconditionally failed on any `MariaDB`-marked version + * string. The floor itself — MySQL/Percona 8.0.13+ or MariaDB + * 10.11+ — is enforced by `ServerEngineDetector::detect()`, not + * re-derived here; this method's job is only to prove the *smoke + * suite* observes the same floor the runtime does; see + * `ServerEngineDetectorTest` for the mechanism's own boundary + * coverage. + */ + public function testServerIsASupportedEngine(): void { - $version = (string) $this->pdo->query('SELECT VERSION()')->fetchColumn(); - - self::assertStringNotContainsString( - 'MariaDB', - $version, - 'StarDust does not support MariaDB; MySQL 8.0.13+ or Percona 8.0.13+ required.', + self::assertContains( + $this->engine, + [ServerEngine::MYSQL, ServerEngine::MARIADB], + 'ServerEngineDetector::detect() returned an engine outside the closed enum — this should be unreachable; it throws UnsupportedServerException for anything else.', ); } @@ -103,9 +130,23 @@ public function testMySqlVersionFloor(): void } /** - * Exit criterion 3: functional / conditional unique indexes must work + * Exit criterion 3: a partial/conditional unique index must work * (this is the mechanism that enforces the registry's "at most one - * live slot per field" invariant per ADR 0017 / 0023). + * live slot per field" invariant per ADR 0017 / 0023) — on **either** + * supported engine, via whichever mechanism that engine has. + * + * MySQL gets the literal 8.0.13+ functional index this test always + * used. MariaDB has no functional-index syntax at all (errno 1064, + * measured on 10.6/10.11/11) and gets the ADR 0054 §4 substitute + * instead: a `PERSISTENT` generated column holding the identical + * `CASE` expression, plus a plain `UNIQUE` index over it — the same + * construct `Bootstrap\Bootstrapper::ensureSlotAssignmentLiveFieldIdColumn()` + * adds to the real registry table. **The two branches are asserted + * on identically past table setup**: same insert sequence, same + * expected `PDOException` on the second live row. That is the + * point — this test exists to prove the *observable behaviour* the + * registry actually depends on, not to prove MySQL's specific DDL + * syntax parses. */ public function testPartialUniqueIndexSupported(): void { @@ -120,14 +161,29 @@ public function testPartialUniqueIndexSupported(): void ) ENGINE=InnoDB "); - // MySQL 8.0.13+ functional index. MariaDB rejects this syntax. - $this->pdo->exec(" - CREATE UNIQUE INDEX ux_{$table}_live - ON {$table} ( - (CASE WHEN status IN ('assigned', 'backfilling', 'ready') - THEN field_id END) - ) - "); + if ($this->engine === ServerEngine::MYSQL) { + // MySQL 8.0.13+ functional index. MariaDB rejects this syntax. + $this->pdo->exec(" + CREATE UNIQUE INDEX ux_{$table}_live + ON {$table} ( + (CASE WHEN status IN ('assigned', 'backfilling', 'ready') + THEN field_id END) + ) + "); + } else { + // MariaDB substitute (ADR 0054 §4): a PERSISTENT generated + // column holding the identical CASE expression, plus a plain + // UNIQUE index over it. + $this->pdo->exec(" + ALTER TABLE {$table} + ADD COLUMN live_field_id INT + GENERATED ALWAYS AS ( + CASE WHEN status IN ('assigned', 'backfilling', 'ready') + THEN field_id END + ) PERSISTENT + "); + $this->pdo->exec("CREATE UNIQUE INDEX ux_{$table}_live ON {$table} (live_field_id)"); + } // Inserting two 'free' rows with the same field_id must succeed // (CASE returns NULL, and NULLs are not unique-constrained). @@ -138,7 +194,7 @@ public function testPartialUniqueIndexSupported(): void $this->pdo->exec("INSERT INTO {$table} (field_id, status) VALUES (1, 'assigned')"); // A second 'assigned' row for the same field_id must violate the - // partial unique constraint. + // partial unique constraint — same observable behaviour on both engines. $this->expectException(PDOException::class); $this->pdo->exec("INSERT INTO {$table} (field_id, status) VALUES (1, 'assigned')"); } diff --git a/tests/Smoke/PageProvisionerTest.php b/tests/Smoke/PageProvisionerTest.php index 20ed6dd..3e6c3a2 100644 --- a/tests/Smoke/PageProvisionerTest.php +++ b/tests/Smoke/PageProvisionerTest.php @@ -12,6 +12,8 @@ use StarDust\Clock\SystemClock; use StarDust\Logging\StdoutNdjsonLogger; use StarDust\Page\PageProvisioner; +use StarDust\Support\ServerEngine; +use StarDust\Support\ServerEngineDetector; use StarDust\Tests\Smoke\Support\SchemaFixture; /** @@ -25,6 +27,7 @@ final class PageProvisionerTest extends TestCase { private PDO $pdo; + private ServerEngine $engine; protected function setUp(): void { @@ -46,6 +49,7 @@ protected function setUp(): void self::fail('Could not connect to test database: ' . $e->getMessage()); } + $this->engine = ServerEngineDetector::detect($this->pdo); SchemaFixture::reset($this->pdo); } @@ -55,6 +59,7 @@ private function newProvisioner(): PageProvisioner pdo: $this->pdo, clock: new SystemClock(), logger: new NullLogger(), + engine: $this->engine, provisionerIdentity: 'phpunit/0', ); } @@ -269,6 +274,7 @@ public function testProvisionEmitsStructuredLogEvent(): void pdo: $this->pdo, clock: new SystemClock(), logger: new StdoutNdjsonLogger(new SystemClock(), $stream), + engine: $this->engine, provisionerIdentity: 'phpunit/0', ); @@ -326,6 +332,7 @@ public function testProvisionDeduplicatesFilterableSlots(): void pdo: $this->pdo, clock: new SystemClock(), logger: new StdoutNdjsonLogger(new SystemClock(), $stream), + engine: $this->engine, provisionerIdentity: 'phpunit/0', ); diff --git a/tests/Smoke/Phase5TestCase.php b/tests/Smoke/Phase5TestCase.php index 4829425..3c6b754 100644 --- a/tests/Smoke/Phase5TestCase.php +++ b/tests/Smoke/Phase5TestCase.php @@ -375,6 +375,7 @@ protected function makeWatcher( pdo: $this->pdo, clock: new SystemClock(), logger: $log, + engine: $this->engine, ), cardinalitySampler: new CardinalitySampler( pdo: $this->pdo, diff --git a/tests/Smoke/Retype/RetypeInitiatorConcurrencyTest.php b/tests/Smoke/Retype/RetypeInitiatorConcurrencyTest.php index 7905655..0d4e3d6 100644 --- a/tests/Smoke/Retype/RetypeInitiatorConcurrencyTest.php +++ b/tests/Smoke/Retype/RetypeInitiatorConcurrencyTest.php @@ -23,9 +23,9 @@ * checkpoint. `RetypeBackfillWorkSource` reads that to choose the ADR * 0024 matrix cell, so the second backfill would coerce through the * wrong one — silently, with no event and no exception. `loadField()` - * therefore runs `FOR UPDATE OF f` inside the initiator's transaction, - * which makes the loser block and then re-read what the winner - * committed. + * therefore runs `FOR UPDATE` on the field row inside the initiator's + * transaction, which makes the loser block and then re-read what the + * winner committed. * * ## Why this asserts on an *incompatible* retype * @@ -47,17 +47,25 @@ * - snapshot read → proceeds → `IncompatibleRetypeException` * * Different exception types, so the fixture discriminates. Validated by - * removing `FOR UPDATE OF f` and confirming this test fails. + * removing the `FOR UPDATE` on `loadField()`'s first statement and + * confirming this test fails. + * + * `loadField()` used to be one `stardust_fields JOIN stardust_models` + * statement with MySQL's `FOR UPDATE OF f`, so this fixture's sibling + * lock mirrored that exact text. MariaDB has no `OF` clause at all, so + * the implementation moved to two single-table statements (the join's + * `tenant_id` half needs no lock — see `RetypeInitiator::loadField()`'s + * docblock) — this fixture only needs to reproduce the part that still + * matters: a `FOR UPDATE` on the `stardust_fields` row for this field. */ final class RetypeInitiatorConcurrencyTest extends Phase6bTestCase { - /** The statement `RetypeInitiator::loadField()` issues, verbatim. */ + /** The statement `RetypeInitiator::loadField()` locks the field row with, verbatim. */ private const LOCK_FIELD_SQL = - 'SELECT f.declared_type, f.is_filterable, f.model_id, f.deleted_at, m.tenant_id' - . ' FROM stardust_fields f' - . ' JOIN stardust_models m ON m.id = f.model_id' - . ' WHERE f.id = ?' - . ' FOR UPDATE OF f'; + 'SELECT declared_type, is_filterable, model_id, deleted_at' + . ' FROM stardust_fields' + . ' WHERE id = ?' + . ' FOR UPDATE'; public function testTheFieldReadBlocksBehindASiblingLockOnTheSameRow(): void { diff --git a/tests/Smoke/Search/MysqlNativeDriverServerTuningTest.php b/tests/Smoke/Search/MysqlNativeDriverServerTuningTest.php new file mode 100644 index 0000000..e5e0261 --- /dev/null +++ b/tests/Smoke/Search/MysqlNativeDriverServerTuningTest.php @@ -0,0 +1,64 @@ +engine !== ServerEngine::MARIADB) { + self::markTestSkipped('This assertion is MariaDB-specific; see the MySQL counterpart below.'); + } + + new MysqlNativeDriver( + pdo: $this->pdo, + logger: new NullLogger(), + cache: new SchemaVersionCache($this->pdo, new NullLogger()), + ); + + $value = (int) $this->pdo->query('SELECT @@max_sort_length')->fetchColumn(); + self::assertSame(FilterLimits::DEFAULT_MAX_STRING_LENGTH * 4, $value); + } + + public function testMySqlIsLeftAtWhateverTheServerDefaultIs(): void + { + if ($this->engine !== ServerEngine::MYSQL) { + self::markTestSkipped('This assertion is MySQL-specific; see the MariaDB counterpart above.'); + } + + $before = (int) $this->pdo->query('SELECT @@max_sort_length')->fetchColumn(); + + new MysqlNativeDriver( + pdo: $this->pdo, + logger: new NullLogger(), + cache: new SchemaVersionCache($this->pdo, new NullLogger()), + ); + + $after = (int) $this->pdo->query('SELECT @@max_sort_length')->fetchColumn(); + self::assertSame($before, $after, 'MySQL correctness never depended on this setting; the driver must not touch it.'); + } +} diff --git a/tests/Smoke/Slot/SlotAffinityTest.php b/tests/Smoke/Slot/SlotAffinityTest.php index 4dd84df..e0c3d70 100644 --- a/tests/Smoke/Slot/SlotAffinityTest.php +++ b/tests/Smoke/Slot/SlotAffinityTest.php @@ -13,6 +13,8 @@ use StarDust\Slot\IndexedFreeCapacityReader; use StarDust\Slot\SlotAssignment; use StarDust\Slot\SlotReserver; +use StarDust\Support\ServerEngine; +use StarDust\Support\ServerEngineDetector; use StarDust\Tests\Smoke\Support\LegacyPage; use StarDust\Tests\Smoke\Support\SchemaFixture; use StarDust\Watcher\SpreadSampler; @@ -39,6 +41,7 @@ final class SlotAffinityTest extends TestCase { private PDO $pdo; + private ServerEngine $engine; protected function setUp(): void { @@ -50,6 +53,7 @@ protected function setUp(): void } $this->pdo = $this->newConnection(); + $this->engine = ServerEngineDetector::detect($this->pdo); SchemaFixture::reset($this->pdo); } @@ -517,6 +521,7 @@ private function provisionPage(array $filterableSlots = []): int pdo: $this->pdo, clock: new SystemClock(), logger: new NullLogger(), + engine: $this->engine, provisionerIdentity: 'phpunit/0', ))->provision($filterableSlots); } @@ -524,7 +529,7 @@ private function provisionPage(array $filterableSlots = []): int /** A pre-ADR-0043 page: sixty columns, none indexed. */ private function provisionLegacyPage(): int { - return LegacyPage::provision($this->pdo, 'phpunit/0'); + return LegacyPage::provision($this->pdo, $this->engine, 'phpunit/0'); } private function createModel(int $tenantId = 1): int diff --git a/tests/Smoke/SlotReserverTest.php b/tests/Smoke/SlotReserverTest.php index 6eef83c..a898bf9 100644 --- a/tests/Smoke/SlotReserverTest.php +++ b/tests/Smoke/SlotReserverTest.php @@ -13,6 +13,8 @@ use StarDust\Logging\StdoutNdjsonLogger; use StarDust\Page\PageProvisioner; use StarDust\Slot\SlotReserver; +use StarDust\Support\ServerEngine; +use StarDust\Support\ServerEngineDetector; use StarDust\Tests\Smoke\Support\LegacyPage; use StarDust\Tests\Smoke\Support\SchemaFixture; @@ -29,6 +31,7 @@ final class SlotReserverTest extends TestCase { private PDO $pdo; + private ServerEngine $engine; protected function setUp(): void { @@ -50,13 +53,14 @@ protected function setUp(): void self::fail('Could not connect to test database: ' . $e->getMessage()); } + $this->engine = ServerEngineDetector::detect($this->pdo); SchemaFixture::reset($this->pdo); } /** A pre-ADR-0043 page: sixty columns, none indexed. */ private function provisionLegacyPage(): int { - return LegacyPage::provision($this->pdo, 'phpunit/0'); + return LegacyPage::provision($this->pdo, $this->engine, 'phpunit/0'); } private function newProvisioner(): PageProvisioner @@ -65,6 +69,7 @@ private function newProvisioner(): PageProvisioner pdo: $this->pdo, clock: new SystemClock(), logger: new NullLogger(), + engine: $this->engine, provisionerIdentity: 'phpunit/0', ); } diff --git a/tests/Smoke/Support/LegacyPage.php b/tests/Smoke/Support/LegacyPage.php index c3610c2..cebf789 100644 --- a/tests/Smoke/Support/LegacyPage.php +++ b/tests/Smoke/Support/LegacyPage.php @@ -5,6 +5,7 @@ namespace StarDust\Tests\Smoke\Support; use PDO; +use StarDust\Support\ServerEngine; /** * Builds a **pre-ADR-0043 extension page**: all sixty slot columns, none @@ -27,6 +28,15 @@ * bypass precedent as `Phase6aTestCase::seedSlotValues()` and * `SlotAffinityTest`'s direct registry writes: fixtures may construct * states the production path refuses to. + * + * **`$engine` (ADR 0055 item 3) branches the collation only, inlined + * directly rather than delegated to `Support\Dialect`.** The column + * layout must stay frozen against `PageProvisioner`'s evolution, and + * that same independence argues for not taking on a dependency on + * `Dialect` either — a future change to that class's signature would + * otherwise break a fixture whose entire job is to hold still. The + * MariaDB literal is `Dialect::tableOptionsClause(ServerEngine::MARIADB)`'s + * value, copied rather than called, for the same reason. */ final class LegacyPage { @@ -49,14 +59,17 @@ private function __construct() * 0043 — DDL first (it auto-commits), then the registry row, the * full inventory and the schema-version bump in one transaction. */ - public static function provision(PDO $pdo, string $provisionerIdentity = 'phpunit/legacy'): int - { + public static function provision( + PDO $pdo, + ServerEngine $engine, + string $provisionerIdentity = 'phpunit/legacy', + ): int { $pageNumber = (int) $pdo ->query('SELECT COALESCE(MAX(id), 0) + 1 FROM stardust_pages') ->fetchColumn(); $tableName = "entry_slots_page_{$pageNumber}"; - $pdo->exec(self::ddl($tableName)); + $pdo->exec(self::ddl($tableName, $engine)); $now = gmdate('Y-m-d H:i:s'); @@ -102,7 +115,7 @@ public static function provision(PDO $pdo, string $provisionerIdentity = 'phpuni return $pageNumber; } - private static function ddl(string $tableName): string + private static function ddl(string $tableName, ServerEngine $engine): string { $lines = [ "CREATE TABLE IF NOT EXISTS {$tableName} (", @@ -126,7 +139,11 @@ private static function ddl(string $tableName): string // it: COMPACT/REDUNDANT cap index keys at 767 bytes, and a test // that later indexes a string slot on this page would hit errno // 1071 on a server whose innodb_default_row_format differs. - $lines[] = ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC'; + $collation = match ($engine) { + ServerEngine::MYSQL => 'utf8mb4_0900_ai_ci', + ServerEngine::MARIADB => 'utf8mb4_unicode_520_nopad_ci', + }; + $lines[] = ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE={$collation} ROW_FORMAT=DYNAMIC"; return implode("\n", $lines); } diff --git a/tests/Smoke/Support/SchemaFixture.php b/tests/Smoke/Support/SchemaFixture.php index c40c5f8..63faf13 100644 --- a/tests/Smoke/Support/SchemaFixture.php +++ b/tests/Smoke/Support/SchemaFixture.php @@ -6,6 +6,7 @@ use PDO; use StarDust\Bootstrap\Bootstrapper; +use StarDust\Support\ServerEngineDetector; /** * The smoke suite's per-test database reset. @@ -139,7 +140,7 @@ public static function reset(PDO $pdo): void // rather than issuing DELETE against a table that isn't there. if (array_diff(self::CORE_TABLES, $present) !== []) { self::dropAll($pdo, $pageTables); - (new Bootstrapper($pdo))->run(); + (new Bootstrapper($pdo, ServerEngineDetector::detect($pdo)))->run(); return; } @@ -176,7 +177,7 @@ public static function reset(PDO $pdo): void // Reseeds the schema-version singleton the sweep just deleted, // and re-creates anything CORE_TABLES failed to describe. - (new Bootstrapper($pdo))->run(); + (new Bootstrapper($pdo, ServerEngineDetector::detect($pdo)))->run(); } /** diff --git a/tests/Smoke/Support/ServerEngineDetectorTest.php b/tests/Smoke/Support/ServerEngineDetectorTest.php new file mode 100644 index 0000000..82822b7 --- /dev/null +++ b/tests/Smoke/Support/ServerEngineDetectorTest.php @@ -0,0 +1,162 @@ +pdo = new PDO($dsn, $user, $pass, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + ]); + } catch (PDOException $e) { + self::fail('Could not connect to test database: ' . $e->getMessage()); + } + } + + public function testDetectsTheConnectedServerWithoutThrowing(): void + { + $engine = ServerEngineDetector::detect($this->pdo); + self::assertContains($engine, [ServerEngine::MYSQL, ServerEngine::MARIADB]); + } + + /** @return array [rawVersion, expectBelowMysqlFloor] */ + public static function mysqlFloorCases(): array + { + return [ + 'just below floor' => ['8.0.12', true], + 'exactly at floor' => ['8.0.13', false], + 'above floor' => ['8.0.14', false], + 'MySQL 5.7 well below floor' => ['5.7.44', true], + ]; + } + + /** @dataProvider mysqlFloorCases */ + public function testMysqlFloorBoundary(string $raw, bool $expectBelow): void + { + self::assertSame($expectBelow, $this->isBelowMysqlFloor($raw)); + } + + /** @return array [rawVersion, expectBelowMariaDbFloor] */ + public static function mariaDbFloorCases(): array + { + return [ + 'just below floor' => ['10.10.9-MariaDB', true], + 'exactly at floor' => ['10.11.0-MariaDB', false], + 'well above floor' => ['11.8.9-MariaDB-ubu2404', false], + ]; + } + + /** @dataProvider mariaDbFloorCases */ + public function testMariaDbFloorBoundary(string $raw, bool $expectBelow): void + { + self::assertSame($expectBelow, $this->isBelowMariaDbFloor($raw)); + } + + /** + * MariaDB's historical `5.5.5-` old-client-compat prefix must be + * stripped before the version is parsed, or `10.11.19-MariaDB` + * reads as `5.5.5` — fails closed, but as "MySQL below floor", + * sending an operator hunting a problem they do not have (ADR 0055 + * §5). Not observed under this project's own mysqlnd-based CI + * driver (Stage 1 probe, 2026-09-20), so this is defensive rather + * than reproducing a measured failure. + */ + public function testLegacyPrefixIsStrippedBeforeParsing(): void + { + $ref = new ReflectionClass(ServerEngineDetector::class); + + $stripLegacyPrefix = $ref->getMethod('stripLegacyPrefix'); + $stripLegacyPrefix->setAccessible(true); + $parseLeadingVersion = $ref->getMethod('parseLeadingVersion'); + $parseLeadingVersion->setAccessible(true); + $containsMarker = $ref->getMethod('containsMariaDbMarker'); + $containsMarker->setAccessible(true); + + $raw = '5.5.5-10.11.19-MariaDB'; + + self::assertTrue($containsMarker->invoke(null, $raw)); + + $stripped = $stripLegacyPrefix->invoke(null, $raw); + self::assertSame('10.11.19-MariaDB', $stripped); + + $parsed = $parseLeadingVersion->invoke(null, $stripped); + self::assertSame([10, 11, 19], $parsed); + } + + public function testThrowsOnAnUnparseableVersionString(): void + { + $ref = new ReflectionClass(ServerEngineDetector::class); + $parseLeadingVersion = $ref->getMethod('parseLeadingVersion'); + $parseLeadingVersion->setAccessible(true); + + self::assertNull($parseLeadingVersion->invoke(null, 'not-a-version-string')); + } + + private function isBelowMysqlFloor(string $raw): bool + { + return $this->isBelowFloorFor($raw, [8, 0, 13]); + } + + private function isBelowMariaDbFloor(string $raw): bool + { + $ref = new ReflectionClass(ServerEngineDetector::class); + $stripLegacyPrefix = $ref->getMethod('stripLegacyPrefix'); + $stripLegacyPrefix->setAccessible(true); + + return $this->isBelowFloorFor((string) $stripLegacyPrefix->invoke(null, $raw), [10, 11, 0]); + } + + /** @param array{int, int, int} $floor */ + private function isBelowFloorFor(string $raw, array $floor): bool + { + $ref = new ReflectionClass(ServerEngineDetector::class); + $parseLeadingVersion = $ref->getMethod('parseLeadingVersion'); + $parseLeadingVersion->setAccessible(true); + $isBelowFloor = $ref->getMethod('isBelowFloor'); + $isBelowFloor->setAccessible(true); + + $parsed = $parseLeadingVersion->invoke(null, $raw); + self::assertNotNull($parsed, "Failed to parse version from '{$raw}'."); + + return $isBelowFloor->invoke(null, $parsed, $floor); + } +} diff --git a/tests/Smoke/Watcher/AdvisoryScheduleTest.php b/tests/Smoke/Watcher/AdvisoryScheduleTest.php index 45bcb44..12cb3da 100644 --- a/tests/Smoke/Watcher/AdvisoryScheduleTest.php +++ b/tests/Smoke/Watcher/AdvisoryScheduleTest.php @@ -336,7 +336,7 @@ public function testReBootstrapPreservesARunningSchedule(): void $repository = new AdvisoryScheduleRepository($this->pdo, new SystemClock()); self::assertTrue($repository->scheduleFirst(4_242)); - (new \StarDust\Bootstrap\Bootstrapper($this->pdo))->run(); + (new \StarDust\Bootstrap\Bootstrapper($this->pdo, $this->engine))->run(); self::assertSame(4_242, $this->storedNextSampleAt()); } @@ -409,7 +409,7 @@ private function watcherOn( logger: $logger, capacityReporter: new CapacityReporter($pdo), pendingDemandReader: new PendingDemandReader($pdo), - pageProvisioner: new PageProvisioner(pdo: $pdo, clock: new SystemClock(), logger: $logger), + pageProvisioner: new PageProvisioner(pdo: $pdo, clock: new SystemClock(), logger: $logger, engine: $this->engine), cardinalitySampler: new CardinalitySampler( pdo: $pdo, logger: $logger, diff --git a/tests/Smoke/WritePathTestCase.php b/tests/Smoke/WritePathTestCase.php index ccf2cf1..c9abd5a 100644 --- a/tests/Smoke/WritePathTestCase.php +++ b/tests/Smoke/WritePathTestCase.php @@ -11,6 +11,8 @@ use StarDust\Clock\SystemClock; use StarDust\Page\PageProvisioner; use StarDust\Slot\SlotReserver; +use StarDust\Support\ServerEngine; +use StarDust\Support\ServerEngineDetector; use StarDust\Tests\Smoke\Support\LegacyPage; use StarDust\Tests\Smoke\Support\SchemaFixture; use StarDust\Write\BulkIngestor; @@ -32,6 +34,7 @@ abstract class WritePathTestCase extends TestCase { protected PDO $pdo; + protected ServerEngine $engine; protected function setUp(): void { @@ -53,6 +56,7 @@ protected function setUp(): void self::fail('Could not connect to test database: ' . $e->getMessage()); } + $this->engine = ServerEngineDetector::detect($this->pdo); SchemaFixture::reset($this->pdo); } @@ -91,6 +95,7 @@ protected function provisionPage(array $filterableSlots = []): int pdo: $this->pdo, clock: new SystemClock(), logger: new NullLogger(), + engine: $this->engine, provisionerIdentity: 'phpunit/0', ))->provision($filterableSlots); } @@ -106,7 +111,7 @@ protected function provisionPage(array $filterableSlots = []): int */ protected function provisionLegacyPage(): int { - return LegacyPage::provision($this->pdo, 'phpunit/0'); + return LegacyPage::provision($this->pdo, $this->engine, 'phpunit/0'); } /**