diff --git a/CHANGELOG.md b/CHANGELOG.md index 90a526f..8b8940f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,10 @@ ### Insight -- Added `evolvephp/insight` as a public experimental diagnostic-batch collection foundation. The package consumes Core execution observations through the existing observation sink boundary, collects immutable bounded per-execution batches and keeps persistence, retention, redaction, dashboards, watchers, OpenTelemetry, Evolve Observe, runtime wiring and independent package release deferred. -- Added storage-neutral Insight batch snapshots, projection from finalized diagnostic batches, a minimal diagnostic batch store contract, an in-memory store for tests and short-lived local development, and a sink adapter that projects and stores accepted batches while keeping persistent storage and retention deferred. -- Added a versioned primitive diagnostic batch snapshot codec and an optional SQLite diagnostic batch store for explicit local-development persistence through caller-supplied SQLite `PDO` connections, while keeping runtime wiring, retention, pruning and application database integration deferred. +- Added `evolvephp/insight` as a public experimental diagnostic-batch collection foundation. The package consumes Core execution observations through the existing observation sink boundary, collects immutable bounded per-execution batches and keeps redaction, dashboards, watchers, OpenTelemetry, Evolve Observe, runtime wiring and independent package release deferred. +- Added storage-neutral Insight batch snapshots, projection from finalized diagnostic batches, a minimal diagnostic batch store contract, an in-memory store for tests and short-lived local development, and a sink adapter that projects and stores accepted batches while keeping runtime wiring deferred. +- Added a versioned primitive diagnostic batch snapshot codec and an optional SQLite diagnostic batch store for explicit local-development persistence through caller-supplied SQLite `PDO` connections, while keeping application database integration deferred. +- Added explicit count-bounded retention to the first-party Insight diagnostic batch stores, with deterministic oldest-first pruning for in-memory and SQLite storage. ### Repository diff --git a/packages/insight/README.md b/packages/insight/README.md index c857481..6654c2c 100644 --- a/packages/insight/README.md +++ b/packages/insight/README.md @@ -23,14 +23,16 @@ Current bounded behavior: Current storage behavior: - `DiagnosticBatchStore` defines minimal save, exact execution-identifier lookup and newest-first bounded reads -- `InMemoryDiagnosticBatchStore` keeps snapshots in insertion order and returns newest batches first -- `SqliteDiagnosticBatchStore` provides an optional persistent local-development adapter for caller-supplied SQLite `PDO` connections +- `InMemoryDiagnosticBatchStore` keeps snapshots in insertion order, requires an explicit positive stored-batch count and prunes oldest retained snapshots first +- `SqliteDiagnosticBatchStore` provides an optional persistent local-development adapter for caller-supplied SQLite `PDO` connections, requires an explicit positive stored-batch count and prunes by SQLite sequence order - `DiagnosticBatchSnapshotCodec` stores snapshots as a versioned primitive JSON payload and rejects malformed, unsupported or unexpected persisted data during reads - duplicate execution identifiers are rejected and never replace the original snapshot - `StoringDiagnosticBatchSink` projects accepted batches and saves the detached snapshot through a configured store - storage remains optional and unwired; installing Insight does not create storage automatically -The in-memory store is unbounded and suitable only for tests or short-lived local development. The SQLite store creates its diagnostic table only when explicitly constructed with a SQLite `PDO`; it does not discover a default path, read application database configuration or automatically use application storage. SQLite reads use deterministic insertion-order sequence values for newest-first results, not diagnostic timestamps. Corrupt stored payloads are not decoded during construction, but the affected `find()` or `latest()` read fails explicitly. Neither store provides retention, pruning or eviction. +Both first-party stores use explicit count-bounded retention. Callers configure a positive maximum stored-batch count, and successful unique saves leave no more than that many retained diagnostic batch snapshots. Pruning is deterministic oldest-first insertion order; SQLite uses its monotonic sequence column as the insertion-order authority. Duplicate execution identifiers are rejected before pruning, so a duplicate save at capacity does not evict or mutate retained snapshots. Time-based retention is not provided. + +The SQLite store creates its diagnostic table only when explicitly constructed with a SQLite `PDO`; it does not discover a default path, read application database configuration or automatically use application storage. SQLite reads use deterministic insertion-order sequence values for newest-first results, not diagnostic timestamps. Corrupt stored payloads are not decoded during construction, but the affected `find()` or `latest()` read fails explicitly. `pdo_sqlite` is a runtime requirement only for applications that explicitly use `SqliteDiagnosticBatchStore`; it is not required for installing or using the non-SQLite Insight functionality. @@ -50,7 +52,7 @@ https://github.com/josiahking/evolvephp ## Current Limitations -This package does not provide retention, pruning, redaction, rich diagnostic capture, filtering, sampling, dashboards, watchers, OpenTelemetry, Evolve Observe, trace propagation, runtime composition, automatic registration, application database integration or production-ready diagnostics. +This package does not provide time-based retention, redaction, rich diagnostic capture, filtering, sampling, dashboards, watchers, OpenTelemetry, Evolve Observe, trace propagation, runtime composition, automatic registration, application database integration or production-ready diagnostics. ## Licence diff --git a/packages/insight/src/Storage/InMemoryDiagnosticBatchStore.php b/packages/insight/src/Storage/InMemoryDiagnosticBatchStore.php index 7618b1b..452410b 100644 --- a/packages/insight/src/Storage/InMemoryDiagnosticBatchStore.php +++ b/packages/insight/src/Storage/InMemoryDiagnosticBatchStore.php @@ -16,6 +16,13 @@ final class InMemoryDiagnosticBatchStore implements DiagnosticBatchStore */ private array $insertionOrder = array(); + public function __construct(private int $maximumStoredBatchCount) + { + if ($this->maximumStoredBatchCount <= 0) { + throw new \InvalidArgumentException('Maximum stored diagnostic batch count must be positive.'); + } + } + public function save(DiagnosticBatchSnapshot $snapshot): void { $identifier = $snapshot->executionIdentifier(); @@ -24,6 +31,8 @@ public function save(DiagnosticBatchSnapshot $snapshot): void throw new \LogicException('Diagnostic batch snapshot already exists for execution identifier.'); } + $this->pruneOldestSnapshotsForIncomingSave(); + $this->snapshotsByIdentifier[$identifier] = $snapshot; $this->insertionOrder[] = $identifier; } @@ -46,4 +55,17 @@ public function latest(int $limit): array $identifiers, ); } + + private function pruneOldestSnapshotsForIncomingSave(): void + { + while (count($this->insertionOrder) >= $this->maximumStoredBatchCount) { + $oldestIdentifier = array_shift($this->insertionOrder); + + if ($oldestIdentifier === null) { + return; + } + + unset($this->snapshotsByIdentifier[$oldestIdentifier]); + } + } } diff --git a/packages/insight/src/Storage/SqliteDiagnosticBatchStore.php b/packages/insight/src/Storage/SqliteDiagnosticBatchStore.php index e9c9b02..7ebb10e 100644 --- a/packages/insight/src/Storage/SqliteDiagnosticBatchStore.php +++ b/packages/insight/src/Storage/SqliteDiagnosticBatchStore.php @@ -10,12 +10,19 @@ final class SqliteDiagnosticBatchStore implements DiagnosticBatchStore private DiagnosticBatchSnapshotCodec $codec; - public function __construct(private \PDO $pdo, ?DiagnosticBatchSnapshotCodec $codec = null) - { + public function __construct( + private \PDO $pdo, + private int $maximumStoredBatchCount, + ?DiagnosticBatchSnapshotCodec $codec = null, + ) { if ($this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME) !== 'sqlite') { throw new \InvalidArgumentException('Sqlite diagnostic batch store requires a SQLite PDO connection.'); } + if ($this->maximumStoredBatchCount <= 0) { + throw new \InvalidArgumentException('Maximum stored diagnostic batch count must be positive.'); + } + $this->codec = $codec ?? new DiagnosticBatchSnapshotCodec(); $this->createSchema(); } @@ -28,12 +35,16 @@ public function save(DiagnosticBatchSnapshot $snapshot): void throw new \LogicException('Diagnostic batch snapshot already exists for execution identifier.'); } + $payload = $this->codec->encode($snapshot); + + $this->pruneOldestSnapshotsForIncomingSave(); + $statement = $this->prepare( 'INSERT INTO ' . self::TABLE . ' (execution_identifier, snapshot_payload) VALUES (:execution_identifier, :snapshot_payload)' ); $this->execute($statement, array( 'execution_identifier' => $identifier, - 'snapshot_payload' => $this->codec->encode($snapshot), + 'snapshot_payload' => $payload, )); } @@ -101,6 +112,38 @@ private function identifierExists(string $identifier): bool return $statement->fetchColumn() !== false; } + private function pruneOldestSnapshotsForIncomingSave(): void + { + $storedBatchCount = $this->storedBatchCount(); + + if ($storedBatchCount < $this->maximumStoredBatchCount) { + return; + } + + $deleteCount = $storedBatchCount - $this->maximumStoredBatchCount + 1; + $statement = $this->prepare( + 'DELETE FROM ' . self::TABLE . ' + WHERE sequence IN ( + SELECT sequence FROM ' . self::TABLE . ' + ORDER BY sequence ASC + LIMIT :delete_count + )' + ); + $statement->bindValue('delete_count', $deleteCount, \PDO::PARAM_INT); + + if (!$statement->execute()) { + throw new \RuntimeException('Failed to execute SQLite diagnostic batch store statement.'); + } + } + + private function storedBatchCount(): int + { + $statement = $this->prepare('SELECT COUNT(*) FROM ' . self::TABLE); + $this->execute($statement, array()); + + return (int) $statement->fetchColumn(); + } + /** * @param array $row */ diff --git a/packages/insight/tests/Unit/Storage/InMemoryDiagnosticBatchStoreTest.php b/packages/insight/tests/Unit/Storage/InMemoryDiagnosticBatchStoreTest.php index 4baf101..b025c67 100644 --- a/packages/insight/tests/Unit/Storage/InMemoryDiagnosticBatchStoreTest.php +++ b/packages/insight/tests/Unit/Storage/InMemoryDiagnosticBatchStoreTest.php @@ -12,7 +12,7 @@ final class InMemoryDiagnosticBatchStoreTest extends TestCase { public function testSaveThenFindReturnsTheStoredSnapshot(): void { - $store = new InMemoryDiagnosticBatchStore(); + $store = new InMemoryDiagnosticBatchStore(10); $snapshot = $this->snapshot('execution-1'); $store->save($snapshot); @@ -20,9 +20,25 @@ public function testSaveThenFindReturnsTheStoredSnapshot(): void self::assertSame($snapshot, $store->find('execution-1')); } + public function testZeroMaximumStoredBatchCountIsRejected(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Maximum stored diagnostic batch count must be positive.'); + + new InMemoryDiagnosticBatchStore(0); + } + + public function testNegativeMaximumStoredBatchCountIsRejected(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Maximum stored diagnostic batch count must be positive.'); + + new InMemoryDiagnosticBatchStore(-1); + } + public function testUnknownFindReturnsNullAndExactIdentifierSemanticsAreUsed(): void { - $store = new InMemoryDiagnosticBatchStore(); + $store = new InMemoryDiagnosticBatchStore(10); $store->save($this->snapshot('execution-10')); self::assertNull($store->find('execution-1')); @@ -31,7 +47,7 @@ public function testUnknownFindReturnsNullAndExactIdentifierSemanticsAreUsed(): public function testMultipleSavesAndLatestAreDeterministicNewestFirst(): void { - $store = new InMemoryDiagnosticBatchStore(); + $store = new InMemoryDiagnosticBatchStore(10); $first = $this->snapshot('execution-1'); $second = $this->snapshot('execution-2'); $third = $this->snapshot('execution-3'); @@ -47,7 +63,7 @@ public function testMultipleSavesAndLatestAreDeterministicNewestFirst(): void public function testLatestLimitGreaterThanCountReturnsAllSnapshots(): void { - $store = new InMemoryDiagnosticBatchStore(); + $store = new InMemoryDiagnosticBatchStore(10); $first = $this->snapshot('execution-1'); $store->save($first); @@ -55,9 +71,54 @@ public function testLatestLimitGreaterThanCountReturnsAllSnapshots(): void self::assertSame(array($first), $store->latest(5)); } + public function testBelowCapacitySavesDoNotEvict(): void + { + $store = new InMemoryDiagnosticBatchStore(3); + $first = $this->snapshot('execution-1'); + $second = $this->snapshot('execution-2'); + + $store->save($first); + $store->save($second); + + self::assertSame($first, $store->find('execution-1')); + self::assertSame($second, $store->find('execution-2')); + self::assertSame(array($second, $first), $store->latest(10)); + } + + public function testCapacityEvictsOldestSnapshotBeforeSavingUniqueSnapshot(): void + { + $store = new InMemoryDiagnosticBatchStore(2); + $first = $this->snapshot('execution-1'); + $second = $this->snapshot('execution-2'); + $third = $this->snapshot('execution-3'); + + $store->save($first); + $store->save($second); + $store->save($third); + + self::assertNull($store->find('execution-1')); + self::assertSame($second, $store->find('execution-2')); + self::assertSame($third, $store->find('execution-3')); + self::assertSame(array($third, $second), $store->latest(10)); + } + + public function testMaximumOneRetainsOnlyNewestSuccessfullyStoredSnapshot(): void + { + $store = new InMemoryDiagnosticBatchStore(1); + $first = $this->snapshot('execution-1'); + $second = $this->snapshot('execution-2'); + + $store->save($first); + $store->save($second); + + self::assertNull($store->find('execution-1')); + self::assertSame($second, $store->find('execution-2')); + self::assertSame(array($second), $store->latest(10)); + } + public function testZeroLimitIsRejected(): void { - $store = new InMemoryDiagnosticBatchStore(); + $store = new InMemoryDiagnosticBatchStore(10); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Latest limit must be positive.'); @@ -67,7 +128,7 @@ public function testZeroLimitIsRejected(): void public function testNegativeLimitIsRejected(): void { - $store = new InMemoryDiagnosticBatchStore(); + $store = new InMemoryDiagnosticBatchStore(10); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Latest limit must be positive.'); @@ -77,7 +138,7 @@ public function testNegativeLimitIsRejected(): void public function testDuplicateIdentifierIsRejectedAndDoesNotReplaceOriginal(): void { - $store = new InMemoryDiagnosticBatchStore(); + $store = new InMemoryDiagnosticBatchStore(10); $original = $this->snapshot('execution-1', 'http-request'); $duplicate = $this->snapshot('execution-1', 'cli-command'); @@ -94,6 +155,28 @@ public function testDuplicateIdentifierIsRejectedAndDoesNotReplaceOriginal(): vo self::assertSame(array($original), $store->latest(10)); } + public function testDuplicateAtCapacityDoesNotEvictOrMutateRetainedSnapshots(): void + { + $store = new InMemoryDiagnosticBatchStore(2); + $first = $this->snapshot('execution-1', 'http-request'); + $second = $this->snapshot('execution-2', 'queue-message'); + $duplicate = $this->snapshot('execution-1', 'cli-command'); + + $store->save($first); + $store->save($second); + + try { + $store->save($duplicate); + self::fail('Expected duplicate execution identifier to be rejected.'); + } catch (\LogicException $exception) { + self::assertSame('Diagnostic batch snapshot already exists for execution identifier.', $exception->getMessage()); + } + + self::assertSame($first, $store->find('execution-1')); + self::assertSame($second, $store->find('execution-2')); + self::assertSame(array($second, $first), $store->latest(10)); + } + private function snapshot(string $identifier, string $kind = 'http-request'): DiagnosticBatchSnapshot { return new DiagnosticBatchSnapshot($identifier, $kind, array(), 0); diff --git a/packages/insight/tests/Unit/Storage/SqliteDiagnosticBatchStoreTest.php b/packages/insight/tests/Unit/Storage/SqliteDiagnosticBatchStoreTest.php index b3acb0a..dcfad6b 100644 --- a/packages/insight/tests/Unit/Storage/SqliteDiagnosticBatchStoreTest.php +++ b/packages/insight/tests/Unit/Storage/SqliteDiagnosticBatchStoreTest.php @@ -20,7 +20,23 @@ protected function setUp(): void public function testSuccessfulExplicitSqliteConstruction(): void { - self::assertSame(array(), (new SqliteDiagnosticBatchStore($this->pdo()))->latest(1)); + self::assertSame(array(), (new SqliteDiagnosticBatchStore($this->pdo(), 10))->latest(1)); + } + + public function testZeroMaximumStoredBatchCountIsRejected(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Maximum stored diagnostic batch count must be positive.'); + + new SqliteDiagnosticBatchStore($this->pdo(), 0); + } + + public function testNegativeMaximumStoredBatchCountIsRejected(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Maximum stored diagnostic batch count must be positive.'); + + new SqliteDiagnosticBatchStore($this->pdo(), -1); } public function testNonSqlitePdoConnectionIsRejected(): void @@ -41,14 +57,14 @@ public function getAttribute(int $attribute): mixed $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Sqlite diagnostic batch store requires a SQLite PDO connection.'); - new SqliteDiagnosticBatchStore($pdo); + new SqliteDiagnosticBatchStore($pdo, 10); } public function testConstructionCreatesSchema(): void { $pdo = $this->pdo(); - new SqliteDiagnosticBatchStore($pdo); + new SqliteDiagnosticBatchStore($pdo, 10); self::assertSame( 'insight_diagnostic_batches', @@ -58,7 +74,7 @@ public function testConstructionCreatesSchema(): void public function testSaveThenExactFind(): void { - $store = new SqliteDiagnosticBatchStore($this->pdo()); + $store = new SqliteDiagnosticBatchStore($this->pdo(), 10); $snapshot = $this->snapshot('execution-1'); $store->save($snapshot); @@ -70,7 +86,7 @@ public function testSaveThenExactFind(): void public function testMultipleSavesAndLatestAreDeterministicNewestFirst(): void { - $store = new SqliteDiagnosticBatchStore($this->pdo()); + $store = new SqliteDiagnosticBatchStore($this->pdo(), 10); $first = $this->snapshot('execution-1'); $second = $this->snapshot('execution-2'); $third = $this->snapshot('execution-3'); @@ -86,7 +102,7 @@ public function testMultipleSavesAndLatestAreDeterministicNewestFirst(): void public function testLatestLimitGreaterThanCountReturnsAllSnapshots(): void { - $store = new SqliteDiagnosticBatchStore($this->pdo()); + $store = new SqliteDiagnosticBatchStore($this->pdo(), 3); $snapshot = $this->snapshot('execution-1'); $store->save($snapshot); @@ -94,9 +110,70 @@ public function testLatestLimitGreaterThanCountReturnsAllSnapshots(): void self::assertEquals(array($snapshot), $store->latest(5)); } + public function testBelowCapacitySavesDoNotEvict(): void + { + $store = new SqliteDiagnosticBatchStore($this->pdo(), 3); + $first = $this->snapshot('execution-1'); + $second = $this->snapshot('execution-2'); + + $store->save($first); + $store->save($second); + + self::assertEquals($first, $store->find('execution-1')); + self::assertEquals($second, $store->find('execution-2')); + self::assertEquals(array($second, $first), $store->latest(10)); + } + + public function testCapacityEvictsOldestSnapshotBeforeSavingUniqueSnapshot(): void + { + $store = new SqliteDiagnosticBatchStore($this->pdo(), 2); + $first = $this->snapshot('execution-1'); + $second = $this->snapshot('execution-2'); + $third = $this->snapshot('execution-3'); + + $store->save($first); + $store->save($second); + $store->save($third); + + self::assertNull($store->find('execution-1')); + self::assertEquals($second, $store->find('execution-2')); + self::assertEquals($third, $store->find('execution-3')); + self::assertEquals(array($third, $second), $store->latest(10)); + } + + public function testMaximumOneRetainsOnlyNewestSuccessfullyStoredSnapshot(): void + { + $store = new SqliteDiagnosticBatchStore($this->pdo(), 1); + $first = $this->snapshot('execution-1'); + $second = $this->snapshot('execution-2'); + + $store->save($first); + $store->save($second); + + self::assertNull($store->find('execution-1')); + self::assertEquals($second, $store->find('execution-2')); + self::assertEquals(array($second), $store->latest(10)); + } + + public function testRetentionSurvivesReopeningSameDatabase(): void + { + $path = $this->temporaryDatabasePath(); + $first = $this->snapshot('execution-1'); + $second = $this->snapshot('execution-2'); + $third = $this->snapshot('execution-3'); + + (new SqliteDiagnosticBatchStore($this->pdo($path), 2))->save($first); + (new SqliteDiagnosticBatchStore($this->pdo($path), 2))->save($second); + $reopened = new SqliteDiagnosticBatchStore($this->pdo($path), 2); + $reopened->save($third); + + self::assertNull($reopened->find('execution-1')); + self::assertEquals(array($third, $second), $reopened->latest(10)); + } + public function testNonPositiveLatestLimitIsRejected(): void { - $store = new SqliteDiagnosticBatchStore($this->pdo()); + $store = new SqliteDiagnosticBatchStore($this->pdo(), 10); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Latest limit must be positive.'); @@ -106,7 +183,7 @@ public function testNonPositiveLatestLimitIsRejected(): void public function testDuplicateIdentifierIsRejectedAndDoesNotReplaceOriginal(): void { - $store = new SqliteDiagnosticBatchStore($this->pdo()); + $store = new SqliteDiagnosticBatchStore($this->pdo(), 10); $original = $this->snapshot('execution-1', 'http-request'); $duplicate = $this->snapshot('execution-1', 'cli-command'); @@ -123,12 +200,101 @@ public function testDuplicateIdentifierIsRejectedAndDoesNotReplaceOriginal(): vo self::assertEquals(array($original), $store->latest(10)); } + public function testDuplicateAtCapacityDoesNotEvictOrMutateRetainedSnapshots(): void + { + $store = new SqliteDiagnosticBatchStore($this->pdo(), 2); + $first = $this->snapshot('execution-1', 'http-request'); + $second = $this->snapshot('execution-2', 'queue-message'); + $duplicate = $this->snapshot('execution-1', 'cli-command'); + + $store->save($first); + $store->save($second); + + try { + $store->save($duplicate); + self::fail('Expected duplicate execution identifier to be rejected.'); + } catch (\LogicException $exception) { + self::assertSame('Diagnostic batch snapshot already exists for execution identifier.', $exception->getMessage()); + } + + self::assertEquals($first, $store->find('execution-1')); + self::assertEquals($second, $store->find('execution-2')); + self::assertEquals(array($second, $first), $store->latest(10)); + } + + public function testExistingOverCapacityRowsAreNotPrunedByConstructionButAreReducedOnNextUniqueSave(): void + { + $pdo = $this->pdo(); + new SqliteDiagnosticBatchStore($pdo, 10); + $this->insertRaw($pdo, 'execution-1', $this->payload('execution-1')); + $this->insertRaw($pdo, 'execution-2', $this->payload('execution-2')); + $this->insertRaw($pdo, 'execution-3', $this->payload('execution-3')); + + $store = new SqliteDiagnosticBatchStore($pdo, 2); + + self::assertSame(3, $this->countRows($pdo)); + + $fourth = $this->snapshot('execution-4'); + $store->save($fourth); + + self::assertSame(2, $this->countRows($pdo)); + self::assertNull($store->find('execution-1')); + self::assertNull($store->find('execution-2')); + self::assertEquals(array($fourth, $this->snapshot('execution-3')), $store->latest(10)); + } + + public function testUnsupportedCandidateSnapshotFailsEncodingBeforePruning(): void + { + $store = new SqliteDiagnosticBatchStore($this->pdo(), 1); + $original = $this->snapshot('execution-1'); + $unsupported = $this->snapshot('execution-2', 'unsupported-kind'); + + $store->save($original); + + try { + $store->save($unsupported); + self::fail('Expected unsupported candidate snapshot to be rejected.'); + } catch (\InvalidArgumentException $exception) { + self::assertSame('Execution kind is not supported by this diagnostic snapshot format.', $exception->getMessage()); + } + + self::assertEquals($original, $store->find('execution-1')); + self::assertNull($store->find('execution-2')); + self::assertEquals(array($original), $store->latest(10)); + } + + public function testPruneFailurePreventsIncomingInsert(): void + { + $pdo = $this->pdo(); + $store = new SqliteDiagnosticBatchStore($pdo, 1); + $original = $this->snapshot('execution-1'); + $candidate = $this->snapshot('execution-2'); + + $store->save($original); + $pdo->exec( + "CREATE TRIGGER block_diagnostic_batch_delete + BEFORE DELETE ON insight_diagnostic_batches + BEGIN + SELECT RAISE(ABORT, 'delete blocked'); + END" + ); + + $this->expectException(\RuntimeException::class); + + try { + $store->save($candidate); + } finally { + self::assertEquals($original, $store->find('execution-1')); + self::assertNull($store->find('execution-2')); + } + } + public function testPayloadIndexIdentifierMismatchIsRejectedAsCorruption(): void { $pdo = $this->pdo(); - new SqliteDiagnosticBatchStore($pdo); + new SqliteDiagnosticBatchStore($pdo, 10); $this->insertRaw($pdo, 'indexed-id', '{"version":1,"execution_identifier":"payload-id","execution_kind":"http-request","observations":[],"dropped_observation_count":0}'); - $store = new SqliteDiagnosticBatchStore($pdo); + $store = new SqliteDiagnosticBatchStore($pdo, 10); $this->expectException(\UnexpectedValueException::class); $this->expectExceptionMessage('Persisted diagnostic batch identifier does not match its index.'); @@ -139,9 +305,9 @@ public function testPayloadIndexIdentifierMismatchIsRejectedAsCorruption(): void public function testCorruptPayloadReadFailsExplicitly(): void { $pdo = $this->pdo(); - new SqliteDiagnosticBatchStore($pdo); + new SqliteDiagnosticBatchStore($pdo, 10); $this->insertRaw($pdo, 'execution-1', '{'); - $store = new SqliteDiagnosticBatchStore($pdo); + $store = new SqliteDiagnosticBatchStore($pdo, 10); $this->expectException(\InvalidArgumentException::class); @@ -151,15 +317,15 @@ public function testCorruptPayloadReadFailsExplicitly(): void public function testConstructorDoesNotEagerlyDecodeCorruptExistingRows(): void { $pdo = $this->pdo(); - new SqliteDiagnosticBatchStore($pdo); + new SqliteDiagnosticBatchStore($pdo, 10); $this->insertRaw($pdo, 'execution-1', '{'); - self::assertNull((new SqliteDiagnosticBatchStore($pdo))->find('missing-execution')); + self::assertNull((new SqliteDiagnosticBatchStore($pdo, 10))->find('missing-execution')); } public function testObservationOrderAndNullableFieldsSurvivePersistence(): void { - $store = new SqliteDiagnosticBatchStore($this->pdo()); + $store = new SqliteDiagnosticBatchStore($this->pdo(), 10); $snapshot = new DiagnosticBatchSnapshot( 'execution-1', 'http-request', @@ -186,9 +352,9 @@ public function testObservationOrderAndNullableFieldsSurvivePersistence(): void self::assertNull($found?->observations()[1]->reuseDecision()); } - private function pdo(): \PDO + private function pdo(?string $path = null): \PDO { - return new \PDO('sqlite::memory:'); + return new \PDO($path === null ? 'sqlite::memory:' : 'sqlite:' . $path); } private function snapshot(string $identifier, string $kind = 'http-request'): DiagnosticBatchSnapshot @@ -206,4 +372,25 @@ private function insertRaw(\PDO $pdo, string $identifier, string $payload): void 'snapshot_payload' => $payload, )); } + + private function payload(string $identifier): string + { + return '{"version":1,"execution_identifier":"' . $identifier . '","execution_kind":"http-request","observations":[],"dropped_observation_count":0}'; + } + + private function countRows(\PDO $pdo): int + { + return (int) $pdo->query('SELECT COUNT(*) FROM insight_diagnostic_batches')->fetchColumn(); + } + + private function temporaryDatabasePath(): string + { + $path = tempnam(sys_get_temp_dir(), 'evolve-insight-'); + + if ($path === false) { + throw new \RuntimeException('Failed to create temporary SQLite database path.'); + } + + return $path; + } } diff --git a/packages/insight/tests/Unit/Storage/StoringDiagnosticBatchSinkTest.php b/packages/insight/tests/Unit/Storage/StoringDiagnosticBatchSinkTest.php index 52eeef6..69272c0 100644 --- a/packages/insight/tests/Unit/Storage/StoringDiagnosticBatchSinkTest.php +++ b/packages/insight/tests/Unit/Storage/StoringDiagnosticBatchSinkTest.php @@ -26,7 +26,7 @@ public function testItImplementsDiagnosticBatchSink(): void public function testItProjectsThenStoresOneFinalizedBatch(): void { - $store = new InMemoryDiagnosticBatchStore(); + $store = new InMemoryDiagnosticBatchStore(10); $sink = new StoringDiagnosticBatchSink(new DiagnosticBatchProjector(), $store); $identifier = ExecutionIdentifier::generate(); $batch = new DiagnosticBatch( @@ -56,7 +56,7 @@ public function testStoreFailurePropagates(): void public function testProjectorStoreCollaborationDoesNotMutateOriginalBatch(): void { - $store = new InMemoryDiagnosticBatchStore(); + $store = new InMemoryDiagnosticBatchStore(10); $sink = new StoringDiagnosticBatchSink(new DiagnosticBatchProjector(), $store); $identifier = ExecutionIdentifier::generate(); $observation = new Observation(ObservationType::ExecutionCompleted, $identifier, ExecutionKind::QueueMessage);