Skip to content

Commit ec7746a

Browse files
committed
feat: Add async migrations
1 parent 0587a78 commit ec7746a

5 files changed

Lines changed: 1083 additions & 59 deletions

File tree

ldclient/migrations/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1+
# async_migrator is import-cheap (asyncio stdlib only, no aiohttp), so it is
2+
# exported eagerly alongside the sync surface and keeps `import ldclient` cheap.
3+
from .async_migrator import *
14
from .migrator import *
25
from .tracker import *
36
from .types import *
47

58
__all__ = [
9+
'AsyncMigrationConfig',
10+
'AsyncMigrator',
11+
'AsyncMigratorBuilder',
12+
'AsyncMigratorFn',
613
'Migrator',
714
'MigratorBuilder',
815
'MigratorCompareFn',
Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
from abc import ABC, abstractmethod
5+
from datetime import datetime
6+
from random import Random
7+
from typing import (
8+
TYPE_CHECKING,
9+
Any,
10+
Awaitable,
11+
Callable,
12+
Optional,
13+
Tuple,
14+
Union
15+
)
16+
17+
from ldclient.impl.sampler import Sampler
18+
from ldclient.impl.util import Result
19+
from ldclient.migrations.tracker import OpTracker
20+
from ldclient.migrations.types import (
21+
ExecutionOrder,
22+
MigrationConfig,
23+
MigratorCompareFn,
24+
Operation,
25+
OperationResult,
26+
Origin,
27+
Stage,
28+
WriteResult,
29+
_MigrationConfigBase,
30+
_MigratorBuilderBase
31+
)
32+
33+
if TYPE_CHECKING:
34+
from ldclient import Context
35+
from ldclient.async_client import AsyncLDClient
36+
37+
__all__ = [
38+
'AsyncMigrator',
39+
'AsyncMigratorBuilder',
40+
'AsyncMigratorImpl',
41+
'AsyncMigrationConfig',
42+
'AsyncExecutor',
43+
'AsyncMigratorFn',
44+
]
45+
46+
AsyncMigratorFn = Callable[[Optional[Any]], Awaitable[Any]]
47+
"""
48+
The async counterpart to :data:`ldclient.migrations.MigratorFn`. When an async
49+
migration wishes to execute a read or write operation, it must delegate that
50+
call to a consumer defined coroutine function. This function must accept an
51+
optional payload value, and return a :class:`ldclient.Result`.
52+
"""
53+
54+
55+
class AsyncMigrator(ABC):
56+
"""
57+
An async migrator is the interface through which migration support is
58+
executed for the async SDK. An async migrator is configured through the
59+
:class:`AsyncMigratorBuilder`.
60+
61+
.. caution::
62+
This feature is experimental and should NOT be considered ready for production
63+
use. It may change or be removed without notice and is not subject to backwards
64+
compatibility guarantees. Pin to a specific minor version and review the changelog
65+
before upgrading.
66+
"""
67+
68+
@abstractmethod
69+
async def read(self, key: str, context: Context, default_stage: Stage, payload: Optional[Any] = None) -> OperationResult:
70+
"""
71+
Uses the provided flag key and context to execute a migration-backed read operation.
72+
73+
:param key: The migration flag key to use when determining the current stage
74+
:param context: The context to use when evaluating the flag
75+
:param default_stage: A default stage to fallback to if one cannot be determined
76+
:param payload: An optional payload to be passed through to the appropriate read method
77+
"""
78+
79+
@abstractmethod
80+
async def write(self, key: str, context: Context, default_stage: Stage, payload: Optional[Any] = None) -> WriteResult:
81+
"""
82+
Uses the provided flag key and context to execute a migration-backed write operation.
83+
84+
:param key: The migration flag key to use when determining the current stage
85+
:param context: The context to use when evaluating the flag
86+
:param default_stage: A default stage to fallback to if one cannot be determined
87+
:param payload: An optional payload to be passed through to the appropriate write method
88+
"""
89+
90+
91+
class AsyncMigratorImpl(AsyncMigrator):
92+
"""
93+
An implementation of the :class:`ldclient.migrations.AsyncMigrator`
94+
interface, capable of supporting feature-flag backed technology migrations
95+
for the async SDK.
96+
"""
97+
98+
def __init__(
99+
self,
100+
sampler: Sampler,
101+
client: AsyncLDClient,
102+
read_execution_order: ExecutionOrder,
103+
read_config: AsyncMigrationConfig,
104+
write_config: AsyncMigrationConfig,
105+
measure_latency: bool,
106+
measure_errors: bool,
107+
):
108+
self._sampler = sampler
109+
self._client = client
110+
self._read_execution_order = read_execution_order
111+
self._read_config = read_config
112+
self._write_config = write_config
113+
self._measure_latency = measure_latency
114+
self._measure_errors = measure_errors
115+
116+
async def read(self, key: str, context: Context, default_stage: Stage, payload: Optional[Any] = None) -> OperationResult:
117+
stage, tracker = await self._client.migration_variation(key, context, default_stage)
118+
tracker.operation(Operation.READ)
119+
120+
old = AsyncExecutor(Origin.OLD, self._read_config.old, tracker, self._measure_latency, self._measure_errors, payload)
121+
new = AsyncExecutor(Origin.NEW, self._read_config.new, tracker, self._measure_latency, self._measure_errors, payload)
122+
123+
if stage == Stage.OFF:
124+
result = await old.run()
125+
elif stage == Stage.DUALWRITE:
126+
result = await old.run()
127+
elif stage == Stage.SHADOW:
128+
result = await self.__read_both(old, new, tracker)
129+
elif stage == Stage.LIVE:
130+
result = await self.__read_both(new, old, tracker)
131+
elif stage == Stage.RAMPDOWN:
132+
result = await new.run()
133+
else:
134+
result = await new.run()
135+
136+
# track_migration_op is synchronous on the async client; do not await it.
137+
self._client.track_migration_op(tracker)
138+
139+
return result
140+
141+
async def write(self, key: str, context: Context, default_stage: Stage, payload: Optional[Any] = None) -> WriteResult:
142+
stage, tracker = await self._client.migration_variation(key, context, default_stage)
143+
tracker.operation(Operation.WRITE)
144+
145+
old = AsyncExecutor(Origin.OLD, self._write_config.old, tracker, self._measure_latency, self._measure_errors, payload)
146+
new = AsyncExecutor(Origin.NEW, self._write_config.new, tracker, self._measure_latency, self._measure_errors, payload)
147+
148+
if stage == Stage.OFF:
149+
result = await old.run()
150+
write_result = WriteResult(result)
151+
elif stage == Stage.DUALWRITE:
152+
authoritative_result, nonauthoritative_result = await self.__write_both(old, new, tracker)
153+
write_result = WriteResult(authoritative_result, nonauthoritative_result)
154+
elif stage == Stage.SHADOW:
155+
authoritative_result, nonauthoritative_result = await self.__write_both(old, new, tracker)
156+
write_result = WriteResult(authoritative_result, nonauthoritative_result)
157+
elif stage == Stage.LIVE:
158+
authoritative_result, nonauthoritative_result = await self.__write_both(new, old, tracker)
159+
write_result = WriteResult(authoritative_result, nonauthoritative_result)
160+
elif stage == Stage.RAMPDOWN:
161+
authoritative_result, nonauthoritative_result = await self.__write_both(new, old, tracker)
162+
write_result = WriteResult(authoritative_result, nonauthoritative_result)
163+
else:
164+
result = await new.run()
165+
write_result = WriteResult(result)
166+
167+
# track_migration_op is synchronous on the async client; do not await it.
168+
self._client.track_migration_op(tracker)
169+
170+
return write_result
171+
172+
async def __read_both(self, authoritative: AsyncExecutor, nonauthoritative: AsyncExecutor, tracker: OpTracker) -> OperationResult:
173+
if self._read_execution_order == ExecutionOrder.PARALLEL:
174+
authoritative_result, nonauthoritative_result = await asyncio.gather(
175+
authoritative.run(),
176+
nonauthoritative.run(),
177+
)
178+
elif self._read_execution_order == ExecutionOrder.RANDOM and self._sampler.sample(2):
179+
nonauthoritative_result = await nonauthoritative.run()
180+
authoritative_result = await authoritative.run()
181+
else:
182+
authoritative_result = await authoritative.run()
183+
nonauthoritative_result = await nonauthoritative.run()
184+
185+
if self._read_config.comparison is None:
186+
return authoritative_result
187+
188+
compare = self._read_config.comparison
189+
if authoritative_result.is_success() and nonauthoritative_result.is_success():
190+
tracker.consistent(lambda: compare(authoritative_result.value, nonauthoritative_result.value))
191+
192+
return authoritative_result
193+
194+
async def __write_both(self, authoritative: AsyncExecutor, nonauthoritative: AsyncExecutor, tracker: OpTracker) -> Tuple[OperationResult, Optional[OperationResult]]:
195+
authoritative_result = await authoritative.run()
196+
tracker.invoked(authoritative.origin)
197+
198+
if not authoritative_result.is_success():
199+
return authoritative_result, None
200+
201+
nonauthoritative_result = await nonauthoritative.run()
202+
tracker.invoked(nonauthoritative.origin)
203+
204+
return authoritative_result, nonauthoritative_result
205+
206+
207+
class AsyncMigrationConfig(_MigrationConfigBase[AsyncMigratorFn]):
208+
"""
209+
The async counterpart to :class:`ldclient.migrations.MigrationConfig`. It
210+
stores references to coroutine functions which execute customer defined
211+
read or write operations on old or new origins of information. For read
212+
operations, an optional (synchronous) comparison function can also be
213+
defined.
214+
215+
.. caution::
216+
This feature is experimental and should NOT be considered ready for production
217+
use. It may change or be removed without notice and is not subject to backwards
218+
compatibility guarantees.
219+
"""
220+
221+
222+
class AsyncMigratorBuilder(_MigratorBuilderBase):
223+
"""
224+
The async migration builder is used to configure and construct an instance
225+
of an :class:`AsyncMigrator`. This migrator can be used to perform
226+
LaunchDarkly assisted technology migrations through the use of
227+
migration-based feature flags.
228+
229+
.. caution::
230+
This feature is experimental and should NOT be considered ready for production
231+
use. It may change or be removed without notice and is not subject to backwards
232+
compatibility guarantees. Pin to a specific minor version and review the changelog
233+
before upgrading.
234+
"""
235+
236+
def __init__(self, client: AsyncLDClient):
237+
# Single _ to prevent mangling; useful for testing
238+
self._client = client
239+
240+
# Default settings as required by the spec
241+
self._read_execution_order = ExecutionOrder.PARALLEL
242+
self._measure_latency = True
243+
self._measure_errors = True
244+
245+
self.__read_config: Optional[AsyncMigrationConfig] = None
246+
self.__write_config: Optional[AsyncMigrationConfig] = None
247+
248+
def read(self, old: AsyncMigratorFn, new: AsyncMigratorFn, comparison: Optional[MigratorCompareFn] = None) -> 'AsyncMigratorBuilder':
249+
"""
250+
Read can be used to configure the migration-read behavior of the
251+
resulting :class:`AsyncMigrator` instance.
252+
253+
Users are required to provide two different read coroutine functions --
254+
one to read from the old migration origin, and one to read from the new
255+
origin. Additionally, customers can opt-in to consistency tracking by
256+
providing a comparison function.
257+
258+
Depending on the migration stage, one or both of these read methods may
259+
be called.
260+
261+
The read methods should accept a single nullable parameter. This
262+
parameter is a payload passed through the :func:`AsyncMigrator.read`
263+
method. This method should return a :class:`ldclient.Result` instance.
264+
265+
The consistency method should accept 2 parameters of any type. These
266+
parameters are the results of executing the read operation against the
267+
old and new origins. If both operations were successful, the
268+
consistency method will be invoked. This method should return true if
269+
the two parameters are equal, or false otherwise. The comparison
270+
function is synchronous.
271+
272+
:param old: The coroutine function to execute when reading from the old origin
273+
:param new: The coroutine function to execute when reading from the new origin
274+
:param comparison: An optional function to use for comparing the results from two origins
275+
"""
276+
self.__read_config = AsyncMigrationConfig(old, new, comparison)
277+
return self
278+
279+
def write(self, old: AsyncMigratorFn, new: AsyncMigratorFn) -> 'AsyncMigratorBuilder':
280+
"""
281+
Write can be used to configure the migration-write behavior of the
282+
resulting :class:`AsyncMigrator` instance.
283+
284+
Users are required to provide two different write coroutine functions --
285+
one to write to the old migration origin, and one to write to the new
286+
origin.
287+
288+
Depending on the migration stage, one or both of these write methods
289+
may be called.
290+
291+
The write methods should accept a single nullable parameter. This
292+
parameter is a payload passed through the :func:`AsyncMigrator.write`
293+
method. This method should return a :class:`ldclient.Result` instance.
294+
295+
:param old: The coroutine function to execute when writing to the old origin
296+
:param new: The coroutine function to execute when writing to the new origin
297+
"""
298+
self.__write_config = AsyncMigrationConfig(old, new)
299+
return self
300+
301+
def build(self) -> Union[AsyncMigrator, str]:
302+
"""
303+
Build constructs an :class:`AsyncMigrator` instance to support
304+
migration-based reads and writes. A string describing any failure
305+
conditions will be returned if the build fails.
306+
"""
307+
if self.__read_config is None:
308+
return "read configuration not provided"
309+
310+
if self.__write_config is None:
311+
return "write configuration not provided"
312+
313+
return AsyncMigratorImpl(
314+
Sampler(Random()),
315+
self._client,
316+
self._read_execution_order,
317+
self.__read_config,
318+
self.__write_config,
319+
self._measure_latency,
320+
self._measure_errors,
321+
)
322+
323+
324+
class AsyncExecutor:
325+
"""
326+
Utility class for executing async migration operations while also tracking
327+
our built-in migration measurements.
328+
"""
329+
330+
def __init__(self, origin: Origin, fn: AsyncMigratorFn, tracker: OpTracker, measure_latency: bool, measure_errors: bool, payload: Any):
331+
self.__origin = origin
332+
self.__fn = fn
333+
self.__tracker = tracker
334+
self.__measure_latency = measure_latency
335+
self.__measure_errors = measure_errors
336+
self.__payload = payload
337+
338+
@property
339+
def origin(self) -> Origin:
340+
return self.__origin
341+
342+
async def run(self) -> OperationResult:
343+
"""
344+
Execute the configured operation and track any available measurements.
345+
"""
346+
start = datetime.now()
347+
348+
try:
349+
result = await self.__fn(self.__payload)
350+
except Exception as e:
351+
result = Result.fail(f"'{self.__origin.value} operation raised an exception", e)
352+
353+
# Record required tracker measurements
354+
if self.__measure_latency:
355+
self.__tracker.latency(self.__origin, datetime.now() - start)
356+
357+
if self.__measure_errors and not result.is_success():
358+
self.__tracker.error(self.__origin)
359+
360+
self.__tracker.invoked(self.__origin)
361+
362+
return OperationResult(self.__origin, result)

0 commit comments

Comments
 (0)