atlan-application-sdk 0.1.1rc34__py3-none-any.whl → 0.1.1rc36__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. application_sdk/activities/__init__.py +3 -2
  2. application_sdk/activities/common/utils.py +21 -1
  3. application_sdk/activities/lock_management.py +110 -0
  4. application_sdk/activities/metadata_extraction/base.py +4 -2
  5. application_sdk/activities/metadata_extraction/sql.py +13 -12
  6. application_sdk/activities/query_extraction/sql.py +24 -20
  7. application_sdk/clients/atlan_auth.py +2 -2
  8. application_sdk/clients/redis.py +443 -0
  9. application_sdk/clients/temporal.py +36 -196
  10. application_sdk/common/error_codes.py +24 -3
  11. application_sdk/constants.py +18 -1
  12. application_sdk/decorators/__init__.py +0 -0
  13. application_sdk/decorators/locks.py +42 -0
  14. application_sdk/handlers/base.py +18 -1
  15. application_sdk/inputs/json.py +6 -4
  16. application_sdk/inputs/parquet.py +16 -13
  17. application_sdk/interceptors/__init__.py +0 -0
  18. application_sdk/interceptors/events.py +193 -0
  19. application_sdk/interceptors/lock.py +139 -0
  20. application_sdk/outputs/__init__.py +6 -3
  21. application_sdk/outputs/json.py +9 -6
  22. application_sdk/outputs/parquet.py +10 -36
  23. application_sdk/server/fastapi/__init__.py +4 -5
  24. application_sdk/services/__init__.py +18 -0
  25. application_sdk/{outputs → services}/atlan_storage.py +64 -16
  26. application_sdk/{outputs → services}/eventstore.py +68 -6
  27. application_sdk/services/objectstore.py +407 -0
  28. application_sdk/services/secretstore.py +344 -0
  29. application_sdk/services/statestore.py +267 -0
  30. application_sdk/version.py +1 -1
  31. application_sdk/worker.py +1 -1
  32. {atlan_application_sdk-0.1.1rc34.dist-info → atlan_application_sdk-0.1.1rc36.dist-info}/METADATA +4 -2
  33. {atlan_application_sdk-0.1.1rc34.dist-info → atlan_application_sdk-0.1.1rc36.dist-info}/RECORD +36 -32
  34. application_sdk/common/credential_utils.py +0 -85
  35. application_sdk/inputs/objectstore.py +0 -238
  36. application_sdk/inputs/secretstore.py +0 -130
  37. application_sdk/inputs/statestore.py +0 -101
  38. application_sdk/outputs/objectstore.py +0 -125
  39. application_sdk/outputs/secretstore.py +0 -38
  40. application_sdk/outputs/statestore.py +0 -113
  41. {atlan_application_sdk-0.1.1rc34.dist-info → atlan_application_sdk-0.1.1rc36.dist-info}/WHEEL +0 -0
  42. {atlan_application_sdk-0.1.1rc34.dist-info → atlan_application_sdk-0.1.1rc36.dist-info}/licenses/LICENSE +0 -0
  43. {atlan_application_sdk-0.1.1rc34.dist-info → atlan_application_sdk-0.1.1rc36.dist-info}/licenses/NOTICE +0 -0
@@ -0,0 +1,443 @@
1
+ """Redis client for distributed locking with high availability support."""
2
+
3
+ from enum import Enum
4
+ from typing import NoReturn, Union
5
+
6
+ import redis
7
+ import redis.asyncio as async_redis
8
+ from redis.exceptions import ConnectionError, RedisError, TimeoutError
9
+
10
+ from application_sdk.common.error_codes import ClientError
11
+ from application_sdk.constants import (
12
+ IS_LOCKING_DISABLED,
13
+ REDIS_HOST,
14
+ REDIS_PASSWORD,
15
+ REDIS_PORT,
16
+ REDIS_SENTINEL_HOSTS,
17
+ REDIS_SENTINEL_SERVICE_NAME,
18
+ )
19
+ from application_sdk.observability.logger_adaptor import get_logger
20
+
21
+ logger = get_logger(__name__)
22
+
23
+
24
+ def _handle_redis_error(e: Exception) -> NoReturn:
25
+ """Handle Redis errors with consistent error mapping.
26
+
27
+ Args:
28
+ e: The Redis exception that occurred
29
+
30
+ Raises:
31
+ ClientError: Appropriate ClientError based on exception type
32
+ """
33
+ if isinstance(e, ConnectionError):
34
+ raise ClientError(f"{ClientError.REDIS_CONNECTION_ERROR}: {e}")
35
+ elif isinstance(e, TimeoutError):
36
+ raise ClientError(f"{ClientError.REDIS_TIMEOUT_ERROR}: {e}")
37
+ elif isinstance(e, RedisError):
38
+ raise ClientError(f"{ClientError.REDIS_PROTOCOL_ERROR}: {e}")
39
+ else:
40
+ raise ClientError(f"{ClientError.REDIS_CONNECTION_ERROR}: {e}")
41
+
42
+
43
+ class LockReleaseResult(Enum):
44
+ """Enum for lock release operation results."""
45
+
46
+ SUCCESS = "success"
47
+ ALREADY_RELEASED = "already_released"
48
+ WRONG_OWNER = "wrong_owner"
49
+
50
+
51
+ _LOCK_RELEASE_LUA_SCRIPT = """
52
+ local current_owner = redis.call("GET", KEYS[1])
53
+ if current_owner == false then
54
+ return -1 -- Key doesn't exist
55
+ elseif current_owner ~= ARGV[1] then
56
+ return -2 -- Wrong owner
57
+ else
58
+ return redis.call("DEL", KEYS[1]) -- Success (returns 1)
59
+ end
60
+ """
61
+
62
+
63
+ class BaseRedisClient:
64
+ """Base Redis client with common functionality."""
65
+
66
+ def __init__(self):
67
+ """Initialize Redis client configuration."""
68
+ if IS_LOCKING_DISABLED:
69
+ logger.info("Strict locking disabled - skipping Redis connection")
70
+ return
71
+
72
+ # Validate Redis configuration
73
+ if not REDIS_PASSWORD or (
74
+ not REDIS_SENTINEL_HOSTS and not (REDIS_HOST and REDIS_PORT)
75
+ ):
76
+ logger.error(
77
+ "Redis configuration invalid: REDIS_PASSWORD is required and either REDIS_SENTINEL_HOSTS or REDIS_HOST/REDIS_PORT must be configured"
78
+ )
79
+ raise ClientError(
80
+ f"{ClientError.REQUEST_VALIDATION_ERROR}: Redis configuration invalid - REDIS_PASSWORD is required and either REDIS_SENTINEL_HOSTS or REDIS_HOST/REDIS_PORT must be configured"
81
+ )
82
+
83
+ def _parse_sentinel_hosts(self) -> list[tuple[str, int]]:
84
+ """Parse sentinel hosts from configuration.
85
+
86
+ Returns:
87
+ List of (host, port) tuples
88
+
89
+ Raises:
90
+ ValueError: If host format is invalid
91
+ ClientError: If no hosts are configured
92
+ """
93
+ try:
94
+ sentinel_hosts = [
95
+ (host.strip(), int(port))
96
+ for host_port in REDIS_SENTINEL_HOSTS.split(",")
97
+ for host, port in [host_port.strip().rsplit(":", 1)]
98
+ ]
99
+ except ValueError as e:
100
+ logger.error(
101
+ f"Invalid Sentinel host format in REDIS_SENTINEL_HOSTS '{REDIS_SENTINEL_HOSTS}': {e}"
102
+ )
103
+ raise
104
+
105
+ if not sentinel_hosts:
106
+ logger.error("No Sentinel hosts configured")
107
+ raise ClientError(
108
+ f"{ClientError.REQUEST_VALIDATION_ERROR}: No Sentinel hosts configured"
109
+ )
110
+
111
+ return sentinel_hosts
112
+
113
+ def _process_lock_release_result(
114
+ self, result: Union[int, None], resource_id: str
115
+ ) -> tuple[bool, LockReleaseResult]:
116
+ """Process lock release Lua script result.
117
+
118
+ Args:
119
+ result (int | None): Result from Redis eval command.
120
+ resource_id (str): Resource ID for logging.
121
+
122
+ Returns:
123
+ tuple[bool, LockReleaseResult]: A tuple ``(success, outcome)``.
124
+
125
+ Raises:
126
+ ClientError: If result type is unexpected or unknown.
127
+ """
128
+ if not isinstance(result, int):
129
+ logger.error(
130
+ f"Unexpected eval result type for {resource_id}: {type(result)}, value: {result}"
131
+ )
132
+ raise ClientError(
133
+ f"{ClientError.REDIS_CONNECTION_ERROR}: Redis connection failed"
134
+ )
135
+
136
+ if result >= 1:
137
+ return True, LockReleaseResult.SUCCESS
138
+ elif result == -1:
139
+ return (
140
+ True,
141
+ LockReleaseResult.ALREADY_RELEASED,
142
+ ) # Not an error - TTL expired
143
+ elif result == -2:
144
+ return False, LockReleaseResult.WRONG_OWNER
145
+ else:
146
+ logger.error(f"Unknown Redis eval result for {resource_id}: {result}")
147
+ raise ClientError(
148
+ f"{ClientError.REDIS_CONNECTION_ERROR}: Redis connection failed"
149
+ )
150
+
151
+
152
+ class RedisClient(BaseRedisClient):
153
+ """Synchronous Redis client for distributed locking."""
154
+
155
+ def __init__(self):
156
+ """Initialize sync Redis client."""
157
+ super().__init__()
158
+ self.redis_client = None
159
+
160
+ def _connect(self) -> None:
161
+ """Establish sync Redis connection."""
162
+ if IS_LOCKING_DISABLED:
163
+ logger.info("Locking disabled - Redis client will operate in no-op mode")
164
+ return
165
+
166
+ try:
167
+ if REDIS_SENTINEL_HOSTS:
168
+ self._connect_via_sentinel()
169
+ else:
170
+ self._connect_standalone()
171
+
172
+ # Test connection
173
+ if not self.redis_client:
174
+ raise ClientError(
175
+ f"{ClientError.REDIS_CONNECTION_ERROR}: Redis connection failed"
176
+ )
177
+
178
+ self.redis_client.ping()
179
+ logger.info("Sync Redis connection established for strict locking")
180
+
181
+ except (ConnectionError, TimeoutError, RedisError, Exception) as e:
182
+ _handle_redis_error(e)
183
+
184
+ def _connect_via_sentinel(self) -> None:
185
+ """Connect to Redis via Sentinel using sync client."""
186
+ sentinel_hosts = self._parse_sentinel_hosts()
187
+ logger.info(f"Connecting to Redis via sync Sentinel: {sentinel_hosts}")
188
+ logger.info(f"Service name: {REDIS_SENTINEL_SERVICE_NAME}")
189
+
190
+ try:
191
+ # Create Sentinel with password
192
+ sentinel = redis.sentinel.Sentinel(
193
+ sentinel_hosts, sentinel_kwargs={"password": REDIS_PASSWORD}
194
+ )
195
+
196
+ # Create master client with password
197
+ self.redis_client = sentinel.master_for(
198
+ REDIS_SENTINEL_SERVICE_NAME, password=REDIS_PASSWORD
199
+ )
200
+
201
+ except (ConnectionError, TimeoutError, RedisError, Exception) as e:
202
+ _handle_redis_error(e)
203
+
204
+ def _connect_standalone(self) -> None:
205
+ """Connect to standalone Redis instance using sync client."""
206
+ logger.debug(f"Connecting to standalone sync Redis: {REDIS_HOST}:{REDIS_PORT}")
207
+
208
+ try:
209
+ self.redis_client = redis.Redis(
210
+ host=REDIS_HOST, port=int(REDIS_PORT), password=REDIS_PASSWORD
211
+ )
212
+
213
+ except (ConnectionError, TimeoutError, RedisError, Exception) as e:
214
+ _handle_redis_error(e)
215
+
216
+ def close(self) -> None:
217
+ """Close the sync Redis client and clean up resources."""
218
+ if self.redis_client:
219
+ try:
220
+ self.redis_client.close()
221
+ logger.info("Sync Redis connection closed")
222
+ except Exception as e:
223
+ logger.error(f"Error closing sync Redis connection: {e}")
224
+ finally:
225
+ self.redis_client = None
226
+
227
+ def __enter__(self):
228
+ """Sync context manager entry."""
229
+ self._connect()
230
+ return self
231
+
232
+ def __exit__(self, exc_type, exc_val, exc_tb):
233
+ """Sync context manager exit with guaranteed cleanup."""
234
+ self.close()
235
+
236
+ def _acquire_lock(
237
+ self, resource_id: str, owner_id: str = "default_owner", ttl_seconds: int = 100
238
+ ) -> bool:
239
+ """Synchronously acquire a distributed lock.
240
+
241
+ Args:
242
+ resource_id: Unique identifier for the resource to lock
243
+ owner_id: Identifier for the lock owner
244
+ ttl_seconds: Time-to-live for the lock in seconds
245
+
246
+ Returns:
247
+ True if lock was acquired, False if lock is already held by another owner
248
+
249
+ Raises:
250
+ ClientError: If Redis connection or operation fails
251
+ """
252
+ if not self.redis_client:
253
+ logger.error("Sync Redis client not initialized")
254
+ raise ClientError(
255
+ f"{ClientError.REDIS_CONNECTION_ERROR}: Redis connection failed"
256
+ )
257
+
258
+ try:
259
+ result = self.redis_client.set(
260
+ resource_id, owner_id, nx=True, ex=ttl_seconds
261
+ )
262
+ return bool(result)
263
+ except (ConnectionError, TimeoutError, RedisError, Exception) as e:
264
+ _handle_redis_error(e)
265
+
266
+ def _release_lock(
267
+ self, resource_id: str, owner_id: str = "default_owner"
268
+ ) -> tuple[bool, LockReleaseResult]:
269
+ """Synchronously release a lock with ownership verification.
270
+
271
+ Args:
272
+ resource_id (str): Unique identifier for the resource to unlock.
273
+ owner_id (str): Identifier for the lock owner.
274
+
275
+ Returns:
276
+ tuple[bool, LockReleaseResult]: Result of the release operation.
277
+ - (True, LockReleaseResult.SUCCESS): Lock released successfully.
278
+ - (True, LockReleaseResult.ALREADY_RELEASED): Lock was already released (TTL expired).
279
+ - (False, LockReleaseResult.WRONG_OWNER): Lock owned by a different owner.
280
+
281
+ Raises:
282
+ ClientError: If Redis connection or operation fails.
283
+ """
284
+ if not self.redis_client:
285
+ logger.error("Sync Redis client not initialized")
286
+ raise ClientError(
287
+ f"{ClientError.REDIS_CONNECTION_ERROR}: Redis connection failed"
288
+ )
289
+
290
+ try:
291
+ result = self.redis_client.eval(
292
+ _LOCK_RELEASE_LUA_SCRIPT, 1, resource_id, owner_id
293
+ )
294
+ return self._process_lock_release_result(result, resource_id)
295
+ except (ConnectionError, TimeoutError, RedisError, Exception) as e:
296
+ _handle_redis_error(e)
297
+
298
+
299
+ class RedisClientAsync(BaseRedisClient):
300
+ """Asynchronous Redis client for distributed locking."""
301
+
302
+ def __init__(self):
303
+ """Initialize async Redis client."""
304
+ super().__init__()
305
+ self.redis_client = None
306
+
307
+ async def _connect(self) -> None:
308
+ """Establish async Redis connection."""
309
+ if IS_LOCKING_DISABLED:
310
+ logger.info("Locking disabled - Redis client will operate in no-op mode")
311
+ return
312
+
313
+ try:
314
+ if REDIS_SENTINEL_HOSTS:
315
+ await self._connect_via_sentinel()
316
+ else:
317
+ await self._connect_standalone()
318
+
319
+ # Test connection
320
+ if not self.redis_client:
321
+ raise ClientError(
322
+ f"{ClientError.REDIS_CONNECTION_ERROR}: Redis connection failed"
323
+ )
324
+
325
+ await self.redis_client.ping()
326
+ logger.info("Async Redis connection established for strict locking")
327
+
328
+ except (ConnectionError, TimeoutError, RedisError, Exception) as e:
329
+ _handle_redis_error(e)
330
+
331
+ async def _connect_via_sentinel(self) -> None:
332
+ """Connect to Redis via Sentinel using async client."""
333
+ sentinel_hosts = self._parse_sentinel_hosts()
334
+ logger.info(f"Connecting to Redis via async Sentinel: {sentinel_hosts}")
335
+ logger.info(f"Service name: {REDIS_SENTINEL_SERVICE_NAME}")
336
+
337
+ try:
338
+ # Create Sentinel with password
339
+ sentinel = async_redis.sentinel.Sentinel(
340
+ sentinel_hosts, sentinel_kwargs={"password": REDIS_PASSWORD}
341
+ )
342
+
343
+ # Create master client with password
344
+ self.redis_client = sentinel.master_for(
345
+ REDIS_SENTINEL_SERVICE_NAME, password=REDIS_PASSWORD
346
+ )
347
+
348
+ except (ConnectionError, TimeoutError, RedisError, Exception) as e:
349
+ _handle_redis_error(e)
350
+
351
+ async def _connect_standalone(self) -> None:
352
+ """Connect to standalone Redis instance using async client."""
353
+ logger.debug(f"Connecting to standalone async Redis: {REDIS_HOST}:{REDIS_PORT}")
354
+
355
+ try:
356
+ self.redis_client = async_redis.Redis(
357
+ host=REDIS_HOST, port=int(REDIS_PORT), password=REDIS_PASSWORD
358
+ )
359
+
360
+ except (ConnectionError, TimeoutError, RedisError, Exception) as e:
361
+ _handle_redis_error(e)
362
+
363
+ async def close(self) -> None:
364
+ """Close the Redis client and clean up resources."""
365
+ if self.redis_client:
366
+ try:
367
+ await self.redis_client.aclose()
368
+ logger.info("Async Redis connection closed")
369
+ except Exception as e:
370
+ logger.error(f"Error closing async Redis connection: {e}")
371
+ finally:
372
+ self.redis_client = None
373
+
374
+ async def __aenter__(self):
375
+ """Async context manager entry."""
376
+ await self._connect()
377
+ return self
378
+
379
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
380
+ """Async context manager exit with guaranteed cleanup."""
381
+ await self.close()
382
+
383
+ async def _acquire_lock(
384
+ self, resource_id: str, owner_id: str = "default_owner", ttl_seconds: int = 100
385
+ ) -> bool:
386
+ """Asynchronously acquire a distributed lock.
387
+
388
+ Args:
389
+ resource_id: Unique identifier for the resource to lock
390
+ owner_id: Identifier for the lock owner
391
+ ttl_seconds: Time-to-live for the lock in seconds
392
+
393
+ Returns:
394
+ True if lock was acquired, False if lock is already held by another owner
395
+
396
+ Raises:
397
+ ClientError: If Redis connection or operation fails
398
+ """
399
+ if not self.redis_client:
400
+ logger.error("Redis client not initialized")
401
+ raise ClientError(
402
+ f"{ClientError.REDIS_CONNECTION_ERROR}: Redis connection failed"
403
+ )
404
+
405
+ try:
406
+ result = await self.redis_client.set(
407
+ resource_id, owner_id, nx=True, ex=ttl_seconds
408
+ )
409
+ return bool(result)
410
+ except (ConnectionError, TimeoutError, RedisError, Exception) as e:
411
+ _handle_redis_error(e)
412
+
413
+ async def _release_lock(
414
+ self, resource_id: str, owner_id: str = "default_owner"
415
+ ) -> tuple[bool, LockReleaseResult]:
416
+ """Asynchronously release a lock with ownership verification.
417
+
418
+ Args:
419
+ resource_id (str): Unique identifier for the resource to unlock.
420
+ owner_id (str): Identifier for the lock owner.
421
+
422
+ Returns:
423
+ tuple[bool, LockReleaseResult]: Result of the release operation.
424
+ - (True, LockReleaseResult.SUCCESS): Lock released successfully.
425
+ - (True, LockReleaseResult.ALREADY_RELEASED): Lock was already released (TTL expired).
426
+ - (False, LockReleaseResult.WRONG_OWNER): Lock owned by a different owner.
427
+
428
+ Raises:
429
+ ClientError: If Redis connection or operation fails.
430
+ """
431
+ if not self.redis_client:
432
+ logger.error("Redis client not initialized")
433
+ raise ClientError(
434
+ f"{ClientError.REDIS_CONNECTION_ERROR}: Redis connection failed"
435
+ )
436
+
437
+ try:
438
+ result = await self.redis_client.eval(
439
+ _LOCK_RELEASE_LUA_SCRIPT, 1, resource_id, owner_id
440
+ )
441
+ return self._process_lock_release_result(result, resource_id)
442
+ except (ConnectionError, TimeoutError, RedisError, Exception) as e:
443
+ _handle_redis_error(e)