Skip to content

Commit 215de1a

Browse files
authored
Merge pull request #3 from DirectoryTree/agent/add-atomic-alias-updates
Add atomic alias updates for zero-downtime index swaps
2 parents 2379e4f + ca83c3f commit 215de1a

12 files changed

Lines changed: 411 additions & 5 deletions

File tree

.github/workflows/run-tests.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ on:
44
push:
55
branches:
66
- master
7+
pull_request:
8+
branches:
9+
- master
710

811
jobs:
912
test:

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,28 @@ $indices->putAlias('books', new Alias(
176176
$aliases = $indices->getAliases('books');
177177
```
178178

179+
Apply multiple alias changes atomically when switching between versioned indexes:
180+
181+
```php
182+
use DirectoryTree\OpenSearchAdapter\Indices\Alias;
183+
use DirectoryTree\OpenSearchAdapter\Indices\AliasActions;
184+
185+
$actions = (new AliasActions)
186+
->remove('books_blue', 'books')
187+
->add('books_green', new Alias('books', isWriteIndex: true));
188+
189+
$indices->updateAliases($actions);
190+
```
191+
192+
An old physical index can be removed in the same atomic operation:
193+
194+
```php
195+
$actions = (new AliasActions)
196+
->remove('books_blue', 'books')
197+
->add('books_green', new Alias('books', isWriteIndex: true))
198+
->removeIndex('books_retired');
199+
```
200+
179201
## Raw Responses
180202

181203
Search response objects expose the original OpenSearch payload through `raw()`:

src/Indices/Alias.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ class Alias
1313
* @param string $name The alias name.
1414
* @param array<string, mixed>|null $filter
1515
* @param string|null $routing The optional alias routing value.
16+
* @param bool|null $isWriteIndex Whether this is the alias write index.
1617
*/
1718
public function __construct(
1819
protected string $name,
1920
protected ?array $filter = null,
2021
protected ?string $routing = null,
22+
protected ?bool $isWriteIndex = null,
2123
) {}
2224

2325
/**
@@ -46,6 +48,14 @@ public function routing(): ?string
4648
return $this->routing;
4749
}
4850

51+
/**
52+
* Determine whether this is the alias write index.
53+
*/
54+
public function isWriteIndex(): ?bool
55+
{
56+
return $this->isWriteIndex;
57+
}
58+
4959
/**
5060
* Get the OpenSearch alias body payload.
5161
*
@@ -63,6 +73,10 @@ public function toArray(): array
6373
$body['filter'] = $this->filter;
6474
}
6575

76+
if (! is_null($this->isWriteIndex)) {
77+
$body['is_write_index'] = $this->isWriteIndex;
78+
}
79+
6680
return $body;
6781
}
6882
}

src/Indices/AliasActions.php

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
namespace DirectoryTree\OpenSearchAdapter\Indices;
4+
5+
/**
6+
* Builds an atomic OpenSearch alias update payload.
7+
*
8+
* @see https://docs.opensearch.org/latest/api-reference/alias/aliases-api/
9+
*/
10+
class AliasActions
11+
{
12+
/**
13+
* The alias actions.
14+
*
15+
* @var array<int, array<string, array<string, mixed>>>
16+
*/
17+
protected array $actions = [];
18+
19+
/**
20+
* Add an alias to an index.
21+
*/
22+
public function add(string $index, Alias $alias): static
23+
{
24+
$this->actions[] = [
25+
'add' => [
26+
'index' => $index,
27+
'alias' => $alias->name(),
28+
...$alias->toArray(),
29+
],
30+
];
31+
32+
return $this;
33+
}
34+
35+
/**
36+
* Remove an alias from an index.
37+
*/
38+
public function remove(string $index, string $alias): static
39+
{
40+
$this->actions[] = [
41+
'remove' => compact('index', 'alias'),
42+
];
43+
44+
return $this;
45+
}
46+
47+
/**
48+
* Remove an index as part of the atomic alias update.
49+
*/
50+
public function removeIndex(string $index): static
51+
{
52+
$this->actions[] = [
53+
'remove_index' => compact('index'),
54+
];
55+
56+
return $this;
57+
}
58+
59+
/**
60+
* Get the configured alias actions.
61+
*
62+
* @return array<int, array<string, array<string, mixed>>>
63+
*/
64+
public function actions(): array
65+
{
66+
return $this->actions;
67+
}
68+
69+
/**
70+
* Get the OpenSearch update aliases body payload.
71+
*
72+
* @return array{actions: array<int, array<string, array<string, mixed>>>}
73+
*/
74+
public function toArray(): array
75+
{
76+
return ['actions' => $this->actions];
77+
}
78+
}

src/Indices/IndexManager.php

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,8 @@ public function getAliases(string $index): array
129129
$results[$name] = new Alias(
130130
$name,
131131
$parameters['filter'] ?? null,
132-
$parameters['routing'] ?? null
132+
$parameters['routing'] ?? null,
133+
$parameters['is_write_index'] ?? null,
133134
);
134135
}
135136

@@ -167,4 +168,16 @@ public function deleteAlias(string $index, string $aliasName): static
167168

168169
return $this;
169170
}
171+
172+
/**
173+
* Atomically apply multiple alias actions.
174+
*/
175+
public function updateAliases(AliasActions $actions): static
176+
{
177+
$this->indices->updateAliases([
178+
'body' => $actions->toArray(),
179+
]);
180+
181+
return $this;
182+
}
170183
}

src/Indices/IndexManagerInterface.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,11 @@ public function putAlias(string $index, Alias $alias): static;
7878
* @see https://docs.opensearch.org/latest/api-reference/index-apis/alias/
7979
*/
8080
public function deleteAlias(string $index, string $aliasName): static;
81+
82+
/**
83+
* Atomically apply multiple alias actions.
84+
*
85+
* @see https://docs.opensearch.org/latest/api-reference/alias/aliases-api/
86+
*/
87+
public function updateAliases(AliasActions $actions): static;
8188
}

src/Testing/Fakes/FakeIndexManager.php

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace DirectoryTree\OpenSearchAdapter\Testing\Fakes;
44

55
use DirectoryTree\OpenSearchAdapter\Indices\Alias;
6+
use DirectoryTree\OpenSearchAdapter\Indices\AliasActions;
67
use DirectoryTree\OpenSearchAdapter\Indices\IndexBlueprint;
78
use DirectoryTree\OpenSearchAdapter\Indices\IndexManagerInterface;
89
use DirectoryTree\OpenSearchAdapter\Indices\Mapping;
@@ -70,6 +71,13 @@ class FakeIndexManager implements IndexManagerInterface
7071
*/
7172
protected array $aliases = [];
7273

74+
/**
75+
* The atomic alias updates.
76+
*
77+
* @var array<int, AliasActions>
78+
*/
79+
protected array $aliasUpdates = [];
80+
7381
/**
7482
* The deleted aliases.
7583
*
@@ -153,7 +161,15 @@ public function putSettings(string $index, Settings $settings): static
153161
public function delete(string $index): static
154162
{
155163
$this->deleted[] = $index;
156-
$this->existing = array_values(array_diff($this->existing, [$index]));
164+
165+
$this->existing = array_values(
166+
array_diff($this->existing, [$index])
167+
);
168+
169+
$this->aliases = array_values(array_filter(
170+
$this->aliases,
171+
fn (array $operation) => $operation['index'] !== $index,
172+
));
157173

158174
return $this;
159175
}
@@ -181,6 +197,8 @@ public function getAliases(string $index): array
181197
*/
182198
public function putAlias(string $index, Alias $alias): static
183199
{
200+
$this->removeAlias($index, $alias->name());
201+
184202
$this->aliases[] = compact('index', 'alias');
185203

186204
return $this;
@@ -196,6 +214,49 @@ public function deleteAlias(string $index, string $aliasName): static
196214
'alias' => $aliasName,
197215
];
198216

217+
$this->removeAlias($index, $aliasName);
218+
219+
return $this;
220+
}
221+
222+
/**
223+
* Atomically apply multiple alias actions.
224+
*/
225+
public function updateAliases(AliasActions $actions): static
226+
{
227+
$this->aliasUpdates[] = $actions;
228+
229+
foreach ($actions->actions() as $action) {
230+
if ($parameters = $action['add'] ?? null) {
231+
$alias = new Alias(
232+
$parameters['alias'],
233+
$parameters['filter'] ?? null,
234+
$parameters['routing'] ?? null,
235+
$parameters['is_write_index'] ?? null,
236+
);
237+
238+
$this->removeAlias($parameters['index'], $parameters['alias']);
239+
240+
$this->aliases[] = [
241+
'index' => $parameters['index'],
242+
'alias' => $alias,
243+
];
244+
}
245+
246+
if ($parameters = $action['remove'] ?? null) {
247+
$this->deletedAliases[] = [
248+
'index' => $parameters['index'],
249+
'alias' => $parameters['alias'],
250+
];
251+
252+
$this->removeAlias($parameters['index'], $parameters['alias']);
253+
}
254+
255+
if ($parameters = $action['remove_index'] ?? null) {
256+
$this->delete($parameters['index']);
257+
}
258+
}
259+
199260
return $this;
200261
}
201262

@@ -300,4 +361,26 @@ public function assertAliasDeleted(string $index, string $alias): static
300361

301362
return $this;
302363
}
364+
365+
/**
366+
* Assert that the given atomic alias update was performed.
367+
*/
368+
public function assertAliasesUpdated(AliasActions $actions): static
369+
{
370+
PHPUnit::assertContainsEquals($actions, $this->aliasUpdates);
371+
372+
return $this;
373+
}
374+
375+
/**
376+
* Remove an alias from the fake's current state.
377+
*/
378+
protected function removeAlias(string $index, string $alias): void
379+
{
380+
$this->aliases = array_values(
381+
array_filter($this->aliases, fn (array $operation) => (
382+
$operation['index'] !== $index || $operation['alias']->name() !== $alias
383+
))
384+
);
385+
}
303386
}

tests/Integration/OpenSearchAdapterTest.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
use DirectoryTree\OpenSearchAdapter\Documents\DocumentManager;
55
use DirectoryTree\OpenSearchAdapter\Documents\DocumentRouting;
66
use DirectoryTree\OpenSearchAdapter\Indices\Alias;
7+
use DirectoryTree\OpenSearchAdapter\Indices\AliasActions;
78
use DirectoryTree\OpenSearchAdapter\Indices\IndexBlueprint;
89
use DirectoryTree\OpenSearchAdapter\Indices\IndexManager;
910
use DirectoryTree\OpenSearchAdapter\Indices\Mapping;
@@ -168,6 +169,40 @@
168169
}
169170
});
170171

172+
it('atomically switches an alias between physical indexes', function (): void {
173+
$client = openSearchClient();
174+
$indices = new IndexManager($client);
175+
176+
$prefix = sprintf('adapter_integration_%s', bin2hex(random_bytes(4)));
177+
$blue = $prefix.'_blue';
178+
$green = $prefix.'_green';
179+
$alias = $prefix.'_alias';
180+
181+
try {
182+
$indices->create(new IndexBlueprint($blue));
183+
$indices->create(new IndexBlueprint($green));
184+
$indices->putAlias($blue, new Alias($alias, isWriteIndex: true));
185+
186+
$indices->updateAliases(
187+
(new AliasActions)
188+
->remove($blue, $alias)
189+
->add($green, new Alias($alias, isWriteIndex: true)),
190+
);
191+
192+
expect($indices->getAliases($blue))->not->toHaveKey($alias)
193+
->and($indices->getAliases($green))->toHaveKey($alias)
194+
->and($indices->getAliases($green)[$alias]->isWriteIndex())->toBeTrue();
195+
} finally {
196+
if ($indices->exists($blue)) {
197+
$indices->delete($blue);
198+
}
199+
200+
if ($indices->exists($green)) {
201+
$indices->delete($green);
202+
}
203+
}
204+
});
205+
171206
/**
172207
* Create an OpenSearch client for integration tests.
173208
*/

0 commit comments

Comments
 (0)