From 46871f0a4162ee7fd71568d32cf4e454e231a8d2 Mon Sep 17 00:00:00 2001 From: Mohamed Abdulalim Date: Thu, 12 Nov 2026 20:19:13 +0200 Subject: [PATCH 1/4] Add WU-10 consumer verification and coexistence harness --- .gitattributes | 1 + .github/workflows/ci.yml | 23 +- CONTRIBUTING.md | 3 +- composer.json | 5 +- docs/architecture/CI_AND_INTEGRATION.md | 5 +- docs/guides/CONSUMER_VERIFICATION.md | 27 ++ harness/consumer.php | 280 ++++++++++++++++++ scripts/consumer-verification.php | 198 +++++++++++++ scripts/integration-support.php | 15 +- .../IntegrationInfrastructureTest.php | 6 +- 10 files changed, 550 insertions(+), 13 deletions(-) create mode 100644 docs/guides/CONSUMER_VERIFICATION.md create mode 100644 harness/consumer.php create mode 100644 scripts/consumer-verification.php diff --git a/.gitattributes b/.gitattributes index 5b2a0a6..db5af5b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,5 +10,6 @@ /docker/ export-ignore /phpstan.neon export-ignore /phpunit.xml export-ignore +/harness/ export-ignore /scripts/ export-ignore /tests/ export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index becb4d0..8c543f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -203,6 +203,27 @@ jobs: test -z "$(docker ps -aq --filter 'label=com.docker.compose.project')" test -z "$(docker volume ls -q --filter 'label=com.docker.compose.project')" + consumer-verification: + name: Consumer verification and coexistence + runs-on: ubuntu-24.04 + timeout-minutes: 40 + steps: + # actions/checkout v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: ./.github/actions/setup-php-composer + with: + php-version: "8.4" + - name: Install and verify external consumer twice on fresh MySQL + run: composer test:consumer + - name: No Compose resources remain + if: always() + run: | + set -euo pipefail + test -z "$(docker ps -aq --filter 'label=com.docker.compose.project')" + test -z "$(docker volume ls -q --filter 'label=com.docker.compose.project')" + exploratory: name: Exploratory MySQL Innovation (not required) if: github.event_name == 'workflow_dispatch' && inputs.exploratory @@ -225,7 +246,7 @@ jobs: ci-gate: name: CI gate if: always() - needs: [workflow-lint, static, compat, integration, examples-smoke] + needs: [workflow-lint, static, compat, integration, examples-smoke, consumer-verification] runs-on: ubuntu-24.04 timeout-minutes: 5 steps: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5e4541f..36885c8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,6 +37,7 @@ composer test:unit # no Docker needed composer test:regression # no Docker needed composer test:integration # needs Docker; starts a disposable MySQL/MariaDB service composer examples:smoke # needs Docker; smoke-executes examples/ against a real engine +composer test:consumer # needs Docker; two fresh external Composer verification runs actionlint .github/workflows/*.yml ``` @@ -56,7 +57,7 @@ This package does not commit `composer.lock`. CI resolves both `latest` and `low - Keep a pull request to one coherent change; include its own tests and any directly affected documentation in the same change rather than splitting them across separate pull requests. - Describe the change, its rationale, and which local commands above you ran. -- All applicable CI gates (workflow lint, static checks, compatibility matrix, Integration, and example smoke) must pass before a merge is considered; CI gating and Git/merge permissions follow [AGENTS.md](AGENTS.md) and the pinned `AI_COLLABORATION_WORKFLOW_AR.md`/`GITHUB_PHASE_STACK_WORKFLOW_AR.md` — in particular, merges require explicit Owner authorization. +- All applicable CI gates (workflow lint, static checks, compatibility matrix, Integration, example smoke, and consumer verification) must pass before a merge is considered; CI gating and Git/merge permissions follow [AGENTS.md](AGENTS.md) and the pinned `AI_COLLABORATION_WORKFLOW_AR.md`/`GITHUB_PHASE_STACK_WORKFLOW_AR.md` — in particular, merges require explicit Owner authorization. ## Reporting a security issue diff --git a/composer.json b/composer.json index 04e52f6..7616f85 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,7 @@ "php-cs-fixer fix --dry-run --diff", "@php scripts/check-per-cs-3-1.php" ], - "syntax": "find src tests scripts examples .php-cs-fixer.php -name '*.php' -print0 | xargs -0 -n1 php -l > /dev/null", + "syntax": "find src tests scripts examples harness .php-cs-fixer.php -name '*.php' -print0 | xargs -0 -n1 php -l > /dev/null", "whitespace": "git diff --check 4b825dc642cb6eb9a060e54bf8d69288fbee4904 HEAD -- . ':!docs/php-engineering-standards'", "test": [ "@test:unit", @@ -61,7 +61,8 @@ "test:unit": "phpunit --testsuite unit", "test:regression": "phpunit --testsuite regression", "test:integration": "@php scripts/integration.php", - "examples:smoke": "@php scripts/examples-smoke.php" + "examples:smoke": "@php scripts/examples-smoke.php", + "test:consumer": "@php scripts/consumer-verification.php" }, "config": { "optimize-autoloader": true, diff --git a/docs/architecture/CI_AND_INTEGRATION.md b/docs/architecture/CI_AND_INTEGRATION.md index f3609c4..99b6496 100644 --- a/docs/architecture/CI_AND_INTEGRATION.md +++ b/docs/architecture/CI_AND_INTEGRATION.md @@ -21,9 +21,10 @@ Every CI gate runs a repository-owned command that a developer can run locally w | Regression suite | `composer test:regression` | Compat | | Integration suite (real database) | `composer test:integration` | Integration | | Standalone examples (real database) | `composer examples:smoke` | Examples smoke | +| Consumer verification and coexistence | `composer test:consumer` | Consumer verification and coexistence | | Workflow lint | `actionlint` over `.github/workflows` | Workflow lint | -Unit and Regression need no Docker. `composer test` runs Unit, Regression, and Integration locally. The required `CI gate` aggregates Workflow lint, Static checks, Compat, Integration, and Examples smoke. The exploratory cell is optional. +Unit and Regression need no Docker. `composer test` runs Unit, Regression, and Integration locally. The required `CI gate` aggregates Workflow lint, Static checks, Compat, Integration, Examples smoke, and Consumer verification and coexistence. The exploratory cell is optional. ## 2. Integration lifecycle @@ -78,6 +79,8 @@ PP_DB_CELL=mariadb-11.8.9 composer examples:smoke CI requires both `mysql-8.4.13` and `mariadb-11.8.9` smoke jobs on PHP 8.4. This smoke matrix is separate from the capability Integration matrix below. +[Consumer verification](../guides/CONSUMER_VERIFICATION.md) uses two fresh MySQL lifecycles and separate production Composer roots. + ## 3. Test matrix One parameterized Compose definition (`compose.integration.yaml`) serves every cell. The data is in `docker/integration-matrix.json`; images come from the Docker Official Images `mysql` and `mariadb` repositories on Docker Hub. diff --git a/docs/guides/CONSUMER_VERIFICATION.md b/docs/guides/CONSUMER_VERIFICATION.md new file mode 100644 index 0000000..ed1e0d5 --- /dev/null +++ b/docs/guides/CONSUMER_VERIFICATION.md @@ -0,0 +1,27 @@ +# Consumer verification and coexistence + +From a checkout of the committed candidate, run `composer test:consumer` with PHP 8.4 or newer (PDO MySQL enabled), Composer 2.10, Git, cURL, tar, and Docker Compose. Internet access is needed for Composer dependencies, the pinned legacy source archive, and the Integration database image. The candidate is archived from the committed Git HEAD and its commit and tree are printed. On pull-request CI, this is the commit that the workflow checked out. + +The required CI job provisions two separate MySQL 8.4.13 services through the existing Integration lifecycle. Each service runs the consumer workload in two independently generated Composer roots outside the repository. Every root starts without a lock, vendor, or project state and installs both packages through Composer with `--no-dev`. It runs the repository's dependency policy, uses only the production autoloader, and verifies the installed package identities. The candidate is mirrored from the Git archive. The consumer project owns its schema and the input catalog fixtures. Each lifecycle verifies zero tables and open transactions, removes the consumer directories, and verifies Compose teardown. + +| Scenario | Legacy identity | Evidence | +|---|---|---| +| Coexistence | Published `maatify/persistence:1.4.0` at `fc1459b49fe5af5ead17814eeb3ada7a3fa81791` | Dist reference checked in Composer lock. Both savepoint runners resolve to separate types. On one PDO connection, a nested candidate rollback preserves earlier caller and legacy writes. The caller can still roll back both packages' work. The candidate also executes the catalog workflow. | +| Differential | [Unpublished legacy source](https://github.com/Maatify/persistence/tree/1f6a7aafe601090b7e55f60d83794364afdefe67) | Exact source archive installed in its own consumer root alongside the candidate. The same fixture, assertions, and observable pagination results apply to both packages. | + +## Behavior evidence map + +The [behavior contract](../architecture/BEHAVIOR_CONTRACT.md) labels published legacy as A, unpublished legacy as B, and this package as C. + +| Behavior | Evidence and limits | +|---|---| +| B-01, B-02, B-03, B-06, B-07 | Differential catalog exercises scoped/nullable allocation, a locking allocation followed by insert in the same transaction, a clamped move, no-op and missing moves, and soft-delete/row visibility. B-02 and the B-03 locking-snapshot change use B as the source baseline. Fine-grained lock footprint and stale-clamp behavior stay covered by the repository's Integration suite. | +| B-04 | Published and source coexistence scenarios prove nested savepoint isolation through transaction runners. The differential catalog also triggers a CHECK constraint failure after a range shift and proves local rollback preserves earlier caller work against B. The remaining Ordering-specific lazy rollback branches stay covered by `ScopedOrderingManagerLazySavepointTest` and `ScopedOrderingManagerSavepointWiringTest`; it is a documented delta from A. | +| B-05, B-10, B-13 | The catalog checks owned commit/rollback, move return values and callback exception identity against B. Fail-closed PDO control failure behavior is an approved delta from A and remains exercised by the named repository tests. Exception FQCNs and the package marker differ. | +| B-08, B-08a, B-09 | Differential catalog compares pagination normalization, filtering, ordering and the result envelope. A second real connection commits between the two count queries without timing sleeps. Autocommit can observe inconsistent counts and caller-owned InnoDB repeatable read gives a single snapshot. The PHP DTO name changes while the observable array and JSON payloads agree. | +| B-11, B-12 | Nested savepoints are checked against both legacy identities; rollback preserves exception identity and caller ownership. The source comparison uses the Gen2 runner names; default retry is disabled in the catalog. The real deadlock replay and fail-closed retry branches remain covered by `DeadlockRetryIntegrationTest` and `DeadlockRetryFailClosedTest`. Retry is a delta from A. | +| B-14, B-15, B-16 | Harness SQL and transactions execute on the named MySQL cell. Existing Integration and compatibility jobs remain required. Engine evidence, Composer constraints, and archive scope remain distinct. Exact release artifact verification belongs to WU-11. | + +The orchestrator is checked by the package's PHPStan configuration. The independent consumer fixture is syntax-checked and validated through execution in the two production Composer roots, where its legacy imports resolve. + +The harness is additional to Unit, Regression, Integration and Examples Smoke. This evidence concerns an unpublished candidate, with no production host migration or support-range claim. DEC-009 remains deferred, and DEC-013 records test versions. diff --git a/harness/consumer.php b/harness/consumer.php new file mode 100644 index 0000000..f115ec6 --- /dev/null +++ b/harness/consumer.php @@ -0,0 +1,280 @@ + PDO::ERRMODE_EXCEPTION, PDO::ATTR_EMULATE_PREPARES => false], + ); + } + + public function prepare(string $query, array $options = []): PDOStatement|false + { + ($this->beforePrepare)?->__invoke($query); + + return parent::prepare($query, $options); + } +} + +function createCatalog(PDO $pdo, string $table): void +{ + $pdo->exec("CREATE TABLE `{$table}` ( + id INT NOT NULL PRIMARY KEY, + scope_id INT NULL, + position INT NOT NULL, + deleted_at DATETIME NULL, + stamp VARCHAR(8) NOT NULL DEFAULT 'ready', + CHECK (stamp <> 'blocked'), + KEY scope_position (scope_id, position) + ) ENGINE=InnoDB"); +} + +/** @return list */ +function catalogIds(PDO $pdo, string $table): array +{ + return array_map(intval(...), $pdo->query("SELECT id FROM `{$table}` ORDER BY id")->fetchAll(PDO::FETCH_COLUMN)); +} + +/** @return array */ +function catalogPage(PDO $pdo, string $table, bool $legacy): array +{ + $total = "SELECT COUNT(*) FROM `{$table}`"; + $filtered = $total . ' WHERE deleted_at IS NULL'; + $data = "SELECT id, position FROM `{$table}` WHERE deleted_at IS NULL"; + $mapper = static fn(array $row): array => ['id' => (int) $row['id'], 'position' => (int) $row['position']]; + if ($legacy) { + $paginator = new LegacyPaginator(); + $query = new LegacyQuery($total, [], $filtered, [], $data, []); + $request = new LegacyRequest(page: '9999', perPage: '0', sortBy: 'invalid'); + $config = new LegacyPaginationConfig(new LegacyWhitelist(['position' => 'position', 'id' => 'id']), 'position', LegacySort::ASC, 'id', LegacySort::DESC); + } else { + $paginator = new PdoPaginator(); + $query = new PdoPaginationQueryDescriptor($total, [], $filtered, [], $data, []); + $request = new PageRequest(page: '9999', perPage: '0', sortBy: 'invalid'); + $config = new PaginationConfig(new SortWhitelist(['position' => 'position', 'id' => 'id']), 'position', SortDirectionEnum::ASC, 'id', SortDirectionEnum::DESC); + } + $result = $paginator->paginate($pdo, $query, $request, $config, $mapper); + verifySame($result->toArray(), json_decode(json_encode($result, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR), 'Pagination array and JSON agree'); + + return $result->toArray(); +} + +/** @return array */ +function exerciseCatalog(CatalogConnection $pdo, string $table, bool $legacy): array +{ + createCatalog($pdo, $table); + $writer = new CatalogConnection(); + try { + $runner = $legacy ? new LegacySourceRunner($pdo) : new PdoTransactionRunner($pdo); + $manager = $legacy ? new LegacyOrdering() : new ScopedOrderingManager(); + $config = $legacy + ? new LegacyOrderingConfig(table: $table, scopeColumn: 'scope_id', orderColumn: 'position', nullableScope: true) + : new ScopedOrderingConfig(table: $table, scopeColumn: 'scope_id', orderColumn: 'position', nullableScope: true); + + $pdo->exec("INSERT INTO `{$table}` (id, scope_id, position, deleted_at) VALUES (1, 7, 1, NULL), (2, 7, 2, NULL), (3, 7, 20, '2026-01-01'), (4, NULL, 1, NULL)"); + verifySame(3, $manager->getNextPosition($pdo, $config, 7), 'Plain allocation excludes deleted rows'); + verifySame(2, $manager->getNextPosition($pdo, $config, null), 'Nullable scope allocation'); + verifySame(true, $manager->rowExistsInScope($pdo, $config, null, 4), 'Nullable scope row exists'); + verifySame(false, $manager->rowExistsInScope($pdo, $config, 7, 3), 'Soft-deleted row is invisible'); + + $position = $runner->run(static function () use ($pdo, $table, $manager, $config): int { + $position = $manager->getNextPositionForUpdate($pdo, $config, 7); + $insert = $pdo->prepare("INSERT INTO `{$table}` (id, scope_id, position, deleted_at) VALUES (5, 7, :position, NULL)"); + $insert->execute(['position' => $position]); + + return $position; + }); + verifySame(3, $position, 'Locking allocation'); + verifySame([1, 2, 3, 4, 5], catalogIds($writer, $table), 'Owned transaction commits observable state'); + verifySame(true, $manager->moveWithinScope($pdo, $config, 7, 1, 99), 'Clamp above the scope maximum'); + verifySame(3, (int) $writer->query("SELECT position FROM `{$table}` WHERE id = 1")->fetchColumn(), 'Move persists the clamped position'); + verifySame(true, $manager->moveWithinScope($pdo, $config, 7, 1, 3), 'Move at current position succeeds'); + verifySame(false, $manager->moveWithinScope($pdo, $config, 7, 99, 2), 'Missing move reports false'); + + $timestampedConfig = $legacy + ? new LegacyOrderingConfig(table: $table, scopeColumn: 'scope_id', orderColumn: 'position', updatedAtColumn: 'stamp') + : new ScopedOrderingConfig(table: $table, scopeColumn: 'scope_id', orderColumn: 'position', updatedAtColumn: 'stamp'); + $beforeMove = $pdo->query("SELECT id, position FROM `{$table}` ORDER BY id")->fetchAll(PDO::FETCH_KEY_PAIR); + $pdo->beginTransaction(); + $pdo->exec("UPDATE `{$table}` SET stamp = 'earlier' WHERE id = 4"); + try { + $manager->moveWithinScope($pdo, $timestampedConfig, 7, 1, 1, 'blocked'); + throw new LogicException('Target write unexpectedly passed its CHECK constraint'); + } catch (PDOException $error) { + verifySame(3819, (int) ($error->errorInfo[1] ?? 0), 'Target write failed after the range shift'); + } + verifySame($beforeMove, $pdo->query("SELECT id, position FROM `{$table}` ORDER BY id")->fetchAll(PDO::FETCH_KEY_PAIR), 'Ordering savepoint reversed the shift'); + verifySame(true, $pdo->inTransaction(), 'Ordering failure retained caller ownership'); + $pdo->commit(); + verifySame('earlier', $writer->query("SELECT stamp FROM `{$table}` WHERE id = 4")->fetchColumn(), 'Earlier caller write survives the failed move'); + + $failure = new RuntimeException('Consumer abort'); + try { + $runner->run(static function () use ($pdo, $table, $failure): never { + $pdo->exec("INSERT INTO `{$table}` (id, scope_id, position, deleted_at) VALUES (6, 7, 4, NULL)"); + throw $failure; + }); + throw new LogicException('Owned runner did not propagate the callback exception'); + } catch (RuntimeException $error) { + verifySame($failure, $error, 'Primary callback exception identity'); + } + verifySame([1, 2, 3, 4, 5], catalogIds($writer, $table), 'Owned transaction rolls back observable state'); + verifySame(false, $pdo->inTransaction(), 'Owned transaction closed'); + + $page = catalogPage($pdo, $table, $legacy); + verifySame([['id' => 4, 'position' => 1]], $page['data'], 'Normalized page applies deterministic tie-breaker'); + verifySame([ + 'page' => 1, 'per_page' => 1, 'total' => 5, 'filtered' => 4, + 'total_pages' => 4, 'has_next' => true, 'has_previous' => false, + 'sort_by' => 'position', 'sort_direction' => 'ASC', + ], $page['pagination'], 'Observable pagination envelope'); + + $pdo->beforePrepare = static function (string $sql) use ($pdo, $writer, $table): void { + if (str_contains($sql, 'COUNT(*)') && str_contains($sql, 'WHERE')) { + $pdo->beforePrepare = null; + $writer->exec("INSERT INTO `{$table}` (id, scope_id, position, deleted_at) VALUES (7, 7, 4, NULL)"); + } + }; + // Start with five visible rows so the between-counts insert makes filtered > total. + $pdo->exec("UPDATE `{$table}` SET deleted_at = NULL WHERE id = 3"); + $withoutSnapshot = catalogPage($pdo, $table, $legacy); + verifySame(5, $withoutSnapshot['pagination']['total'], 'Total ran before the concurrent commit'); + verifySame(6, $withoutSnapshot['pagination']['filtered'], 'Filter observed the concurrent commit'); + verifySame(false, $pdo->inTransaction(), 'Paginator left autocommit intact'); + + $pdo->exec('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ'); + $pdo->beginTransaction(); + $pdo->beforePrepare = static function (string $sql) use ($pdo, $writer, $table): void { + if (str_contains($sql, 'COUNT(*)') && str_contains($sql, 'WHERE')) { + $pdo->beforePrepare = null; + $writer->exec("INSERT INTO `{$table}` (id, scope_id, position, deleted_at) VALUES (8, 7, 5, NULL)"); + } + }; + $withSnapshot = catalogPage($pdo, $table, $legacy); + verifySame(6, $withSnapshot['pagination']['total'], 'Caller snapshot total'); + verifySame(6, $withSnapshot['pagination']['filtered'], 'Caller snapshot filter'); + verifySame(true, $pdo->inTransaction(), 'Caller retained snapshot ownership'); + $pdo->rollBack(); + verifySame([1, 2, 3, 4, 5, 7, 8], catalogIds($writer, $table), 'Both independent commits persisted'); + + return [$page, $withoutSnapshot, $withSnapshot]; + } finally { + $pdo->beforePrepare = null; + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + $pdo->exec("DROP TABLE `{$table}`"); + } +} + +function exerciseSharedConnection(PDO $pdo, bool $legacySource): void +{ + $table = 'consumer_coexistence'; + createCatalog($pdo, $table); + try { + $candidate = new PdoSavepointTransactionRunner($pdo); + $legacy = $legacySource ? new LegacySourceSavepointRunner($pdo) : new LegacyReleasedSavepointRunner($pdo); + verifySame(false, is_a($candidate, $legacy::class), 'Candidate is a distinct type'); + verifySame(false, is_a($legacy, $candidate::class), 'Legacy is a distinct type'); + $pdo->beginTransaction(); + $pdo->exec('INSERT INTO consumer_coexistence (id, scope_id, position, deleted_at) VALUES (1, NULL, 1, NULL)'); + $failure = new RuntimeException('Rollback the nested operation'); + $outerResult = $legacy->run(static function () use ($candidate, $pdo, $failure): string { + $pdo->exec('INSERT INTO consumer_coexistence (id, scope_id, position, deleted_at) VALUES (2, NULL, 2, NULL)'); + try { + $candidate->run(static function () use ($pdo, $failure): never { + $pdo->exec('INSERT INTO consumer_coexistence (id, scope_id, position, deleted_at) VALUES (3, NULL, 3, NULL)'); + throw $failure; + }); + throw new LogicException('Nested runner did not propagate the callback exception'); + } catch (RuntimeException $error) { + verifySame($failure, $error, 'Nested candidate propagated the same exception'); + } + verifySame([1, 2], catalogIds($pdo, 'consumer_coexistence'), 'Nested rollback retained caller and legacy work'); + + return 'outer result'; + }); + verifySame('outer result', $outerResult, 'Legacy savepoint returns the callback result'); + verifySame(true, $pdo->inTransaction(), 'Outer transaction remains caller-owned'); + $pdo->rollBack(); + verifySame([], catalogIds($pdo, $table), 'Caller still decides whether to persist both packages’ work'); + } finally { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + $pdo->exec("DROP TABLE `{$table}`"); + } +} + +$scenario = $argv[1] ?? ''; +if (!in_array($scenario, ['coexistence', 'differential'], true)) { + throw new InvalidArgumentException('Unknown consumer scenario'); +} +verifySame(true, InstalledVersions::isInstalled('maatify/php-persistence', false), 'Candidate installed via Composer'); +verifySame(true, InstalledVersions::isInstalled('maatify/persistence', false), 'Legacy installed via Composer'); +verifySame(false, InstalledVersions::isInstalled('phpunit/phpunit'), 'Consumer does not install package development dependencies'); +verifySame(false, class_exists(Maatify\PhpPersistence\Tests\Integration\IntegrationTestCase::class), 'Production autoload excludes package tests'); +$pdo = new CatalogConnection(); +exerciseSharedConnection($pdo, $scenario === 'differential'); +$candidate = exerciseCatalog($pdo, 'consumer_candidate', false); +if ($scenario === 'differential') { + $legacy = exerciseCatalog($pdo, 'consumer_legacy', true); + verifySame($legacy, $candidate, 'Legacy baseline and candidate observable pagination results'); +} +echo "CONSUMER WORKFLOW OK scenario={$scenario}\n"; diff --git a/scripts/consumer-verification.php b/scripts/consumer-verification.php new file mode 100644 index 0000000..461e40d --- /dev/null +++ b/scripts/consumer-verification.php @@ -0,0 +1,198 @@ + $command + * @param array $environment + */ +function consumerCommand(array $command, string $directory, array $environment = []): string +{ + [$status, $out, $error] = execute($command, $environment, $directory, boxed(180)); + if ($status !== 0) { + throw new RuntimeException($command[0] . ' failed (' . $status . '): ' . $error); + } + + return trim($out); +} + +/** @param array $config */ +function writeConsumerManifest(string $directory, array $config): void +{ + if (!mkdir($directory, 0700) || file_put_contents($directory . '/composer.json', json_encode($config, JSON_THROW_ON_ERROR)) === false) { + throw new RuntimeException('Cannot create clean consumer project'); + } +} + +/** + * Creates one independent Composer root and verifies a production installation before running its scenario. + * + * @param array $environment + * @param array $policy + */ +function runConsumerRoot(string $root, string $workspace, string $scenario, string $candidateRef, string $candidateTree, array $environment, array $policy): int +{ + $directory = $workspace . '/' . $scenario; + $candidateVersion = 'dev-candidate'; + $legacyVersion = $scenario === 'coexistence' ? LEGACY_RELEASE : 'dev-baseline'; + $legacyRef = $scenario === 'coexistence' ? LEGACY_RELEASE_REF : LEGACY_SOURCE_REF; + $repositories = [ + ['type' => 'path', 'url' => $workspace . '/candidate', 'options' => [ + 'symlink' => false, 'reference' => 'none', 'versions' => [CONSUMER_PACKAGE => $candidateVersion], + ]], + ]; + if ($scenario === 'differential') { + $repositories[] = ['type' => 'path', 'url' => $workspace . '/persistence-' . LEGACY_SOURCE_REF, 'options' => [ + 'symlink' => false, 'reference' => 'none', 'versions' => [LEGACY_PACKAGE => $legacyVersion], + ]]; + } + + writeConsumerManifest($directory, [ + 'name' => 'verification/consumer', + 'repositories' => $repositories, + 'require' => [CONSUMER_PACKAGE => $candidateVersion, LEGACY_PACKAGE => $legacyVersion], + 'config' => ['policy' => $policy], + ]); + $composerEnvironment = [ + 'COMPOSER' => $directory . '/composer.json', + 'COMPOSER_HOME' => $directory . '/composer-home', + 'COMPOSER_CACHE_DIR' => $directory . '/composer-cache', + 'COMPOSER_VENDOR_DIR' => $directory . '/vendor', + ]; + consumerCommand(['composer', 'install', '--no-dev', '--no-interaction', '--prefer-dist', '--no-progress'], $directory, $composerEnvironment); + consumerCommand(['composer', 'check-platform-reqs', '--no-dev'], $directory, $composerEnvironment); + $audit = consumerCommand(['composer', 'audit', '--no-dev', '--no-interaction', '--abandoned=fail'], $directory, $composerEnvironment); + echo $audit . "\n"; + + $installed = json_decode((string) file_get_contents($directory . '/composer.lock'), true, 512, JSON_THROW_ON_ERROR); + if (!is_array($installed) || !is_array($installed['packages'] ?? null)) { + throw new RuntimeException('Invalid production lock metadata'); + } + + $references = []; + foreach ($installed['packages'] as $package) { + if (is_array($package) && is_string($package['name'] ?? null)) { + $dist = $package['dist'] ?? null; + $references[$package['name']] = is_array($dist) ? ($dist['reference'] ?? null) : null; + } + } + if (!array_key_exists(CONSUMER_PACKAGE, $references) + || !array_key_exists(LEGACY_PACKAGE, $references) + || ($scenario === 'coexistence' && $references[LEGACY_PACKAGE] !== $legacyRef) + ) { + throw new RuntimeException('Installed lock does not identify the intended dependencies'); + } + + $candidateClass = '/src/Transaction/Repository/PdoTransactionRunner.php'; + $installedPath = $directory . '/vendor/' . CONSUMER_PACKAGE; + $sourceDigest = hash_file('sha256', $workspace . '/candidate' . $candidateClass); + if (is_link($installedPath) || $sourceDigest === false || $sourceDigest !== hash_file('sha256', $installedPath . $candidateClass)) { + throw new RuntimeException('Candidate installation is not an independent copy of the exact artifact'); + } + + if (!copy($root . '/harness/consumer.php', $directory . '/consumer.php')) { + throw new RuntimeException('Cannot stage consumer workload'); + } + echo "CONSUMER-INSTALL scenario={$scenario} candidate={$candidateRef} tree={$candidateTree} legacy={$legacyRef}\n"; + + return passthrough([PHP_BINARY, $directory . '/consumer.php', $scenario], $environment, $directory, boxed(120)); +} + +/** + * @param array $environment + * @param array $policy + */ +function verifyConsumer(string $root, array $environment, array $policy): int +{ + $workspace = sys_get_temp_dir() . '/pp-consumer-' . bin2hex(random_bytes(12)); + if (!mkdir($workspace, 0700)) { + throw new RuntimeException('Cannot create consumer workspace'); + } + + $status = EXIT_INFRASTRUCTURE; + try { + $candidateRef = consumerCommand(['git', 'rev-parse', 'HEAD'], $root); + $candidateTree = consumerCommand(['git', 'rev-parse', $candidateRef . '^{tree}'], $root); + $archive = $workspace . '/candidate.tar'; + consumerCommand(['git', 'archive', '--format=tar', '--output=' . $archive, $candidateRef], $root); + if (!mkdir($workspace . '/candidate', 0700)) { + throw new RuntimeException('Cannot create candidate artifact directory'); + } + consumerCommand(['tar', '-xf', $archive, '-C', $workspace . '/candidate'], $root); + $legacyArchive = $workspace . '/legacy.tar.gz'; + consumerCommand(['curl', '--fail', '--silent', '--show-error', '--location', '--output', $legacyArchive, + 'https://codeload.github.com/Maatify/persistence/tar.gz/' . LEGACY_SOURCE_REF], $workspace); + consumerCommand(['tar', '-xzf', $legacyArchive], $workspace); + + $status = EXIT_TEST_FAILURE; + if (runConsumerRoot($root, $workspace, 'coexistence', $candidateRef, $candidateTree, $environment, $policy) === 0 + && runConsumerRoot($root, $workspace, 'differential', $candidateRef, $candidateTree, $environment, $policy) === 0 + ) { + $status = 0; + } + } catch (RuntimeException $exception) { + fwrite(STDERR, $exception->getMessage() . "\n"); + } finally { + [$cleanupStatus] = execute(['rm', '-rf', '--', $workspace]); + if ($cleanupStatus !== 0 || file_exists($workspace)) { + fwrite(STDERR, "Consumer workspace cleanup failed\n"); + $status = EXIT_INFRASTRUCTURE; + } + } + + return $status; +} + +$root = dirname(__DIR__); +$arguments = $_SERVER['argv'] ?? null; +if (!is_array($arguments) || count($arguments) !== 1) { + fail(EXIT_USAGE, 'Usage: composer test:consumer'); +} +foreach (['PP_UP_TIMEOUT_SECONDS', 'PP_SUITE_TIMEOUT_SECONDS', 'PP_TEARDOWN_TIMEOUT_SECONDS'] as $setting) { + secondsFromEnvironment($setting, 1); +} +startBudget(); + +foreach ([['docker', 'compose', 'version'], ['docker', 'info'], ['composer', '--version'], ['git', '--version'], ['curl', '--version'], ['tar', '--version']] as $check) { + [$status] = execute($check); + if ($status !== 0) { + fail(EXIT_INFRASTRUCTURE, 'Prerequisite failed: ' . implode(' ', $check)); + } +} +if (!extension_loaded('pdo_mysql')) { + fail(EXIT_INFRASTRUCTURE, 'Prerequisite failed: ext-pdo_mysql is not loaded'); +} + +$manifest = json_decode((string) file_get_contents($root . '/composer.json'), true, 512, JSON_THROW_ON_ERROR); +$config = is_array($manifest) ? ($manifest['config'] ?? null) : null; +if (!is_array($config) || !is_array($config['policy'] ?? null)) { + fail(EXIT_USAGE, 'Missing Composer policy'); +} +/** @var array $policy */ +$policy = $config['policy']; + +$cell = loadMatrix($root)['cells'][CONSUMER_CELL]; +$options = ['cell' => CONSUMER_CELL, 'freshRuns' => 2, 'suitePasses' => 1]; +for ($run = 1; $run <= $options['freshRuns']; $run++) { + if (remainingWorkSeconds() < 1) { + fail(EXIT_INFRASTRUCTURE, 'No time remains for another clean consumer lifecycle'); + } + + $status = lifecycle($root, CONSUMER_CELL, $cell, $options, $run, static fn(array $environment): int => verifyConsumer($root, $environment, $policy)); + if ($status !== 0) { + exit($status); + } +} +echo "CONSUMER VERIFICATION OK (two clean lifecycles)\n"; diff --git a/scripts/integration-support.php b/scripts/integration-support.php index 848b125..a1eb5f9 100644 --- a/scripts/integration-support.php +++ b/scripts/integration-support.php @@ -348,8 +348,9 @@ function connect(array $suiteEnvironment, string $user, string $password): PDO * @param list $compose * @param array{engine: string, image: string, tag: string, digest: string, expectedVersion: string, tier: string, platform: ?string, required: bool} $cell * @param array{cell: ?string, freshRuns: int, suitePasses: int} $options + * @param (callable(array): int)|null $verification Optional external verification, with the same service lifecycle and residue checks. */ -function runOnService(string $root, string $cellKey, array $cell, array $options, array $environment, array $compose): int +function runOnService(string $root, string $cellKey, array $cell, array $options, array $environment, array $compose, ?callable $verification = null): int { [$upStatus, , $upError] = execute([...$compose, 'up', '--detach', '--wait', '--wait-timeout', (string) HEALTH_TIMEOUT_SECONDS], $environment, $root, boxed(secondsFromEnvironment('PP_UP_TIMEOUT_SECONDS', HEALTH_TIMEOUT_SECONDS + 420))); if ($upStatus !== 0) { @@ -423,8 +424,11 @@ function runOnService(string $root, string $cellKey, array $cell, array $options return EXIT_INFRASTRUCTURE; } echo '--- suite pass ' . $pass . '/' . $options['suitePasses'] . "\n"; - if (passthrough($phpunit, $suiteEnvironment, $root, boxed(secondsFromEnvironment('PP_SUITE_TIMEOUT_SECONDS', 900))) !== 0) { - return EXIT_TEST_FAILURE; + $result = $verification === null + ? passthrough($phpunit, $suiteEnvironment, $root, boxed(secondsFromEnvironment('PP_SUITE_TIMEOUT_SECONDS', 900))) + : $verification($suiteEnvironment); + if ($result !== 0) { + return $verification === null ? EXIT_TEST_FAILURE : $result; } try { // The application user has no PROCESS privilege; the disposable root account inspects the server. @@ -452,8 +456,9 @@ function runOnService(string $root, string $cellKey, array $cell, array $options * * @param array{engine: string, image: string, tag: string, digest: string, expectedVersion: string, tier: string, platform: ?string, required: bool} $cell * @param array{cell: ?string, freshRuns: int, suitePasses: int} $options + * @param (callable(array): int)|null $verification Optional external verification, with the same service lifecycle and residue checks. */ -function lifecycle(string $root, string $cellKey, array $cell, array $options, int $number): int +function lifecycle(string $root, string $cellKey, array $cell, array $options, int $number, ?callable $verification = null): int { $project = 'phppersist-it-' . bin2hex(random_bytes(4)); $environment = [ @@ -483,7 +488,7 @@ function lifecycle(string $root, string $cellKey, array $cell, array $options, i $status = EXIT_INFRASTRUCTURE; try { - $status = runOnService($root, $cellKey, $cell, $options, $environment, $compose); + $status = runOnService($root, $cellKey, $cell, $options, $environment, $compose, $verification); } finally { $torndown = teardown($compose, $project, $environment, $root); } diff --git a/tests/Regression/IntegrationInfrastructureTest.php b/tests/Regression/IntegrationInfrastructureTest.php index 59de128..824de02 100644 --- a/tests/Regression/IntegrationInfrastructureTest.php +++ b/tests/Regression/IntegrationInfrastructureTest.php @@ -181,7 +181,7 @@ public function testWorkflowFollowsTheHardeningRules(): void $names[] = $chunks[$index]; self::assertStringContainsString('timeout-minutes:', $chunks[$index + 1], $chunks[$index] . ' needs a timeout'); } - self::assertSame(['workflow-lint', 'static', 'compat', 'integration', 'examples-smoke', 'exploratory', 'ci-gate'], $names); + self::assertSame(['workflow-lint', 'static', 'compat', 'integration', 'examples-smoke', 'consumer-verification', 'exploratory', 'ci-gate'], $names); } private static function constantSeconds(string $source, string $name): int @@ -209,7 +209,7 @@ public function testJobTimeoutsCoverTheOrchestrationBudgetTeardownAndSetup(): vo + 600; $workflow = self::read('.github/workflows/ci.yml'); - foreach (['integration', 'examples-smoke', 'exploratory'] as $job) { + foreach (['integration', 'examples-smoke', 'consumer-verification', 'exploratory'] as $job) { self::assertGreaterThanOrEqual( $needed, self::jobTimeoutMinutes($workflow, $job) * 60, @@ -224,7 +224,7 @@ public function testGateInspectsEveryRequiredJob(): void [, $gate] = array_pad(explode("\n ci-gate:\n", $workflow, 2), 2, ''); self::assertMatchesRegularExpression( - '/^ needs: \[workflow-lint, static, compat, integration, examples-smoke\]$/m', + '/^ needs: \[workflow-lint, static, compat, integration, examples-smoke, consumer-verification\]$/m', $gate, ); self::assertStringNotContainsString('exploratory', $gate, 'the exploratory job is not required'); From 84d1a4f1041b95c470ca3b2697a12609d1b7a148 Mon Sep 17 00:00:00 2001 From: Mohamed Abdulalim Date: Thu, 12 Nov 2026 20:20:02 +0200 Subject: [PATCH 2/4] Match repository formatting in consumer manifest --- scripts/consumer-verification.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/consumer-verification.php b/scripts/consumer-verification.php index 461e40d..ccaa05a 100644 --- a/scripts/consumer-verification.php +++ b/scripts/consumer-verification.php @@ -48,11 +48,12 @@ function runConsumerRoot(string $root, string $workspace, string $scenario, stri $candidateVersion = 'dev-candidate'; $legacyVersion = $scenario === 'coexistence' ? LEGACY_RELEASE : 'dev-baseline'; $legacyRef = $scenario === 'coexistence' ? LEGACY_RELEASE_REF : LEGACY_SOURCE_REF; - $repositories = [ - ['type' => 'path', 'url' => $workspace . '/candidate', 'options' => [ - 'symlink' => false, 'reference' => 'none', 'versions' => [CONSUMER_PACKAGE => $candidateVersion], - ]], + $candidateRepository = [ + 'type' => 'path', + 'url' => $workspace . '/candidate', + 'options' => ['symlink' => false, 'reference' => 'none', 'versions' => [CONSUMER_PACKAGE => $candidateVersion]], ]; + $repositories = [$candidateRepository]; if ($scenario === 'differential') { $repositories[] = ['type' => 'path', 'url' => $workspace . '/persistence-' . LEGACY_SOURCE_REF, 'options' => [ 'symlink' => false, 'reference' => 'none', 'versions' => [LEGACY_PACKAGE => $legacyVersion], From ba01adc0f8caef1383af05c65cd5a6c7d336d078 Mon Sep 17 00:00:00 2001 From: Mohamed Abdulalim Date: Thu, 12 Nov 2026 20:26:05 +0200 Subject: [PATCH 3/4] Close WU-10 differential and lifecycle review findings --- docs/guides/CONSUMER_VERIFICATION.md | 6 +- harness/consumer.php | 190 ++++++++++++++++++++++++--- scripts/consumer-verification.php | 76 +++++++---- scripts/integration-support.php | 105 ++++++++++----- 4 files changed, 295 insertions(+), 82 deletions(-) diff --git a/docs/guides/CONSUMER_VERIFICATION.md b/docs/guides/CONSUMER_VERIFICATION.md index ed1e0d5..261af93 100644 --- a/docs/guides/CONSUMER_VERIFICATION.md +++ b/docs/guides/CONSUMER_VERIFICATION.md @@ -6,7 +6,7 @@ The required CI job provisions two separate MySQL 8.4.13 services through the ex | Scenario | Legacy identity | Evidence | |---|---|---| -| Coexistence | Published `maatify/persistence:1.4.0` at `fc1459b49fe5af5ead17814eeb3ada7a3fa81791` | Dist reference checked in Composer lock. Both savepoint runners resolve to separate types. On one PDO connection, a nested candidate rollback preserves earlier caller and legacy writes. The caller can still roll back both packages' work. The candidate also executes the catalog workflow. | +| Coexistence | Published `maatify/persistence:1.4.0` at `fc1459b49fe5af5ead17814eeb3ada7a3fa81791` | Dist reference checked in Composer lock. Both savepoint runners resolve to separate types. On one PDO connection, each package takes its turn as the inner savepoint runner. Rolling back the inner operation preserves earlier caller and outer-runner writes. The caller can still roll back both packages' work. The candidate also executes the catalog workflow. | | Differential | [Unpublished legacy source](https://github.com/Maatify/persistence/tree/1f6a7aafe601090b7e55f60d83794364afdefe67) | Exact source archive installed in its own consumer root alongside the candidate. The same fixture, assertions, and observable pagination results apply to both packages. | ## Behavior evidence map @@ -15,11 +15,11 @@ The [behavior contract](../architecture/BEHAVIOR_CONTRACT.md) labels published l | Behavior | Evidence and limits | |---|---| -| B-01, B-02, B-03, B-06, B-07 | Differential catalog exercises scoped/nullable allocation, a locking allocation followed by insert in the same transaction, a clamped move, no-op and missing moves, and soft-delete/row visibility. B-02 and the B-03 locking-snapshot change use B as the source baseline. Fine-grained lock footprint and stale-clamp behavior stay covered by the repository's Integration suite. | +| B-01, B-02, B-03, B-06, B-07 | Differential catalog exercises scoped/nullable allocation, a locking allocation followed by insert in the same transaction, a clamped move, no-op and missing moves, and soft-delete/row visibility. B-02 and the B-03 locking-snapshot change use B as the source baseline. For both packages, a second connection commits a new maximum after the caller establishes its InnoDB repeatable-read snapshot. The plain read retains the old maximum and the locked move clamps to the newly committed maximum. Fine-grained lock footprint stays covered by the repository's Integration suite. | | B-04 | Published and source coexistence scenarios prove nested savepoint isolation through transaction runners. The differential catalog also triggers a CHECK constraint failure after a range shift and proves local rollback preserves earlier caller work against B. The remaining Ordering-specific lazy rollback branches stay covered by `ScopedOrderingManagerLazySavepointTest` and `ScopedOrderingManagerSavepointWiringTest`; it is a documented delta from A. | | B-05, B-10, B-13 | The catalog checks owned commit/rollback, move return values and callback exception identity against B. Fail-closed PDO control failure behavior is an approved delta from A and remains exercised by the named repository tests. Exception FQCNs and the package marker differ. | | B-08, B-08a, B-09 | Differential catalog compares pagination normalization, filtering, ordering and the result envelope. A second real connection commits between the two count queries without timing sleeps. Autocommit can observe inconsistent counts and caller-owned InnoDB repeatable read gives a single snapshot. The PHP DTO name changes while the observable array and JSON payloads agree. | -| B-11, B-12 | Nested savepoints are checked against both legacy identities; rollback preserves exception identity and caller ownership. The source comparison uses the Gen2 runner names; default retry is disabled in the catalog. The real deadlock replay and fail-closed retry branches remain covered by `DeadlockRetryIntegrationTest` and `DeadlockRetryFailClosedTest`. Retry is a delta from A. | +| B-11, B-12 | Nested savepoints are checked in both directions against both legacy identities. A separate differential fixture uses the two Gen2 runner forms in each package with one configured retry. A bounded pipe handshake and two conflicting InnoDB row locks produce a real `40001/1213` victim. Each form replays once inside a fresh transaction and commits only the second attempt. Retry is a delta from A. Retry exhaustion and fail-closed paths remain covered by `DeadlockRetryIntegrationTest` and `DeadlockRetryFailClosedTest`. | | B-14, B-15, B-16 | Harness SQL and transactions execute on the named MySQL cell. Existing Integration and compatibility jobs remain required. Engine evidence, Composer constraints, and archive scope remain distinct. Exact release artifact verification belongs to WU-11. | The orchestrator is checked by the package's PHPStan configuration. The independent consumer fixture is syntax-checked and validated through execution in the two production Composer roots, where its legacy imports resolve. diff --git a/harness/consumer.php b/harness/consumer.php index f115ec6..85b5b99 100644 --- a/harness/consumer.php +++ b/harness/consumer.php @@ -147,6 +147,17 @@ function exerciseCatalog(CatalogConnection $pdo, string $table, bool $legacy): a verifySame(true, $manager->moveWithinScope($pdo, $config, 7, 1, 3), 'Move at current position succeeds'); verifySame(false, $manager->moveWithinScope($pdo, $config, 7, 99, 2), 'Missing move reports false'); + $pdo->exec('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ'); + $pdo->beginTransaction(); + verifySame(4, $manager->getNextPosition($pdo, $config, 7), 'Initial snapshot sees the old maximum'); + $writer->exec("INSERT INTO `{$table}` (id, scope_id, position, deleted_at) VALUES (9, 7, 9, NULL)"); + verifySame(4, $manager->getNextPosition($pdo, $config, 7), 'Plain snapshot remains stale after the independent commit'); + verifySame(true, $manager->moveWithinScope($pdo, $config, 7, 1, 99), 'Move uses the current locked maximum'); + verifySame(9, (int) $pdo->query("SELECT position FROM `{$table}` WHERE id = 1")->fetchColumn(), 'Move clamps to the new maximum instead of the snapshot maximum'); + $pdo->rollBack(); + verifySame(3, (int) $writer->query("SELECT position FROM `{$table}` WHERE id = 1")->fetchColumn(), 'Caller can undo the stale-snapshot move'); + $writer->exec("DELETE FROM `{$table}` WHERE id = 9"); + $timestampedConfig = $legacy ? new LegacyOrderingConfig(table: $table, scopeColumn: 'scope_id', orderColumn: 'position', updatedAtColumn: 'stamp') : new ScopedOrderingConfig(table: $table, scopeColumn: 'scope_id', orderColumn: 'position', updatedAtColumn: 'stamp'); @@ -232,28 +243,30 @@ function exerciseSharedConnection(PDO $pdo, bool $legacySource): void $legacy = $legacySource ? new LegacySourceSavepointRunner($pdo) : new LegacyReleasedSavepointRunner($pdo); verifySame(false, is_a($candidate, $legacy::class), 'Candidate is a distinct type'); verifySame(false, is_a($legacy, $candidate::class), 'Legacy is a distinct type'); - $pdo->beginTransaction(); - $pdo->exec('INSERT INTO consumer_coexistence (id, scope_id, position, deleted_at) VALUES (1, NULL, 1, NULL)'); - $failure = new RuntimeException('Rollback the nested operation'); - $outerResult = $legacy->run(static function () use ($candidate, $pdo, $failure): string { - $pdo->exec('INSERT INTO consumer_coexistence (id, scope_id, position, deleted_at) VALUES (2, NULL, 2, NULL)'); - try { - $candidate->run(static function () use ($pdo, $failure): never { - $pdo->exec('INSERT INTO consumer_coexistence (id, scope_id, position, deleted_at) VALUES (3, NULL, 3, NULL)'); - throw $failure; - }); - throw new LogicException('Nested runner did not propagate the callback exception'); - } catch (RuntimeException $error) { - verifySame($failure, $error, 'Nested candidate propagated the same exception'); - } - verifySame([1, 2], catalogIds($pdo, 'consumer_coexistence'), 'Nested rollback retained caller and legacy work'); + foreach ([[$legacy, $candidate], [$candidate, $legacy]] as [$outer, $inner]) { + $pdo->beginTransaction(); + $pdo->exec('INSERT INTO consumer_coexistence (id, scope_id, position, deleted_at) VALUES (1, NULL, 1, NULL)'); + $failure = new RuntimeException('Rollback the nested operation'); + $outerResult = $outer->run(static function () use ($inner, $pdo, $failure): string { + $pdo->exec('INSERT INTO consumer_coexistence (id, scope_id, position, deleted_at) VALUES (2, NULL, 2, NULL)'); + try { + $inner->run(static function () use ($pdo, $failure): never { + $pdo->exec('INSERT INTO consumer_coexistence (id, scope_id, position, deleted_at) VALUES (3, NULL, 3, NULL)'); + throw $failure; + }); + throw new LogicException('Nested runner did not propagate the callback exception'); + } catch (RuntimeException $error) { + verifySame($failure, $error, 'Nested runner propagated the same exception'); + } + verifySame([1, 2], catalogIds($pdo, 'consumer_coexistence'), 'Nested rollback retained caller and outer runner work'); - return 'outer result'; - }); - verifySame('outer result', $outerResult, 'Legacy savepoint returns the callback result'); - verifySame(true, $pdo->inTransaction(), 'Outer transaction remains caller-owned'); - $pdo->rollBack(); - verifySame([], catalogIds($pdo, $table), 'Caller still decides whether to persist both packages’ work'); + return 'outer result'; + }); + verifySame('outer result', $outerResult, 'Outer savepoint returns the callback result'); + verifySame(true, $pdo->inTransaction(), 'Outer transaction remains caller-owned'); + $pdo->rollBack(); + verifySame([], catalogIds($pdo, $table), 'Caller still decides whether to persist both packages’ work'); + } } finally { if ($pdo->inTransaction()) { $pdo->rollBack(); @@ -262,7 +275,138 @@ function exerciseSharedConnection(PDO $pdo, bool $legacySource): void } } +/** + * Runs the other side of the retry fixture on an independent PDO connection. + */ +function runRetryPartner(): void +{ + $pdo = new CatalogConnection(); + $pdo->exec('SET SESSION innodb_lock_wait_timeout = 10'); + $pdo->beginTransaction(); + try { + // Modified rows make this transaction heavier than the single-write consumer attempt. + $pdo->exec('UPDATE consumer_retry SET weight = weight + 1 WHERE id >= 100'); + $pdo->query('SELECT id FROM consumer_retry WHERE id = 2 FOR UPDATE')->fetchColumn(); + fwrite(STDOUT, "LOCKED\n"); + fflush(STDOUT); + verifySame("REQUEST\n", fgets(STDIN), 'Retry partner received request to form the cycle'); + $pdo->query('SELECT id FROM consumer_retry WHERE id = 1 FOR UPDATE')->fetchColumn(); + } finally { + $pdo->rollBack(); + } + fwrite(STDOUT, "ROLLED BACK\n"); + fflush(STDOUT); +} + +/** @param resource $pipe */ +function expectRetryPartnerLine($pipe, string $line): void +{ + $read = [$pipe]; + $write = null; + $except = null; + if (stream_select($read, $write, $except, 15) !== 1) { + throw new RuntimeException('Timed out waiting for the retry partner'); + } + verifySame($line . "\n", fgets($pipe), 'Retry partner protocol'); +} + +/** + * Coordinates the conflicting row locks and propagates the actual driver exception to the runner. + */ +function induceConsumerDeadlock(PDO $pdo): never +{ + $pdo->query('SELECT id FROM consumer_retry WHERE id = 1 FOR UPDATE')->fetchColumn(); + $child = proc_open([PHP_BINARY, __FILE__, 'retry-partner'], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => STDERR], $pipes); + if (!is_resource($child)) { + throw new RuntimeException('Cannot start the retry partner'); + } + + $finished = false; + try { + expectRetryPartnerLine($pipes[1], 'LOCKED'); + fwrite($pipes[0], "REQUEST\n"); + fflush($pipes[0]); + try { + $pdo->query('SELECT id FROM consumer_retry WHERE id = 2 FOR UPDATE')->fetchColumn(); + } catch (PDOException $error) { + verifySame('40001', $error->errorInfo[0] ?? null, 'Deadlock SQLSTATE'); + verifySame(1213, (int) ($error->errorInfo[1] ?? 0), 'InnoDB chose the consumer as deadlock victim'); + expectRetryPartnerLine($pipes[1], 'ROLLED BACK'); + $finished = true; + + throw $error; + } + + throw new LogicException('The consumer did not receive a real deadlock'); + } finally { + if (!$finished) { + proc_terminate($child); + } + fclose($pipes[0]); + fclose($pipes[1]); + $status = proc_close($child); + if ($finished) { + verifySame(0, $status, 'Retry partner exited successfully after rollback'); + } + } +} + +/** + * Proves replay from a fresh transaction and a single persisted result for both runner forms. + * + * @return array + */ +function exerciseDeadlockRetry(PDO $pdo, bool $legacy): array +{ + $pdo->exec('CREATE TABLE consumer_retry (id INT PRIMARY KEY, weight INT NOT NULL) ENGINE=InnoDB'); + $observer = new CatalogConnection(); + try { + $pdo->exec('INSERT INTO consumer_retry VALUES (1, 0), (2, 0), (3, 0)'); + $pdo->exec('INSERT INTO consumer_retry WITH RECURSIVE seq(n) AS (SELECT 100 UNION ALL SELECT n + 1 FROM seq WHERE n < 163) SELECT n, 0 FROM seq'); + $pdo->exec('SET SESSION innodb_lock_wait_timeout = 10'); + $runners = $legacy + ? ['plain' => new LegacySourceRunner($pdo, 1), 'savepoint' => new LegacySourceSavepointRunner($pdo, 1)] + : ['plain' => new PdoTransactionRunner($pdo, 1), 'savepoint' => new PdoSavepointTransactionRunner($pdo, 1)]; + $evidence = []; + foreach ($runners as $kind => $runner) { + $attempts = 0; + $result = $runner->run(static function () use ($pdo, &$attempts): string { + $attempts++; + verifySame(true, $pdo->inTransaction(), 'Every attempt begins in an active transaction'); + $weight = (int) $pdo->query('SELECT weight FROM consumer_retry WHERE id = 3')->fetchColumn(); + verifySame(0, $weight, 'The first attempt leaves no write visible to the replay'); + $write = $pdo->prepare('UPDATE consumer_retry SET weight = :attempt WHERE id = 3'); + $write->execute(['attempt' => $attempts]); + if ($attempts === 1) { + induceConsumerDeadlock($pdo); + } + + return 'replayed'; + }); + $committed = (int) $observer->query('SELECT weight FROM consumer_retry WHERE id = 3')->fetchColumn(); + verifySame(2, $attempts, 'One real deadlock replays the full callback once'); + verifySame(2, $committed, 'Only the second attempt is committed'); + verifySame('replayed', $result, 'Replay returns the callback result'); + verifySame(false, $pdo->inTransaction(), 'Runner ended its owned transaction'); + verifySame(0, (int) $observer->query('SELECT SUM(weight) FROM consumer_retry WHERE id >= 100')->fetchColumn(), 'Partner left no writes'); + $evidence[$kind] = ['attempts' => $attempts, 'committed' => $committed, 'result' => $result]; + $pdo->exec('UPDATE consumer_retry SET weight = 0 WHERE id = 3'); + } + + return $evidence; + } finally { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + $pdo->exec('DROP TABLE consumer_retry'); + } +} + $scenario = $argv[1] ?? ''; +if ($scenario === 'retry-partner') { + runRetryPartner(); + exit(0); +} if (!in_array($scenario, ['coexistence', 'differential'], true)) { throw new InvalidArgumentException('Unknown consumer scenario'); } @@ -276,5 +420,9 @@ function exerciseSharedConnection(PDO $pdo, bool $legacySource): void if ($scenario === 'differential') { $legacy = exerciseCatalog($pdo, 'consumer_legacy', true); verifySame($legacy, $candidate, 'Legacy baseline and candidate observable pagination results'); + $candidateRetries = exerciseDeadlockRetry($pdo, false); + $legacyRetries = exerciseDeadlockRetry($pdo, true); + verifySame($legacyRetries, $candidateRetries, 'Both owned runner forms replay and commit equivalently'); + echo 'CONSUMER RETRY OK scenarios=plain,savepoint baseline=candidate,legacy-source' . "\n"; } echo "CONSUMER WORKFLOW OK scenario={$scenario}\n"; diff --git a/scripts/consumer-verification.php b/scripts/consumer-verification.php index ccaa05a..9938991 100644 --- a/scripts/consumer-verification.php +++ b/scripts/consumer-verification.php @@ -20,6 +20,10 @@ */ function consumerCommand(array $command, string $directory, array $environment = []): string { + if (remainingWorkSeconds() < 1) { + throw new RuntimeException('Consumer verification time budget exhausted'); + } + [$status, $out, $error] = execute($command, $environment, $directory, boxed(180)); if ($status !== 0) { throw new RuntimeException($command[0] . ' failed (' . $status . '): ' . $error); @@ -42,7 +46,7 @@ function writeConsumerManifest(string $directory, array $config): void * @param array $environment * @param array $policy */ -function runConsumerRoot(string $root, string $workspace, string $scenario, string $candidateRef, string $candidateTree, array $environment, array $policy): int +function runConsumerRoot(string $workspace, string $scenario, string $candidateRef, string $candidateTree, array $environment, array $policy): int { $directory = $workspace . '/' . $scenario; $candidateVersion = 'dev-candidate'; @@ -103,53 +107,76 @@ function runConsumerRoot(string $root, string $workspace, string $scenario, stri throw new RuntimeException('Candidate installation is not an independent copy of the exact artifact'); } - if (!copy($root . '/harness/consumer.php', $directory . '/consumer.php')) { - throw new RuntimeException('Cannot stage consumer workload'); + consumerCommand(['tar', '-xf', $workspace . '/harness.tar', '-C', $directory], $workspace); + if (remainingWorkSeconds() < 1) { + throw new RuntimeException('Consumer verification time budget exhausted before running ' . $scenario); } echo "CONSUMER-INSTALL scenario={$scenario} candidate={$candidateRef} tree={$candidateTree} legacy={$legacyRef}\n"; return passthrough([PHP_BINARY, $directory . '/consumer.php', $scenario], $environment, $directory, boxed(120)); } -/** - * @param array $environment - * @param array $policy - */ -function verifyConsumer(string $root, array $environment, array $policy): int +function cleanupConsumerWorkspace(string $workspace): bool +{ + [$cleanupStatus] = execute(['rm', '-rf', '--', $workspace]); + if ($cleanupStatus !== 0 || file_exists($workspace)) { + fwrite(STDERR, "Consumer workspace cleanup failed\n"); + + return false; + } + + return true; +} + +/** @param array $environment */ +function verifyConsumer(string $root, string $candidateRef, string $candidateTree, array $environment, int $suiteTimeout): int { $workspace = sys_get_temp_dir() . '/pp-consumer-' . bin2hex(random_bytes(12)); if (!mkdir($workspace, 0700)) { throw new RuntimeException('Cannot create consumer workspace'); } + $overallDeadline = OrchestrationBudget::$deadline; + OrchestrationBudget::$deadline = microtime(true) + boxed($suiteTimeout) + reserveSeconds(); $status = EXIT_INFRASTRUCTURE; try { - $candidateRef = consumerCommand(['git', 'rev-parse', 'HEAD'], $root); - $candidateTree = consumerCommand(['git', 'rev-parse', $candidateRef . '^{tree}'], $root); $archive = $workspace . '/candidate.tar'; consumerCommand(['git', 'archive', '--format=tar', '--output=' . $archive, $candidateRef], $root); if (!mkdir($workspace . '/candidate', 0700)) { throw new RuntimeException('Cannot create candidate artifact directory'); } consumerCommand(['tar', '-xf', $archive, '-C', $workspace . '/candidate'], $root); + consumerCommand(['git', 'archive', '--format=tar', '--output=' . $workspace . '/harness.tar', $candidateRef . ':harness'], $root); + $manifest = json_decode((string) file_get_contents($workspace . '/candidate/composer.json'), true, 512, JSON_THROW_ON_ERROR); + $config = is_array($manifest) ? ($manifest['config'] ?? null) : null; + if (!is_array($config) || !is_array($config['policy'] ?? null)) { + throw new RuntimeException('Missing Composer policy in the candidate archive'); + } + /** @var array $policy */ + $policy = $config['policy']; $legacyArchive = $workspace . '/legacy.tar.gz'; consumerCommand(['curl', '--fail', '--silent', '--show-error', '--location', '--output', $legacyArchive, 'https://codeload.github.com/Maatify/persistence/tar.gz/' . LEGACY_SOURCE_REF], $workspace); consumerCommand(['tar', '-xzf', $legacyArchive], $workspace); $status = EXIT_TEST_FAILURE; - if (runConsumerRoot($root, $workspace, 'coexistence', $candidateRef, $candidateTree, $environment, $policy) === 0 - && runConsumerRoot($root, $workspace, 'differential', $candidateRef, $candidateTree, $environment, $policy) === 0 + if (runConsumerRoot($workspace, 'coexistence', $candidateRef, $candidateTree, $environment, $policy) === 0 + && runConsumerRoot($workspace, 'differential', $candidateRef, $candidateTree, $environment, $policy) === 0 ) { $status = 0; } } catch (RuntimeException $exception) { fwrite(STDERR, $exception->getMessage() . "\n"); } finally { - [$cleanupStatus] = execute(['rm', '-rf', '--', $workspace]); - if ($cleanupStatus !== 0 || file_exists($workspace)) { - fwrite(STDERR, "Consumer workspace cleanup failed\n"); - $status = EXIT_INFRASTRUCTURE; + OrchestrationBudget::$deadline = $overallDeadline; + try { + if (!cleanupConsumerWorkspace($workspace)) { + $status = EXIT_INFRASTRUCTURE; + } + } catch (ConsumerVerificationInterrupted $interruption) { + cleanupConsumerWorkspace($workspace); + + throw $interruption; } } @@ -161,9 +188,10 @@ function verifyConsumer(string $root, array $environment, array $policy): int if (!is_array($arguments) || count($arguments) !== 1) { fail(EXIT_USAGE, 'Usage: composer test:consumer'); } -foreach (['PP_UP_TIMEOUT_SECONDS', 'PP_SUITE_TIMEOUT_SECONDS', 'PP_TEARDOWN_TIMEOUT_SECONDS'] as $setting) { +foreach (['PP_UP_TIMEOUT_SECONDS', 'PP_TEARDOWN_TIMEOUT_SECONDS'] as $setting) { secondsFromEnvironment($setting, 1); } +$suiteTimeout = secondsFromEnvironment('PP_SUITE_TIMEOUT_SECONDS', 900); startBudget(); foreach ([['docker', 'compose', 'version'], ['docker', 'info'], ['composer', '--version'], ['git', '--version'], ['curl', '--version'], ['tar', '--version']] as $check) { @@ -176,14 +204,12 @@ function verifyConsumer(string $root, array $environment, array $policy): int fail(EXIT_INFRASTRUCTURE, 'Prerequisite failed: ext-pdo_mysql is not loaded'); } -$manifest = json_decode((string) file_get_contents($root . '/composer.json'), true, 512, JSON_THROW_ON_ERROR); -$config = is_array($manifest) ? ($manifest['config'] ?? null) : null; -if (!is_array($config) || !is_array($config['policy'] ?? null)) { - fail(EXIT_USAGE, 'Missing Composer policy'); +try { + $candidateRef = consumerCommand(['git', 'rev-parse', 'HEAD'], $root); + $candidateTree = consumerCommand(['git', 'rev-parse', $candidateRef . '^{tree}'], $root); +} catch (RuntimeException $exception) { + fail(EXIT_INFRASTRUCTURE, $exception->getMessage()); } -/** @var array $policy */ -$policy = $config['policy']; - $cell = loadMatrix($root)['cells'][CONSUMER_CELL]; $options = ['cell' => CONSUMER_CELL, 'freshRuns' => 2, 'suitePasses' => 1]; for ($run = 1; $run <= $options['freshRuns']; $run++) { @@ -191,7 +217,7 @@ function verifyConsumer(string $root, array $environment, array $policy): int fail(EXIT_INFRASTRUCTURE, 'No time remains for another clean consumer lifecycle'); } - $status = lifecycle($root, CONSUMER_CELL, $cell, $options, $run, static fn(array $environment): int => verifyConsumer($root, $environment, $policy)); + $status = lifecycle($root, CONSUMER_CELL, $cell, $options, $run, static fn(array $environment): int => verifyConsumer($root, $candidateRef, $candidateTree, $environment, $suiteTimeout)); if ($status !== 0) { exit($status); } diff --git a/scripts/integration-support.php b/scripts/integration-support.php index a1eb5f9..4dba93d 100644 --- a/scripts/integration-support.php +++ b/scripts/integration-support.php @@ -19,6 +19,11 @@ const TIMEOUT_EXIT_STATUS = 124; const TERMINATION_GRACE_SECONDS = 5; +/** + * A catchable signal in consumer verification. It unwinds the workspace before Compose teardown. + */ +final class ConsumerVerificationInterrupted extends Exception {} + /** * Positive integer seconds from an environment variable, or the default when it is not set. * A set but invalid value is a configuration error. @@ -145,32 +150,44 @@ function execute(array $command, array $environment = [], ?string $cwd = null, i stream_set_blocking($stream, false); } $deadline = microtime(true) + $timeoutSeconds; - while ($open !== []) { - if (microtime(true) >= $deadline) { - terminateProcess($process); - foreach ($open as $stream) { - fclose($stream); + try { + while ($open !== []) { + if (microtime(true) >= $deadline) { + terminateProcess($process); + foreach ($open as $stream) { + fclose($stream); + } + proc_close($process); + + return [TIMEOUT_EXIT_STATUS, $buffers[1], $buffers[2] . "\n" . $command[0] . ' timed out after ' . $timeoutSeconds . " seconds\n"]; } - proc_close($process); - - return [TIMEOUT_EXIT_STATUS, $buffers[1], $buffers[2] . "\n" . $command[0] . ' timed out after ' . $timeoutSeconds . " seconds\n"]; - } - $read = array_values($open); - $write = null; - $except = null; - if (stream_select($read, $write, $except, 1) === false) { - break; - } - foreach ($open as $index => $stream) { - $chunk = fread($stream, 65536); - if ($chunk !== false && $chunk !== '') { - $buffers[$index] .= $chunk; + $read = array_values($open); + $write = null; + $except = null; + if (stream_select($read, $write, $except, 1) === false) { + break; + } + foreach ($open as $index => $stream) { + $chunk = fread($stream, 65536); + if ($chunk !== false && $chunk !== '') { + $buffers[$index] .= $chunk; + } + if (feof($stream)) { + fclose($stream); + unset($open[$index]); + } } - if (feof($stream)) { + } + } catch (ConsumerVerificationInterrupted $exception) { + terminateProcess($process); + foreach ($open as $stream) { + if (is_resource($stream)) { fclose($stream); - unset($open[$index]); } } + proc_close($process); + + throw $exception; } return [proc_close($process), $buffers[1], $buffers[2]]; @@ -190,21 +207,30 @@ function passthrough(array $command, array $environment, string $cwd, int $timeo return 127; } $deadline = microtime(true) + $timeoutSeconds; - while (true) { - $status = proc_get_status($process); - if (!$status['running']) { - $code = proc_close($process); + try { + while (true) { + $status = proc_get_status($process); + if (!$status['running']) { + $code = proc_close($process); + + return $code === -1 ? $status['exitcode'] : $code; + } + if (microtime(true) >= $deadline) { + terminateProcess($process); + proc_close($process); + fwrite(STDERR, $command[0] . ' timed out after ' . $timeoutSeconds . " seconds\n"); - return $code === -1 ? $status['exitcode'] : $code; + return TIMEOUT_EXIT_STATUS; + } + usleep(100_000); } - if (microtime(true) >= $deadline) { + } catch (ConsumerVerificationInterrupted $exception) { + if (is_resource($process)) { terminateProcess($process); proc_close($process); - fwrite(STDERR, $command[0] . ' timed out after ' . $timeoutSeconds . " seconds\n"); - - return TIMEOUT_EXIT_STATUS; } - usleep(100_000); + + throw $exception; } } @@ -477,7 +503,13 @@ function lifecycle(string $root, string $cellKey, array $cell, array $options, i if (function_exists('pcntl_async_signals') && function_exists('pcntl_signal')) { pcntl_async_signals(true); foreach ([SIGINT, SIGTERM] as $signal) { - pcntl_signal($signal, static function () use ($compose, $project, $environment, $root): void { + pcntl_signal($signal, static function () use ($compose, $project, $environment, $root, $verification): void { + if ($verification !== null) { + pcntl_signal(SIGINT, SIG_IGN); + pcntl_signal(SIGTERM, SIG_IGN); + + throw new ConsumerVerificationInterrupted(); + } teardown($compose, $project, $environment, $root); exit(130); }); @@ -489,8 +521,15 @@ function lifecycle(string $root, string $cellKey, array $cell, array $options, i $status = EXIT_INFRASTRUCTURE; try { $status = runOnService($root, $cellKey, $cell, $options, $environment, $compose, $verification); + } catch (ConsumerVerificationInterrupted) { + $status = 130; } finally { - $torndown = teardown($compose, $project, $environment, $root); + try { + $torndown = teardown($compose, $project, $environment, $root); + } catch (ConsumerVerificationInterrupted) { + $torndown = teardown($compose, $project, $environment, $root); + $status = 130; + } } return $status === 0 && !$torndown ? EXIT_INFRASTRUCTURE : $status; From d14d09bfc0cb78825d0e41ca0ea4480ad506f35b Mon Sep 17 00:00:00 2001 From: Mohamed Abdulalim Date: Thu, 12 Nov 2026 20:45:04 +0200 Subject: [PATCH 4/4] Keep consumer suite deadlines within the overall budget --- scripts/consumer-verification.php | 11 ++++++++-- scripts/integration-support.php | 15 +++++++++++++ .../IntegrationInfrastructureTest.php | 22 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/scripts/consumer-verification.php b/scripts/consumer-verification.php index 9938991..2644ed4 100644 --- a/scripts/consumer-verification.php +++ b/scripts/consumer-verification.php @@ -131,15 +131,22 @@ function cleanupConsumerWorkspace(string $workspace): bool /** @param array $environment */ function verifyConsumer(string $root, string $candidateRef, string $candidateTree, array $environment, int $suiteTimeout): int { + $overallDeadline = OrchestrationBudget::$deadline; + $deadline = suiteDeadline($overallDeadline, microtime(true), $suiteTimeout, reserveSeconds()); + if ($deadline === null) { + fwrite(STDERR, "Consumer verification time budget exhausted before suite startup\n"); + + return EXIT_INFRASTRUCTURE; + } + $workspace = sys_get_temp_dir() . '/pp-consumer-' . bin2hex(random_bytes(12)); if (!mkdir($workspace, 0700)) { throw new RuntimeException('Cannot create consumer workspace'); } - $overallDeadline = OrchestrationBudget::$deadline; - OrchestrationBudget::$deadline = microtime(true) + boxed($suiteTimeout) + reserveSeconds(); $status = EXIT_INFRASTRUCTURE; try { + OrchestrationBudget::$deadline = $deadline; $archive = $workspace . '/candidate.tar'; consumerCommand(['git', 'archive', '--format=tar', '--output=' . $archive, $candidateRef], $root); if (!mkdir($workspace . '/candidate', 0700)) { diff --git a/scripts/integration-support.php b/scripts/integration-support.php index 4dba93d..7d29528 100644 --- a/scripts/integration-support.php +++ b/scripts/integration-support.php @@ -101,6 +101,21 @@ function boxed(int $ownSeconds): int return max(1, min($ownSeconds, remainingWorkSeconds())); } + +/** + * Computes a suite's absolute deadline without extending the overall budget. + * Returns null when fewer than one whole second remains for work before teardown. + */ +function suiteDeadline(?float $overallDeadline, float $now, int $suiteTimeout, int $reserve): ?float +{ + $deadline = $now + $suiteTimeout + $reserve; + if ($overallDeadline !== null) { + $deadline = min($deadline, $overallDeadline); + } + + return $deadline - $now - $reserve >= 1 ? $deadline : null; +} + /** * Stops a process that exceeded its deadline: SIGTERM, a short grace period, then SIGKILL. * diff --git a/tests/Regression/IntegrationInfrastructureTest.php b/tests/Regression/IntegrationInfrastructureTest.php index 824de02..27ae578 100644 --- a/tests/Regression/IntegrationInfrastructureTest.php +++ b/tests/Regression/IntegrationInfrastructureTest.php @@ -218,6 +218,28 @@ public function testJobTimeoutsCoverTheOrchestrationBudgetTeardownAndSetup(): vo } } + #[DataProvider('suiteDeadlines')] + public function testSuiteDeadlinePreservesBothBudgets(?float $overall, float $now, int $suite, ?float $expected): void + { + require_once self::root() . '/scripts/integration-support.php'; + + self::assertSame($expected, suiteDeadline($overall, $now, $suite, 210)); + } + + /** @return array */ + public static function suiteDeadlines(): array + { + return [ + 'overall budget is tighter' => [322.5, 100.25, 30, 322.5], + 'suite budget is tighter' => [400.0, 100.25, 30, 340.25], + 'equal budgets' => [340.25, 100.25, 30, 340.25], + 'no overall budget' => [null, 100.25, 30, 340.25], + 'one whole second remains' => [311.25, 100.25, 30, 311.25], + 'second cycle has subsecond work budget' => [322.5, 111.75, 30, null], + 'second cycle has exhausted work budget' => [322.5, 112.5, 30, null], + ]; + } + public function testGateInspectsEveryRequiredJob(): void { $workflow = self::read('.github/workflows/ci.yml');