From 6b201e62924890b7c1b1a0a083a380f9f13d54a0 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Sun, 12 Jul 2026 12:49:30 -0400 Subject: [PATCH 1/3] Add atomic alias updates --- README.md | 22 ++++++ src/Indices/Alias.php | 14 ++++ src/Indices/AliasActions.php | 78 +++++++++++++++++++ src/Indices/IndexManager.php | 15 +++- src/Indices/IndexManagerInterface.php | 7 ++ src/Testing/Fakes/FakeIndexManager.php | 85 ++++++++++++++++++++- tests/Integration/OpenSearchAdapterTest.php | 35 +++++++++ tests/Unit/Indices/AliasActionsTest.php | 62 +++++++++++++++ tests/Unit/Indices/AliasTest.php | 18 +++++ tests/Unit/Indices/FakeIndexManagerTest.php | 28 +++++++ tests/Unit/Indices/IndexManagerTest.php | 38 ++++++++- 11 files changed, 398 insertions(+), 4 deletions(-) create mode 100644 src/Indices/AliasActions.php create mode 100644 tests/Unit/Indices/AliasActionsTest.php diff --git a/README.md b/README.md index e9643e7..9b1b496 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,28 @@ $indices->putAlias('books', new Alias( $aliases = $indices->getAliases('books'); ``` +Apply multiple alias changes atomically when switching between versioned indexes: + +```php +use DirectoryTree\OpenSearchAdapter\Indices\Alias; +use DirectoryTree\OpenSearchAdapter\Indices\AliasActions; + +$actions = (new AliasActions) + ->remove('books_blue', 'books') + ->add('books_green', new Alias('books', isWriteIndex: true)); + +$indices->updateAliases($actions); +``` + +An old physical index can be removed in the same atomic operation: + +```php +$actions = (new AliasActions) + ->remove('books_blue', 'books') + ->add('books_green', new Alias('books', isWriteIndex: true)) + ->removeIndex('books_retired'); +``` + ## Raw Responses Search response objects expose the original OpenSearch payload through `raw()`: diff --git a/src/Indices/Alias.php b/src/Indices/Alias.php index c0476a0..49f6c59 100644 --- a/src/Indices/Alias.php +++ b/src/Indices/Alias.php @@ -13,11 +13,13 @@ class Alias * @param string $name The alias name. * @param array|null $filter * @param string|null $routing The optional alias routing value. + * @param bool|null $isWriteIndex Whether this is the alias write index. */ public function __construct( protected string $name, protected ?array $filter = null, protected ?string $routing = null, + protected ?bool $isWriteIndex = null, ) {} /** @@ -46,6 +48,14 @@ public function routing(): ?string return $this->routing; } + /** + * Determine whether this is the alias write index. + */ + public function isWriteIndex(): ?bool + { + return $this->isWriteIndex; + } + /** * Get the OpenSearch alias body payload. * @@ -63,6 +73,10 @@ public function toArray(): array $body['filter'] = $this->filter; } + if (! is_null($this->isWriteIndex)) { + $body['is_write_index'] = $this->isWriteIndex; + } + return $body; } } diff --git a/src/Indices/AliasActions.php b/src/Indices/AliasActions.php new file mode 100644 index 0000000..5f41720 --- /dev/null +++ b/src/Indices/AliasActions.php @@ -0,0 +1,78 @@ +>> + */ + protected array $actions = []; + + /** + * Add an alias to an index. + */ + public function add(string $index, Alias $alias): static + { + $this->actions[] = [ + 'add' => [ + 'index' => $index, + 'alias' => $alias->name(), + ...$alias->toArray(), + ], + ]; + + return $this; + } + + /** + * Remove an alias from an index. + */ + public function remove(string $index, string $alias): static + { + $this->actions[] = [ + 'remove' => compact('index', 'alias'), + ]; + + return $this; + } + + /** + * Remove an index as part of the atomic alias update. + */ + public function removeIndex(string $index): static + { + $this->actions[] = [ + 'remove_index' => compact('index'), + ]; + + return $this; + } + + /** + * Get the configured alias actions. + * + * @return array>> + */ + public function actions(): array + { + return $this->actions; + } + + /** + * Get the OpenSearch update aliases body payload. + * + * @return array{actions: array>>} + */ + public function toArray(): array + { + return ['actions' => $this->actions]; + } +} diff --git a/src/Indices/IndexManager.php b/src/Indices/IndexManager.php index fb88374..116c99d 100644 --- a/src/Indices/IndexManager.php +++ b/src/Indices/IndexManager.php @@ -129,7 +129,8 @@ public function getAliases(string $index): array $results[$name] = new Alias( $name, $parameters['filter'] ?? null, - $parameters['routing'] ?? null + $parameters['routing'] ?? null, + $parameters['is_write_index'] ?? null, ); } @@ -167,4 +168,16 @@ public function deleteAlias(string $index, string $aliasName): static return $this; } + + /** + * Atomically apply multiple alias actions. + */ + public function updateAliases(AliasActions $actions): static + { + $this->indices->updateAliases([ + 'body' => $actions->toArray(), + ]); + + return $this; + } } diff --git a/src/Indices/IndexManagerInterface.php b/src/Indices/IndexManagerInterface.php index ff77e44..3d52c2f 100644 --- a/src/Indices/IndexManagerInterface.php +++ b/src/Indices/IndexManagerInterface.php @@ -78,4 +78,11 @@ public function putAlias(string $index, Alias $alias): static; * @see https://docs.opensearch.org/latest/api-reference/index-apis/alias/ */ public function deleteAlias(string $index, string $aliasName): static; + + /** + * Atomically apply multiple alias actions. + * + * @see https://docs.opensearch.org/latest/api-reference/alias/aliases-api/ + */ + public function updateAliases(AliasActions $actions): static; } diff --git a/src/Testing/Fakes/FakeIndexManager.php b/src/Testing/Fakes/FakeIndexManager.php index 2828b94..a122104 100644 --- a/src/Testing/Fakes/FakeIndexManager.php +++ b/src/Testing/Fakes/FakeIndexManager.php @@ -3,6 +3,7 @@ namespace DirectoryTree\OpenSearchAdapter\Testing\Fakes; use DirectoryTree\OpenSearchAdapter\Indices\Alias; +use DirectoryTree\OpenSearchAdapter\Indices\AliasActions; use DirectoryTree\OpenSearchAdapter\Indices\IndexBlueprint; use DirectoryTree\OpenSearchAdapter\Indices\IndexManagerInterface; use DirectoryTree\OpenSearchAdapter\Indices\Mapping; @@ -70,6 +71,13 @@ class FakeIndexManager implements IndexManagerInterface */ protected array $aliases = []; + /** + * The atomic alias updates. + * + * @var array + */ + protected array $aliasUpdates = []; + /** * The deleted aliases. * @@ -153,7 +161,15 @@ public function putSettings(string $index, Settings $settings): static public function delete(string $index): static { $this->deleted[] = $index; - $this->existing = array_values(array_diff($this->existing, [$index])); + + $this->existing = array_values( + array_diff($this->existing, [$index]) + ); + + $this->aliases = array_values(array_filter( + $this->aliases, + fn (array $operation) => $operation['index'] !== $index, + )); return $this; } @@ -181,6 +197,8 @@ public function getAliases(string $index): array */ public function putAlias(string $index, Alias $alias): static { + $this->removeAlias($index, $alias->name()); + $this->aliases[] = compact('index', 'alias'); return $this; @@ -196,6 +214,49 @@ public function deleteAlias(string $index, string $aliasName): static 'alias' => $aliasName, ]; + $this->removeAlias($index, $aliasName); + + return $this; + } + + /** + * Atomically apply multiple alias actions. + */ + public function updateAliases(AliasActions $actions): static + { + $this->aliasUpdates[] = $actions; + + foreach ($actions->actions() as $action) { + if ($parameters = $action['add'] ?? null) { + $alias = new Alias( + $parameters['alias'], + $parameters['filter'] ?? null, + $parameters['routing'] ?? null, + $parameters['is_write_index'] ?? null, + ); + + $this->removeAlias($parameters['index'], $parameters['alias']); + + $this->aliases[] = [ + 'index' => $parameters['index'], + 'alias' => $alias, + ]; + } + + if ($parameters = $action['remove'] ?? null) { + $this->deletedAliases[] = [ + 'index' => $parameters['index'], + 'alias' => $parameters['alias'], + ]; + + $this->removeAlias($parameters['index'], $parameters['alias']); + } + + if ($parameters = $action['remove_index'] ?? null) { + $this->delete($parameters['index']); + } + } + return $this; } @@ -300,4 +361,26 @@ public function assertAliasDeleted(string $index, string $alias): static return $this; } + + /** + * Assert that the given atomic alias update was performed. + */ + public function assertAliasesUpdated(AliasActions $actions): static + { + PHPUnit::assertContainsEquals($actions, $this->aliasUpdates); + + return $this; + } + + /** + * Remove an alias from the fake's current state. + */ + protected function removeAlias(string $index, string $alias): void + { + $this->aliases = array_values( + array_filter($this->aliases, fn (array $operation) => ( + $operation['index'] !== $index || $operation['alias']->name() !== $alias + )) + ); + } } diff --git a/tests/Integration/OpenSearchAdapterTest.php b/tests/Integration/OpenSearchAdapterTest.php index c7819e6..095205b 100644 --- a/tests/Integration/OpenSearchAdapterTest.php +++ b/tests/Integration/OpenSearchAdapterTest.php @@ -4,6 +4,7 @@ use DirectoryTree\OpenSearchAdapter\Documents\DocumentManager; use DirectoryTree\OpenSearchAdapter\Documents\DocumentRouting; use DirectoryTree\OpenSearchAdapter\Indices\Alias; +use DirectoryTree\OpenSearchAdapter\Indices\AliasActions; use DirectoryTree\OpenSearchAdapter\Indices\IndexBlueprint; use DirectoryTree\OpenSearchAdapter\Indices\IndexManager; use DirectoryTree\OpenSearchAdapter\Indices\Mapping; @@ -168,6 +169,40 @@ } }); +it('atomically switches an alias between physical indexes', function (): void { + $client = openSearchClient(); + $indices = new IndexManager($client); + + $prefix = sprintf('adapter_integration_%s', bin2hex(random_bytes(4))); + $blue = $prefix.'_blue'; + $green = $prefix.'_green'; + $alias = $prefix.'_alias'; + + try { + $indices->create(new IndexBlueprint($blue)); + $indices->create(new IndexBlueprint($green)); + $indices->putAlias($blue, new Alias($alias, isWriteIndex: true)); + + $indices->updateAliases( + (new AliasActions) + ->remove($blue, $alias) + ->add($green, new Alias($alias, isWriteIndex: true)), + ); + + expect($indices->getAliases($blue))->not->toHaveKey($alias) + ->and($indices->getAliases($green))->toHaveKey($alias) + ->and($indices->getAliases($green)[$alias]->isWriteIndex())->toBeTrue(); + } finally { + if ($indices->exists($blue)) { + $indices->delete($blue); + } + + if ($indices->exists($green)) { + $indices->delete($green); + } + } +}); + /** * Create an OpenSearch client for integration tests. */ diff --git a/tests/Unit/Indices/AliasActionsTest.php b/tests/Unit/Indices/AliasActionsTest.php new file mode 100644 index 0000000..0f5c49c --- /dev/null +++ b/tests/Unit/Indices/AliasActionsTest.php @@ -0,0 +1,62 @@ +remove('posts_blue', 'posts') + ->add('posts_green', new Alias('posts', isWriteIndex: true)) + ->removeIndex('posts_retired'); + + expect($actions->actions())->toBe([ + [ + 'remove' => [ + 'index' => 'posts_blue', + 'alias' => 'posts', + ], + ], + [ + 'add' => [ + 'index' => 'posts_green', + 'alias' => 'posts', + 'is_write_index' => true, + ], + ], + [ + 'remove_index' => [ + 'index' => 'posts_retired', + ], + ], + ])->and($actions->toArray())->toBe([ + 'actions' => $actions->actions(), + ]); +}); + +test('add actions include alias routing and filters', function () { + $actions = (new AliasActions)->add( + 'posts', + new Alias( + name: 'published_posts', + filter: ['term' => ['status' => 'published']], + routing: 'tenant-1', + isWriteIndex: false, + ), + ); + + expect($actions->toArray())->toBe([ + 'actions' => [ + [ + 'add' => [ + 'index' => 'posts', + 'alias' => 'published_posts', + 'routing' => 'tenant-1', + 'filter' => ['term' => ['status' => 'published']], + 'is_write_index' => false, + ], + ], + ], + ]); +}); diff --git a/tests/Unit/Indices/AliasTest.php b/tests/Unit/Indices/AliasTest.php index 2934940..e66249d 100644 --- a/tests/Unit/Indices/AliasTest.php +++ b/tests/Unit/Indices/AliasTest.php @@ -30,3 +30,21 @@ ], ], $alias->toArray()); }); + +test('write index can be configured', function () { + $alias = new Alias('2030', isWriteIndex: true); + + expect($alias->isWriteIndex())->toBeTrue() + ->and($alias->toArray())->toBe([ + 'is_write_index' => true, + ]); +}); + +test('write index false is included in the payload', function () { + $alias = new Alias('2030', isWriteIndex: false); + + expect($alias->isWriteIndex())->toBeFalse() + ->and($alias->toArray())->toBe([ + 'is_write_index' => false, + ]); +}); diff --git a/tests/Unit/Indices/FakeIndexManagerTest.php b/tests/Unit/Indices/FakeIndexManagerTest.php index c341dce..3b7e27e 100644 --- a/tests/Unit/Indices/FakeIndexManagerTest.php +++ b/tests/Unit/Indices/FakeIndexManagerTest.php @@ -1,6 +1,7 @@ putAlias('posts', new Alias('published_posts')); $indices->deleteAlias('posts', 'published_posts'); $indices->assertAliasDeleted('posts', 'published_posts'); + + expect($indices->getAliases('posts'))->toBe([]); +}); + +test('fake index manager applies atomic alias updates', function () { + $indices = new FakeIndexManager(existing: ['posts_blue', 'posts_green', 'posts_retired']); + $indices->putAlias('posts_blue', new Alias('posts', isWriteIndex: true)); + + $actions = (new AliasActions) + ->remove('posts_blue', 'posts') + ->add('posts_green', new Alias('posts', isWriteIndex: true)) + ->removeIndex('posts_retired'); + + $indices->updateAliases($actions); + + $indices + ->assertAliasesUpdated($actions) + ->assertAliasDeleted('posts_blue', 'posts') + ->assertAliasPut('posts_green', new Alias('posts', isWriteIndex: true)) + ->assertDeleted('posts_retired'); + + expect($indices->getAliases('posts_blue'))->toBe([]) + ->and($indices->getAliases('posts_green'))->toEqual([ + 'posts' => new Alias('posts', isWriteIndex: true), + ]) + ->and($indices->exists('posts_retired'))->toBeFalse(); }); diff --git a/tests/Unit/Indices/IndexManagerTest.php b/tests/Unit/Indices/IndexManagerTest.php index e4e9eef..7ac8bc9 100644 --- a/tests/Unit/Indices/IndexManagerTest.php +++ b/tests/Unit/Indices/IndexManagerTest.php @@ -3,6 +3,7 @@ namespace DirectoryTree\OpenSearchAdapter\Tests\Unit\Indices; use DirectoryTree\OpenSearchAdapter\Indices\Alias; +use DirectoryTree\OpenSearchAdapter\Indices\AliasActions; use DirectoryTree\OpenSearchAdapter\Indices\IndexBlueprint; use DirectoryTree\OpenSearchAdapter\Indices\IndexManager; use DirectoryTree\OpenSearchAdapter\Indices\Mapping; @@ -199,13 +200,15 @@ ->willReturn([ $index => [ 'aliases' => [ - $aliasName => [], + $aliasName => [ + 'is_write_index' => true, + ], ], ], ]); $this->assertEquals( - [$aliasName => new Alias($aliasName)], + [$aliasName => new Alias($aliasName, isWriteIndex: true)], $this->indexManager->getAliases($index) ); }); @@ -233,6 +236,37 @@ $this->assertSame($this->indexManager, $this->indexManager->putAlias($index, $alias)); }); +test('aliases can be updated atomically', function () { + $actions = (new AliasActions) + ->remove('posts_blue', 'posts') + ->add('posts_green', new Alias('posts', isWriteIndex: true)); + + $this->indices + ->expects($this->once()) + ->method('updateAliases') + ->with([ + 'body' => [ + 'actions' => [ + [ + 'remove' => [ + 'index' => 'posts_blue', + 'alias' => 'posts', + ], + ], + [ + 'add' => [ + 'index' => 'posts_green', + 'alias' => 'posts', + 'is_write_index' => true, + ], + ], + ], + ], + ]); + + $this->assertSame($this->indexManager, $this->indexManager->updateAliases($actions)); +}); + test('alias can be deleted', function () { $index = 'foo'; $aliasName = 'bar'; From bf54a2cf1588612383a01b4861e87bda9acd554e Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Sun, 12 Jul 2026 12:56:49 -0400 Subject: [PATCH 2/3] Format tests --- tests/Unit/Indices/FakeIndexManagerTest.php | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/Unit/Indices/FakeIndexManagerTest.php b/tests/Unit/Indices/FakeIndexManagerTest.php index 3b7e27e..5c5b878 100644 --- a/tests/Unit/Indices/FakeIndexManagerTest.php +++ b/tests/Unit/Indices/FakeIndexManagerTest.php @@ -76,7 +76,10 @@ test('fake index manager records aliases', function () { $indices = new FakeIndexManager; - $alias = new Alias('published_posts', ['term' => ['status' => 'published']], 'tenant-1'); + + $alias = new Alias('published_posts', [ + 'term' => ['status' => 'published'], + ], 'tenant-1'); $indices->putAlias('posts', $alias); @@ -89,6 +92,7 @@ test('fake index manager records deleted aliases', function () { $indices = new FakeIndexManager; + $indices->putAlias('posts', new Alias('published_posts')); $indices->deleteAlias('posts', 'published_posts'); @@ -99,7 +103,12 @@ }); test('fake index manager applies atomic alias updates', function () { - $indices = new FakeIndexManager(existing: ['posts_blue', 'posts_green', 'posts_retired']); + $indices = new FakeIndexManager(existing: [ + 'posts_blue', + 'posts_green', + 'posts_retired', + ]); + $indices->putAlias('posts_blue', new Alias('posts', isWriteIndex: true)); $actions = (new AliasActions) From ca83c3f98553d5af772d2c99499cacb0c9a2b8fc Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Sun, 12 Jul 2026 13:04:49 -0400 Subject: [PATCH 3/3] Run tests on pull requests --- .github/workflows/run-tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 2a88cb1..3538269 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -4,6 +4,9 @@ on: push: branches: - master + pull_request: + branches: + - master jobs: test: