From f5fa74d2afaf3ecf826df6b4a02f61c27b67dfb5 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sat, 27 Jun 2026 13:11:21 -0400 Subject: [PATCH 1/2] Add the base change stream classes. Part 2 of replication / sync / changelogs. --- .../Backend/Storage/Journal/ChangeScope.php | 62 ++++++++++ .../Backend/Storage/Journal/ChangeStream.php | 54 +++++++++ .../Backend/Storage/Journal/ScopeType.php | 26 ++++ .../Storage/Journal/ChangeScopeTest.php | 62 ++++++++++ .../Storage/Journal/ChangeStreamTest.php | 111 ++++++++++++++++++ 5 files changed, 315 insertions(+) create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeScope.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeStream.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ScopeType.php create mode 100644 tests/unit/Server/Backend/Storage/Journal/ChangeScopeTest.php create mode 100644 tests/unit/Server/Backend/Storage/Journal/ChangeStreamTest.php diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeScope.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeScope.php new file mode 100644 index 00000000..fae4d6ff --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeScope.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Journal; + +use FreeDSx\Ldap\Entry\Dn; + +/** + * A base DN plus extent that decides whether a change falls within a consumer's view. + * + * @author Chad Sikorra + */ +final readonly class ChangeScope +{ + private function __construct( + private Dn $baseDn, + private ScopeType $type, + ) {} + + public static function baseObject(Dn $baseDn): self + { + return new self( + $baseDn, + ScopeType::BaseObject, + ); + } + + public static function oneLevel(Dn $baseDn): self + { + return new self( + $baseDn, + ScopeType::OneLevel, + ); + } + + public static function wholeSubtree(Dn $baseDn): self + { + return new self( + $baseDn, + ScopeType::WholeSubtree, + ); + } + + public function contains(Dn $dn): bool + { + return match ($this->type) { + ScopeType::BaseObject => $dn->normalize()->toString() === $this->baseDn->normalize()->toString(), + ScopeType::OneLevel => $dn->isChildOf($this->baseDn), + ScopeType::WholeSubtree => $dn->isDescendantOf($this->baseDn), + }; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeStream.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeStream.php new file mode 100644 index 00000000..20d583be --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeStream.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Journal; + +/** + * Read-only view over the journal: the seam the audit sink and RFC 4533 provider consume. + * + * @author Chad Sikorra + */ +final readonly class ChangeStream +{ + public function __construct( + private ChangeJournalInterface $journal, + ) {} + + /** + * Records with seq greater than $afterSeq, optionally narrowed to a scope, in ascending seq order. + * + * @api + * + * @return iterable + */ + public function since( + int $afterSeq = 0, + ?ChangeScope $scope = null, + ): iterable { + foreach ($this->journal->read($afterSeq) as $record) { + if ($scope === null || $scope->contains($record->change->dn)) { + yield $record; + } + } + } + + /** + * The highest seq currently in the journal; the high-water mark a consumer cookie advances to. + * + * @api + */ + public function latestSeq(): int + { + return $this->journal->latestSeq(); + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ScopeType.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ScopeType.php new file mode 100644 index 00000000..fc81fe84 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ScopeType.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Journal; + +/** + * The DIT extent a change stream covers. + * + * @author Chad Sikorra + */ +enum ScopeType +{ + case BaseObject; + case OneLevel; + case WholeSubtree; +} diff --git a/tests/unit/Server/Backend/Storage/Journal/ChangeScopeTest.php b/tests/unit/Server/Backend/Storage/Journal/ChangeScopeTest.php new file mode 100644 index 00000000..a0330048 --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Journal/ChangeScopeTest.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Journal; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeScope; +use PHPUnit\Framework\TestCase; + +final class ChangeScopeTest extends TestCase +{ + private Dn $base; + + protected function setUp(): void + { + $this->base = new Dn('dc=example,dc=com'); + } + + public function test_whole_subtree_contains_the_base_and_all_descendants(): void + { + $scope = ChangeScope::wholeSubtree($this->base); + + self::assertTrue($scope->contains(new Dn('dc=example,dc=com'))); + self::assertTrue($scope->contains(new Dn('cn=a,dc=example,dc=com'))); + self::assertTrue($scope->contains(new Dn('cn=x,ou=people,dc=example,dc=com'))); + self::assertFalse($scope->contains(new Dn('dc=other,dc=com'))); + } + + public function test_one_level_contains_only_direct_children(): void + { + $scope = ChangeScope::oneLevel($this->base); + + self::assertTrue($scope->contains(new Dn('cn=a,dc=example,dc=com'))); + self::assertFalse($scope->contains(new Dn('dc=example,dc=com'))); + self::assertFalse($scope->contains(new Dn('cn=x,ou=people,dc=example,dc=com'))); + } + + public function test_base_object_contains_only_the_base(): void + { + $scope = ChangeScope::baseObject($this->base); + + self::assertTrue($scope->contains(new Dn('dc=example,dc=com'))); + self::assertFalse($scope->contains(new Dn('cn=a,dc=example,dc=com'))); + } + + public function test_base_object_match_is_case_insensitive(): void + { + $scope = ChangeScope::baseObject($this->base); + + self::assertTrue($scope->contains(new Dn('DC=Example,DC=Com'))); + } +} diff --git a/tests/unit/Server/Backend/Storage/Journal/ChangeStreamTest.php b/tests/unit/Server/Backend/Storage/Journal/ChangeStreamTest.php new file mode 100644 index 00000000..4be1eba0 --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Journal/ChangeStreamTest.php @@ -0,0 +1,111 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Journal; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Protocol\Authorization\AuthzId; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeRecord; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeScope; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeStream; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeType; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\InMemoryChangeJournal; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\PendingChange; +use PHPUnit\Framework\TestCase; + +final class ChangeStreamTest extends TestCase +{ + private InMemoryChangeJournal $journal; + + private ChangeStream $subject; + + protected function setUp(): void + { + $this->journal = new InMemoryChangeJournal(); + $this->subject = new ChangeStream($this->journal); + } + + public function test_since_returns_only_records_after_the_given_seq(): void + { + $this->append('cn=a,dc=example,dc=com'); + $this->append('cn=b,dc=example,dc=com'); + $this->append('cn=c,dc=example,dc=com'); + + $seqs = array_map( + static fn(ChangeRecord $record): int => $record->seq, + iterator_to_array($this->subject->since(1)), + ); + + self::assertSame( + [2, 3], + $seqs, + ); + } + + public function test_since_without_a_scope_returns_every_record(): void + { + $this->append('cn=a,dc=example,dc=com'); + $this->append('cn=b,dc=other,dc=com'); + + self::assertCount( + 2, + iterator_to_array($this->subject->since()), + ); + } + + public function test_since_with_a_scope_filters_by_dn(): void + { + $this->append('dc=example,dc=com'); + $this->append('cn=a,dc=example,dc=com'); + $this->append('cn=b,dc=other,dc=com'); + + $dns = array_map( + static fn(ChangeRecord $record): string => $record->change->dn->toString(), + iterator_to_array($this->subject->since( + 0, + ChangeScope::wholeSubtree(new Dn('dc=example,dc=com')), + )), + ); + + self::assertSame( + ['dc=example,dc=com', 'cn=a,dc=example,dc=com'], + $dns, + ); + } + + public function test_latest_seq_reflects_the_journal_high_water_mark(): void + { + self::assertSame( + 0, + $this->subject->latestSeq(), + ); + + $this->append('cn=a,dc=example,dc=com'); + $this->append('cn=b,dc=example,dc=com'); + + self::assertSame( + 2, + $this->subject->latestSeq(), + ); + } + + private function append(string $dn): void + { + $this->journal->append(new PendingChange( + changeType: ChangeType::Add, + dn: new Dn($dn), + entryUuid: '11111111-1111-4111-8111-111111111111', + authzId: AuthzId::anonymous(), + )); + } +} From afb18b4a42bbdedad5b8a078be77f0b1d3c49de6 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sat, 27 Jun 2026 16:01:10 -0400 Subject: [PATCH 2/2] Add pruning / retention policy for the journal. Reorg the structure. Part 3 of replication / sync / changelogs. --- .../Storage/Adapter/InMemoryStorage.php | 4 +- .../ChangeJournalingInterface.php | 4 +- .../Journal/{ => Capture}/ChangeRecorder.php | 4 +- .../Journal/{ => Change}/ChangeRecord.php | 3 +- .../Journal/{ => Change}/ChangeType.php | 2 +- .../Journal/{ => Change}/PendingChange.php | 2 +- .../Journal/ChangeJournalInterface.php | 10 +++ .../Storage/Journal/InMemoryChangeJournal.php | 27 ++++++ .../Journal/{ => Read}/ChangeScope.php | 2 +- .../Journal/{ => Read}/ChangeStream.php | 5 +- .../Storage/Journal/{ => Read}/ScopeType.php | 2 +- .../Storage/Journal/RetentionPolicy.php | 41 +++++++++ .../Storage/WritableStorageBackend.php | 2 +- .../Adapter/WritableStorageBackendTest.php | 8 +- .../{ => Capture}/ChangeRecorderTest.php | 8 +- .../Journal/InMemoryChangeJournalTest.php | 84 ++++++++++++++++++- .../Journal/{ => Read}/ChangeScopeTest.php | 4 +- .../Journal/{ => Read}/ChangeStreamTest.php | 12 +-- .../Storage/Journal/RetentionPolicyTest.php | 43 ++++++++++ 19 files changed, 237 insertions(+), 30 deletions(-) rename src/FreeDSx/Ldap/Server/Backend/Storage/Journal/{ => Capture}/ChangeJournalingInterface.php (81%) rename src/FreeDSx/Ldap/Server/Backend/Storage/Journal/{ => Capture}/ChangeRecorder.php (94%) rename src/FreeDSx/Ldap/Server/Backend/Storage/Journal/{ => Change}/ChangeRecord.php (83%) rename src/FreeDSx/Ldap/Server/Backend/Storage/Journal/{ => Change}/ChangeType.php (89%) rename src/FreeDSx/Ldap/Server/Backend/Storage/Journal/{ => Change}/PendingChange.php (93%) rename src/FreeDSx/Ldap/Server/Backend/Storage/Journal/{ => Read}/ChangeScope.php (95%) rename src/FreeDSx/Ldap/Server/Backend/Storage/Journal/{ => Read}/ChangeStream.php (86%) rename src/FreeDSx/Ldap/Server/Backend/Storage/Journal/{ => Read}/ScopeType.php (88%) create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Journal/RetentionPolicy.php rename tests/unit/Server/Backend/Storage/Journal/{ => Capture}/ChangeRecorderTest.php (95%) rename tests/unit/Server/Backend/Storage/Journal/{ => Read}/ChangeScopeTest.php (96%) rename tests/unit/Server/Backend/Storage/Journal/{ => Read}/ChangeStreamTest.php (89%) create mode 100644 tests/unit/Server/Backend/Storage/Journal/RetentionPolicyTest.php diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorage.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorage.php index a598aaf7..1b3800e9 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorage.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorage.php @@ -18,10 +18,10 @@ use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Support\ArrayEntryStorageTrait; use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Capture\ChangeJournalingInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\PendingChange; use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeJournalInterface; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeJournalingInterface; use FreeDSx\Ldap\Server\Backend\Storage\Journal\InMemoryChangeJournal; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\PendingChange; use FreeDSx\Ldap\Server\Backend\Storage\StorageListOptions; /** diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeJournalingInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Capture/ChangeJournalingInterface.php similarity index 81% rename from src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeJournalingInterface.php rename to src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Capture/ChangeJournalingInterface.php index 9f2863de..680afb59 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeJournalingInterface.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Capture/ChangeJournalingInterface.php @@ -11,7 +11,9 @@ * file that was distributed with this source code. */ -namespace FreeDSx\Ldap\Server\Backend\Storage\Journal; +namespace FreeDSx\Ldap\Server\Backend\Storage\Journal\Capture; + +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\PendingChange; /** * Append a change within the active write boundary. diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeRecorder.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Capture/ChangeRecorder.php similarity index 94% rename from src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeRecorder.php rename to src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Capture/ChangeRecorder.php index f9370a99..0004ebe4 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeRecorder.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Capture/ChangeRecorder.php @@ -11,12 +11,14 @@ * file that was distributed with this source code. */ -namespace FreeDSx\Ldap\Server\Backend\Storage\Journal; +namespace FreeDSx\Ldap\Server\Backend\Storage\Journal\Capture; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Schema\Definition\AttributeTypeOid; use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeType; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\PendingChange; use FreeDSx\Ldap\Server\Backend\Write\WriteContext; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeRecord.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Change/ChangeRecord.php similarity index 83% rename from src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeRecord.php rename to src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Change/ChangeRecord.php index 233391e0..a1fb35d5 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeRecord.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Change/ChangeRecord.php @@ -11,9 +11,10 @@ * file that was distributed with this source code. */ -namespace FreeDSx\Ldap\Server\Backend\Storage\Journal; +namespace FreeDSx\Ldap\Server\Backend\Storage\Journal\Change; use DateTimeImmutable; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\ReplicaId; /** * A PendingChange stamped by the journal. diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeType.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Change/ChangeType.php similarity index 89% rename from src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeType.php rename to src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Change/ChangeType.php index d592a904..385a2351 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeType.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Change/ChangeType.php @@ -11,7 +11,7 @@ * file that was distributed with this source code. */ -namespace FreeDSx\Ldap\Server\Backend\Storage\Journal; +namespace FreeDSx\Ldap\Server\Backend\Storage\Journal\Change; /** * The kind of write a change-journal record captures. diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/PendingChange.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Change/PendingChange.php similarity index 93% rename from src/FreeDSx/Ldap/Server/Backend/Storage/Journal/PendingChange.php rename to src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Change/PendingChange.php index da513570..30bb920a 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/PendingChange.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Change/PendingChange.php @@ -11,7 +11,7 @@ * file that was distributed with this source code. */ -namespace FreeDSx\Ldap\Server\Backend\Storage\Journal; +namespace FreeDSx\Ldap\Server\Backend\Storage\Journal\Change; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeJournalInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeJournalInterface.php index a31701c9..18af579a 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeJournalInterface.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeJournalInterface.php @@ -13,6 +13,9 @@ namespace FreeDSx\Ldap\Server\Backend\Storage\Journal; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeRecord; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\PendingChange; + /** * Append-only log of committed writes. * @@ -40,4 +43,11 @@ public function read(int $afterSeq = 0): iterable; * @api */ public function latestSeq(): int; + + /** + * Drop records that fall outside the policy; returns how many were removed. seq keeps climbing. + * + * @api + */ + public function prune(RetentionPolicy $policy): int; } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/InMemoryChangeJournal.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/InMemoryChangeJournal.php index 9096e3fa..5f74989d 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/InMemoryChangeJournal.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/InMemoryChangeJournal.php @@ -13,6 +13,8 @@ namespace FreeDSx\Ldap\Server\Backend\Storage\Journal; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeRecord; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\PendingChange; use FreeDSx\Ldap\Server\Clock\ClockInterface; use FreeDSx\Ldap\Server\Clock\SystemClock; @@ -61,4 +63,29 @@ public function latestSeq(): int { return $this->seq; } + + public function prune(RetentionPolicy $policy): int + { + $before = count($this->records); + $records = $this->records; + + if ($policy->maxRecords !== null && count($records) > $policy->maxRecords) { + $records = array_slice( + $records, + count($records) - $policy->maxRecords, + ); + } + + if ($policy->maxAgeSeconds !== null) { + $oldest = $this->clock->now()->getTimestamp() - $policy->maxAgeSeconds; + $records = array_filter( + $records, + static fn(ChangeRecord $record): bool => $record->createdAt->getTimestamp() >= $oldest, + ); + } + + $this->records = array_values($records); + + return $before - count($this->records); + } } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeScope.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Read/ChangeScope.php similarity index 95% rename from src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeScope.php rename to src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Read/ChangeScope.php index fae4d6ff..dc214b22 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeScope.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Read/ChangeScope.php @@ -11,7 +11,7 @@ * file that was distributed with this source code. */ -namespace FreeDSx\Ldap\Server\Backend\Storage\Journal; +namespace FreeDSx\Ldap\Server\Backend\Storage\Journal\Read; use FreeDSx\Ldap\Entry\Dn; diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeStream.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Read/ChangeStream.php similarity index 86% rename from src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeStream.php rename to src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Read/ChangeStream.php index 20d583be..5c8be05b 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ChangeStream.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Read/ChangeStream.php @@ -11,7 +11,10 @@ * file that was distributed with this source code. */ -namespace FreeDSx\Ldap\Server\Backend\Storage\Journal; +namespace FreeDSx\Ldap\Server\Backend\Storage\Journal\Read; + +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeRecord; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeJournalInterface; /** * Read-only view over the journal: the seam the audit sink and RFC 4533 provider consume. diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ScopeType.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Read/ScopeType.php similarity index 88% rename from src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ScopeType.php rename to src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Read/ScopeType.php index fc81fe84..ab1f67ad 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/ScopeType.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Read/ScopeType.php @@ -11,7 +11,7 @@ * file that was distributed with this source code. */ -namespace FreeDSx\Ldap\Server\Backend\Storage\Journal; +namespace FreeDSx\Ldap\Server\Backend\Storage\Journal\Read; /** * The DIT extent a change stream covers. diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/RetentionPolicy.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/RetentionPolicy.php new file mode 100644 index 00000000..fabf4695 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/RetentionPolicy.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Journal; + +use FreeDSx\Ldap\Exception\InvalidArgumentException; + +/** + * Bounds journal growth: a record is purge-eligible once it fails either limit (whichever is tighter). + * + * @author Chad Sikorra + */ +final readonly class RetentionPolicy +{ + /** + * @param ?int $maxRecords hard ceiling on retained records, or null for no count limit + * @param ?int $maxAgeSeconds age horizon in seconds, or null for no time limit + */ + public function __construct( + public ?int $maxRecords = null, + public ?int $maxAgeSeconds = null, + ) { + if ($maxRecords !== null && $maxRecords < 1) { + throw new InvalidArgumentException('maxRecords must be at least 1 when set.'); + } + + if ($maxAgeSeconds !== null && $maxAgeSeconds < 1) { + throw new InvalidArgumentException('maxAgeSeconds must be at least 1 when set.'); + } + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php index 106beec5..9c0b5811 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php @@ -24,7 +24,7 @@ use FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Operation\WriteEntryOperationHandler; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeRecorder; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Capture\ChangeRecorder; use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; diff --git a/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php b/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php index 6d0fa925..13f68fe0 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php @@ -29,11 +29,11 @@ use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeRecord; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeRecorder; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeType; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Capture\ChangeRecorder; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeRecord; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeType; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\PendingChange; use FreeDSx\Ldap\Server\Backend\Storage\Journal\InMemoryChangeJournal; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\PendingChange; use FreeDSx\Ldap\Server\Backend\Storage\Exception\InvalidAttributeException; use FreeDSx\Ldap\Server\Backend\Storage\Exception\StorageIoException; use FreeDSx\Ldap\Server\Backend\Storage\Exception\TimeLimitExceededException; diff --git a/tests/unit/Server/Backend/Storage/Journal/ChangeRecorderTest.php b/tests/unit/Server/Backend/Storage/Journal/Capture/ChangeRecorderTest.php similarity index 95% rename from tests/unit/Server/Backend/Storage/Journal/ChangeRecorderTest.php rename to tests/unit/Server/Backend/Storage/Journal/Capture/ChangeRecorderTest.php index 754efde2..95a1ba5c 100644 --- a/tests/unit/Server/Backend/Storage/Journal/ChangeRecorderTest.php +++ b/tests/unit/Server/Backend/Storage/Journal/Capture/ChangeRecorderTest.php @@ -11,7 +11,7 @@ * file that was distributed with this source code. */ -namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Journal; +namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Journal\Capture; use FreeDSx\Ldap\Control\ControlBag; use FreeDSx\Ldap\Entry\Attribute; @@ -20,9 +20,9 @@ use FreeDSx\Ldap\Schema\Definition\AttributeTypeOid; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeRecord; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeRecorder; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeType; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Capture\ChangeRecorder; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeRecord; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeType; use FreeDSx\Ldap\Server\Backend\Storage\Journal\InMemoryChangeJournal; use FreeDSx\Ldap\Server\Backend\Write\WriteContext; use FreeDSx\Ldap\Server\Token\BindToken; diff --git a/tests/unit/Server/Backend/Storage/Journal/InMemoryChangeJournalTest.php b/tests/unit/Server/Backend/Storage/Journal/InMemoryChangeJournalTest.php index ad924f36..e35ba0b7 100644 --- a/tests/unit/Server/Backend/Storage/Journal/InMemoryChangeJournalTest.php +++ b/tests/unit/Server/Backend/Storage/Journal/InMemoryChangeJournalTest.php @@ -15,11 +15,12 @@ use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Protocol\Authorization\AuthzId; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeRecord; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeType; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeRecord; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeType; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\PendingChange; use FreeDSx\Ldap\Server\Backend\Storage\Journal\InMemoryChangeJournal; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\PendingChange; use FreeDSx\Ldap\Server\Backend\Storage\Journal\ReplicaId; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\RetentionPolicy; use PHPUnit\Framework\TestCase; use Tests\Support\FreeDSx\Ldap\Clock\FrozenClock; @@ -118,6 +119,83 @@ public function test_read_without_an_argument_returns_everything(): void ); } + public function test_an_unbounded_policy_prunes_nothing(): void + { + $this->subject->append($this->change('cn=a,dc=example,dc=com')); + $this->subject->append($this->change('cn=b,dc=example,dc=com')); + + self::assertSame( + 0, + $this->subject->prune(new RetentionPolicy()), + ); + self::assertCount( + 2, + iterator_to_array($this->subject->read()), + ); + } + + public function test_the_record_cap_keeps_only_the_newest_records(): void + { + for ($i = 0; $i < 5; $i++) { + $this->subject->append($this->change("cn={$i},dc=example,dc=com")); + } + + $removed = $this->subject->prune(new RetentionPolicy(maxRecords: 2)); + + $seqs = array_map( + static fn(ChangeRecord $record): int => $record->seq, + iterator_to_array($this->subject->read()), + ); + self::assertSame( + 3, + $removed, + ); + self::assertSame( + [4, 5], + $seqs, + ); + } + + public function test_the_age_window_drops_records_older_than_the_horizon(): void + { + $this->subject->append($this->change('cn=old,dc=example,dc=com')); + $this->clock->setTo($this->clock->now()->modify('+10 seconds')); + $this->subject->append($this->change('cn=new,dc=example,dc=com')); + + $removed = $this->subject->prune(new RetentionPolicy(maxAgeSeconds: 5)); + + $dns = array_map( + static fn(ChangeRecord $record): string => $record->change->dn->toString(), + iterator_to_array($this->subject->read()), + ); + self::assertSame( + 1, + $removed, + ); + self::assertSame( + ['cn=new,dc=example,dc=com'], + $dns, + ); + } + + public function test_pruning_leaves_the_seq_counter_climbing(): void + { + $this->subject->append($this->change('cn=a,dc=example,dc=com')); + $this->subject->append($this->change('cn=b,dc=example,dc=com')); + $this->subject->append($this->change('cn=c,dc=example,dc=com')); + + $this->subject->prune(new RetentionPolicy(maxRecords: 1)); + + self::assertSame( + 3, + $this->subject->latestSeq(), + ); + self::assertSame( + 4, + $this->subject->append($this->change('cn=d,dc=example,dc=com'))->seq, + ); + } + private function change(string $dn): PendingChange { return new PendingChange( diff --git a/tests/unit/Server/Backend/Storage/Journal/ChangeScopeTest.php b/tests/unit/Server/Backend/Storage/Journal/Read/ChangeScopeTest.php similarity index 96% rename from tests/unit/Server/Backend/Storage/Journal/ChangeScopeTest.php rename to tests/unit/Server/Backend/Storage/Journal/Read/ChangeScopeTest.php index a0330048..1b0256de 100644 --- a/tests/unit/Server/Backend/Storage/Journal/ChangeScopeTest.php +++ b/tests/unit/Server/Backend/Storage/Journal/Read/ChangeScopeTest.php @@ -11,10 +11,10 @@ * file that was distributed with this source code. */ -namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Journal; +namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Journal\Read; use FreeDSx\Ldap\Entry\Dn; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeScope; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Read\ChangeScope; use PHPUnit\Framework\TestCase; final class ChangeScopeTest extends TestCase diff --git a/tests/unit/Server/Backend/Storage/Journal/ChangeStreamTest.php b/tests/unit/Server/Backend/Storage/Journal/Read/ChangeStreamTest.php similarity index 89% rename from tests/unit/Server/Backend/Storage/Journal/ChangeStreamTest.php rename to tests/unit/Server/Backend/Storage/Journal/Read/ChangeStreamTest.php index 4be1eba0..e9a77017 100644 --- a/tests/unit/Server/Backend/Storage/Journal/ChangeStreamTest.php +++ b/tests/unit/Server/Backend/Storage/Journal/Read/ChangeStreamTest.php @@ -11,16 +11,16 @@ * file that was distributed with this source code. */ -namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Journal; +namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Journal\Read; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Protocol\Authorization\AuthzId; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeRecord; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeScope; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeStream; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeType; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeRecord; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeType; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\PendingChange; use FreeDSx\Ldap\Server\Backend\Storage\Journal\InMemoryChangeJournal; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\PendingChange; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Read\ChangeScope; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Read\ChangeStream; use PHPUnit\Framework\TestCase; final class ChangeStreamTest extends TestCase diff --git a/tests/unit/Server/Backend/Storage/Journal/RetentionPolicyTest.php b/tests/unit/Server/Backend/Storage/Journal/RetentionPolicyTest.php new file mode 100644 index 00000000..09d5389e --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Journal/RetentionPolicyTest.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Journal; + +use FreeDSx\Ldap\Exception\InvalidArgumentException; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\RetentionPolicy; +use PHPUnit\Framework\TestCase; + +final class RetentionPolicyTest extends TestCase +{ + public function test_it_allows_unbounded_axes(): void + { + $policy = new RetentionPolicy(); + + self::assertNull($policy->maxRecords); + self::assertNull($policy->maxAgeSeconds); + } + + public function test_it_rejects_a_non_positive_record_limit(): void + { + self::expectException(InvalidArgumentException::class); + + new RetentionPolicy(maxRecords: 0); + } + + public function test_it_rejects_a_non_positive_age_limit(): void + { + self::expectException(InvalidArgumentException::class); + + new RetentionPolicy(maxAgeSeconds: 0); + } +}