aws-python-helper 1.0.0__tar.gz → 1.0.3__tar.gz

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 (55) hide show
  1. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/PKG-INFO +156 -5
  2. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/README.md +155 -4
  3. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/__init__.py +5 -1
  4. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/api/fetcher.py +4 -3
  5. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/fargate/fetcher.py +4 -3
  6. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/lambda_standalone/base.py +24 -5
  7. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/lambda_standalone/fetcher.py +4 -3
  8. aws_python_helper-1.0.3/aws_python_helper/model_index_sync/__init__.py +3 -0
  9. aws_python_helper-1.0.3/aws_python_helper/model_index_sync/base.py +191 -0
  10. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/repository/base.py +108 -22
  11. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/sqs/fetcher.py +4 -3
  12. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper.egg-info/PKG-INFO +156 -5
  13. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper.egg-info/SOURCES.txt +2 -0
  14. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/pyproject.toml +1 -1
  15. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/api/__init__.py +0 -0
  16. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/api/auth_middleware.py +0 -0
  17. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/api/auth_validators.py +0 -0
  18. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/api/base.py +0 -0
  19. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/api/dispatcher.py +0 -0
  20. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/api/exceptions.py +0 -0
  21. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/api/handler.py +0 -0
  22. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/context/__init__.py +0 -0
  23. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/context/session.py +0 -0
  24. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/context/state_validator.py +0 -0
  25. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/database/__init__.py +0 -0
  26. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/database/database_proxy.py +0 -0
  27. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/database/external_database_proxy.py +0 -0
  28. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/database/external_mongo_manager.py +0 -0
  29. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/database/mongo_manager.py +0 -0
  30. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/fargate/__init__.py +0 -0
  31. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/fargate/executor.py +0 -0
  32. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/fargate/handler.py +0 -0
  33. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/fargate/task_base.py +0 -0
  34. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/invoker/__init__.py +0 -0
  35. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/invoker/api_client.py +0 -0
  36. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/invoker/exceptions.py +0 -0
  37. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/invoker/lambda_invoker.py +0 -0
  38. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/lambda_standalone/__init__.py +0 -0
  39. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/lambda_standalone/handler.py +0 -0
  40. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/model_query/__init__.py +0 -0
  41. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/model_query/base.py +0 -0
  42. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/repository/__init__.py +0 -0
  43. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/sns/__init__.py +0 -0
  44. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/sns/publisher.py +0 -0
  45. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/sqs/__init__.py +0 -0
  46. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/sqs/consumer_base.py +0 -0
  47. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/sqs/handler.py +0 -0
  48. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/utils/__init__.py +0 -0
  49. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/utils/json_encoder.py +0 -0
  50. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/utils/response.py +0 -0
  51. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper/utils/serializer.py +0 -0
  52. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper.egg-info/dependency_links.txt +0 -0
  53. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper.egg-info/requires.txt +0 -0
  54. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/aws_python_helper.egg-info/top_level.txt +0 -0
  55. {aws_python_helper-1.0.0 → aws_python_helper-1.0.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-python-helper
3
- Version: 1.0.0
3
+ Version: 1.0.3
4
4
  Summary: AWS Python Helper Framework
5
5
  Author-email: Fabian Claros <neufabiae@gmail.com>
6
6
  License-Expression: MIT
@@ -39,6 +39,7 @@ Mini-framework to create REST APIs, SQS Consumers, SNS Publishers, Fargate Tasks
39
39
  - **LambdaInvoker**: Invoke other Lambda functions (sync or async) with built-in error handling
40
40
  - **ApiClient**: HTTP client for inter-service communication — resolves service URLs and auth token from environment variables automatically
41
41
  - **ModelQueryLambda**: Base class for MongoDB query-proxy Lambdas — declare an `allowed_collections` whitelist and the framework handles `find` and `aggregate` operations generically
42
+ - **ModelIndexSyncLambda**: Base class for index-synchronization Lambdas — declare your `repositories` and the framework creates the declared indexes **and prunes orphans** across the `core` database and all active state databases, idempotently and in parallel, in a single run (run once per deploy)
42
43
  - **JSON utilities**: Automatic serialization of MongoDB types
43
44
  - **Type hints**: Modern Python with type annotations
44
45
  - **Async/await**: Full support for asynchronous operations
@@ -67,6 +68,7 @@ All available classes and functions:
67
68
  | `fargate_handler` | `aws_python_helper.fargate.handler` | Entry point handler for Fargate |
68
69
  | `Repository` | `aws_python_helper.repository.base` | Base class for MongoDB repositories |
69
70
  | `ModelQueryLambda` | `aws_python_helper` | Generic MongoDB query-proxy Lambda base class |
71
+ | `ModelIndexSyncLambda` | `aws_python_helper` | Index-sync Lambda base class (creates declared indexes + prunes orphans across `core` + all state DBs, in parallel) |
70
72
  | `LambdaInvoker` | `aws_python_helper` | Invoke Lambda functions (sync/async) with error handling |
71
73
  | `ApiClient` | `aws_python_helper` | HTTP client for inter-service communication |
72
74
  | `LambdaInvocationError` | `aws_python_helper` | Raised when boto3 fails to invoke a Lambda |
@@ -77,6 +79,8 @@ All available classes and functions:
77
79
  | `Session` | `aws_python_helper` | Request-scoped session object (state + user) |
78
80
  | `get_session` | `aws_python_helper` | Read the current Session from async context |
79
81
  | `set_session` | `aws_python_helper` | Set the current Session in async context |
82
+ | `StateValidator` | `aws_python_helper` | Validates a state against `core.states` (cached, active states only) |
83
+ | `InvalidStateError` | `aws_python_helper` | Raised when a state is not found or not active |
80
84
  | `MongoJSONEncoder` | `aws_python_helper.utils.json_encoder` | JSON encoder for MongoDB types |
81
85
  | `mongo_json_dumps` | `aws_python_helper.utils.json_encoder` | Helper to serialize MongoDB types |
82
86
  | `serialize_mongo_types` | `aws_python_helper.utils.serializer` | Recursively serialize MongoDB types |
@@ -250,6 +254,15 @@ class GenerateRouteLambda(Lambda):
250
254
  }
251
255
  ```
252
256
 
257
+ > **`requires_state` (session requirement)** — By default (`requires_state = True`) the framework requires the event to carry a `session` with a valid `state` and validates it against `core.states` before running. Override `requires_state` to return `False` for Lambdas that operate across **all** states and must not be tied to a single one (e.g. index synchronization) — the `session`/`state` then becomes optional. See [ModelIndexSyncLambda](#-modelindexsynclambda).
258
+ >
259
+ > ```python
260
+ > class SyncAllStatesLambda(Lambda):
261
+ > @property
262
+ > def requires_state(self) -> bool:
263
+ > return False # no session.state required; runs across every state
264
+ > ```
265
+
253
266
  **2. Configure the handler** in `src/handlers/lambda_handler.py`:
254
267
 
255
268
  ```python
@@ -511,7 +524,9 @@ class AddressAPI(API):
511
524
 
512
525
  ## 🗂️ Repository Pattern
513
526
 
514
- The framework provides a `Repository` base class that eliminates repetitive boilerplate in data access layers. Each repository only declares what collection it uses, whether it belongs to an external cluster, and what indexes to create. The base class handles the MongoDB connection and index creation automatically.
527
+ The framework provides a `Repository` base class that eliminates repetitive boilerplate in data access layers. Each repository only declares what collection it uses, whether it belongs to an external cluster, and what indexes it needs. The base class handles the MongoDB connection automatically.
528
+
529
+ > **Index creation is not triggered at request time.** Declaring `indexes` only *describes* the indexes; it does not create them on collection access (this avoids per-request overhead and half-finished index builds in short-lived Lambda/API runtimes). Indexes are created by the [`ModelIndexSyncLambda`](#-modelindexsynclambda) — run once per deploy — or by calling `await repo.ensure_indexes()` explicitly.
515
530
 
516
531
  ### Properties to override
517
532
 
@@ -542,7 +557,15 @@ def indexes(self):
542
557
  ]
543
558
  ```
544
559
 
545
- Indexes are created automatically in the background on first collection access no need to call any initialization method.
560
+ These definitions are consumed by `ensure_indexes()`, which the [`ModelIndexSyncLambda`](#-modelindexsynclambda) runs across the relevant databases at deploy time. `ensure_indexes()` is idempotent (safe to re-run) and accepts an explicit `database_name` so the same state-scoped repository can be materialized in every state database:
561
+
562
+ ```python
563
+ # Create this repository's indexes in a specific database (what the index-sync Lambda does):
564
+ await repo.ensure_indexes(database_name="connecticut")
565
+
566
+ # Or, with no argument, in the repository's resolved database (database_key or session.state):
567
+ await repo.ensure_indexes()
568
+ ```
546
569
 
547
570
  ### Repository with a fixed database
548
571
 
@@ -669,7 +692,7 @@ The framework propagates a `Session` object automatically across the entire asyn
669
692
  | Entry point | How the session is read |
670
693
  |-------------|-------------------------|
671
694
  | **API Gateway** | `constitution-state` header → `session.state` (when `AUTHORIZATION` includes `state`); auth middleware → `session.user` (when includes `user`). Returns `400` if required header is missing |
672
- | **Standalone Lambda** | `session` dict in the event payload — **required** (must include `state`), raises `ValueError` if missing |
695
+ | **Standalone Lambda** | `session` dict in the event payload — **required by default** (must include `state`), raises `ValueError` if missing. A Lambda can opt out via `requires_state = False` (e.g. `ModelIndexSyncLambda`), making the session optional |
673
696
  | **SQS Consumer (single mode)** | Per-record: reads `session` from SNS `MessageAttributes` (Base64-encoded JSON) |
674
697
  | **SQS Consumer (batch mode)** | Groups records by `session.state`; calls `process_batch()` once per group with the correct session in context |
675
698
  | **Fargate Task** | `SESSION` env var (JSON) — auto-injected by `FargateExecutor` |
@@ -1179,13 +1202,15 @@ Create `src/lambda/model-query/main.py` in the microservice that owns the data:
1179
1202
  ```python
1180
1203
  from aws_python_helper import ModelQueryLambda
1181
1204
 
1182
- class ModelQueryLambda(ModelQueryLambda):
1205
+ class OrdersModelQueryLambda(ModelQueryLambda):
1183
1206
 
1184
1207
  @property
1185
1208
  def allowed_collections(self) -> list:
1186
1209
  return ["orders", "customers"] # whitelist — unlisted collections are rejected
1187
1210
  ```
1188
1211
 
1212
+ > **Class discovery:** name your subclass distinctly from the imported base (e.g. `OrdersModelQueryLambda`, not `ModelQueryLambda`). The framework loads the class **defined in the file** — not the imported base — so subclassing an imported concrete base (`ModelQueryLambda`, `ModelIndexSyncLambda`) works without name collisions.
1213
+
1189
1214
  Register the handler in `src/handlers/lambda_handler.py`:
1190
1215
 
1191
1216
  ```python
@@ -1333,6 +1358,132 @@ Always pass `self.session.to_dict()` when calling from an API or Lambda to ensur
1333
1358
 
1334
1359
  ---
1335
1360
 
1361
+ ## 🧱 ModelIndexSyncLambda
1362
+
1363
+ `ModelIndexSyncLambda` is a ready-to-use base class for **synchronizing all of a service's MongoDB indexes deterministically**, in one run. Because index creation is no longer triggered at request time (see the [Repository Pattern](#️-repository-pattern) note), this Lambda is the single source of truth for materializing indexes — typically invoked **once per deploy**.
1364
+
1365
+ **Why:** in short-lived Lambda/API runtimes, creating indexes lazily on first access often never finishes (the function responds and freezes). This Lambda creates every declared index up front, `await`-ing each one, idempotently — and across **every** active state database, not just the one of the current request.
1366
+
1367
+ It does a **two-way sync** per collection: it creates the declared indexes **and prunes orphans** — any index present in MongoDB that is no longer declared in the repository's `indexes` is dropped (the default `_id_` index and special text/geo/hashed indexes are always preserved).
1368
+
1369
+ > ⚠️ **Orphan pruning drops indexes not declared in code.** Indexes created out-of-band (by a DBA, Atlas, or another tool) that aren't in any repository's `indexes` will be removed on every run. Declare every index you want to keep.
1370
+
1371
+ **Concurrency & fault tolerance:** each `(collection, database)` unit is synced **in parallel** (bounded by `max_concurrency`, default 10); index work *within* a unit stays sequential. A failure in one unit is **captured and isolated** — it never aborts the others, so a single broken collection doesn't stop the rest of the sync. The top-level response is always `success: true`; per-unit outcomes are reported in `results[].success`.
1372
+
1373
+ ### Creating an index-sync Lambda
1374
+
1375
+ Create `src/lambda/model-index-sync/main.py` and declare which repositories to sync:
1376
+
1377
+ ```python
1378
+ import aws_python_helper
1379
+
1380
+ from repositories.parcels import ParcelsRepository # state-scoped (database_key = None)
1381
+ from repositories.users import UsersRepository # core (database_key = "core")
1382
+ from repositories.tokens import TokensRepository # core
1383
+
1384
+ class OrdersModelIndexSyncLambda(aws_python_helper.ModelIndexSyncLambda):
1385
+
1386
+ @property
1387
+ def repositories(self) -> list:
1388
+ return [ParcelsRepository, UsersRepository, TokensRepository]
1389
+ ```
1390
+
1391
+ Register the handler in `src/handlers/lambda_handler.py`:
1392
+
1393
+ ```python
1394
+ from aws_python_helper.lambda_standalone.handler import lambda_handler
1395
+
1396
+ index_sync_handler = lambda_handler('model-index-sync')
1397
+
1398
+ __all__ = ["index_sync_handler"]
1399
+ ```
1400
+
1401
+ > Declare repositories **explicitly** (don't auto-discover) so the synced set is intentional. List your own collections; **external** repositories (`is_external = True`) belong to another cluster/service and are typically excluded.
1402
+
1403
+ ### How it routes each repository
1404
+
1405
+ For every repository in the list, the Lambda decides where to create its indexes:
1406
+
1407
+ | Repository kind | `database_key` | Target databases |
1408
+ |-----------------|----------------|------------------|
1409
+ | **Core / fixed** | `"core"` (or any string) | that single database |
1410
+ | **State-scoped** | `None` | **every active state**, read from `core.states` where `is_active = true` (e.g. `connecticut`, `new_jersey`, …) |
1411
+
1412
+ A state-scoped collection such as `parcels` therefore gets its indexes created in `connecticut`, `new_jersey`, and any other active state — automatically. Creation is idempotent, so re-running it is safe.
1413
+
1414
+ This Lambda sets `requires_state = False`: it is not tied to a single state and does not require a `session` in the event.
1415
+
1416
+ ### Response format
1417
+
1418
+ The top-level `success` is **always `true`** (a failing collection no longer aborts the run). Inspect `data.summary` for the OK/failed counts and `data.results[].success` for each unit; failed units include an `error` field.
1419
+
1420
+ ```json
1421
+ {
1422
+ "success": true,
1423
+ "data": {
1424
+ "summary": { "total": 4, "succeeded": 3, "failed": 1 },
1425
+ "results": [
1426
+ {"repository": "ParcelsRepository", "database": "connecticut", "collection": "parcels", "indexes": ["parcel_id_1", "..."], "orphan_indexes_dropped": ["old_field_1"], "success": true},
1427
+ {"repository": "ParcelsRepository", "database": "new_jersey", "collection": "parcels", "indexes": ["parcel_id_1", "..."], "orphan_indexes_dropped": [], "success": true},
1428
+ {"repository": "UsersRepository", "database": "core", "collection": "users", "indexes": ["email_1"], "orphan_indexes_dropped": [], "success": true},
1429
+ {"repository": "TokensRepository", "database": "core", "collection": "tokens", "indexes": [], "orphan_indexes_dropped": [], "success": false, "error": "..."}
1430
+ ]
1431
+ }
1432
+ }
1433
+ ```
1434
+
1435
+ ### Properties to override / configure
1436
+
1437
+ | Property | Default | Purpose |
1438
+ |----------|---------|---------|
1439
+ | `repositories` | `[]` | **Required.** List of `Repository` subclasses (the classes, not instances) to sync. Empty raises a validation error |
1440
+ | `states_database` | `"core"` | Database holding the states collection |
1441
+ | `states_collection` | `"states"` | Collection listing the states (filtered by `is_active = true`, field `name`) |
1442
+ | `max_concurrency` | `10` | Max `(collection, database)` units synced in parallel. `0` (or negative) = unlimited |
1443
+ | `requires_state` | `False` | Inherited override — the Lambda runs across all states, no session required |
1444
+
1445
+ ### Running it once per deploy (Terraform)
1446
+
1447
+ Since deploys are applied manually with Terraform, attach an `aws_lambda_invocation` that runs **on every `apply`**, right after the function code is updated. `timestamp()` changes on every plan, so it always re-runs (creation is idempotent and also picks up any newly-activated state).
1448
+
1449
+ > **Detecting failures in the deploy.** The top-level `success` is now **always `true`** — a single failed collection no longer aborts the run, so checking `success` alone won't catch partial failures. If you want the `apply` to fail when any collection failed, point the `postcondition` at `data.summary.failed` instead (shown below). If you'd rather the deploy never fail on index errors and only review them afterward (in the response/logs), simply omit the `lifecycle` block.
1450
+
1451
+ ```hcl
1452
+ module "lambda" {
1453
+ source = "../../../../../modules/lambda"
1454
+ function_name = "${...service_name}${var.lambda_name}-${...env}"
1455
+ source_handler = "handlers.lambda_handler.index_sync_handler"
1456
+ timeout = 300
1457
+ memory_size = 512
1458
+ # ... mongo + vpc config (same as any other Lambda)
1459
+ }
1460
+
1461
+ resource "aws_lambda_invocation" "sync_on_deploy" {
1462
+ function_name = module.lambda.lambda_function_name
1463
+ input = jsonencode({ _invoked_at = timestamp() }) # always changes → always re-runs
1464
+
1465
+ depends_on = [module.lambda] # run after the code is updated
1466
+
1467
+ # Optional: fail the apply if any collection failed to sync.
1468
+ # Omit this whole lifecycle block if you prefer the deploy to never fail on
1469
+ # index errors (review data.results[].success / logs instead).
1470
+ lifecycle {
1471
+ postcondition {
1472
+ condition = try(jsondecode(self.result).data.summary.failed, 1) == 0
1473
+ error_message = "Index sync had failures during apply: ${self.result}"
1474
+ }
1475
+ }
1476
+ }
1477
+ ```
1478
+
1479
+ You can also invoke it manually at any time (e.g. after activating a new state without a code deploy):
1480
+
1481
+ ```bash
1482
+ aws lambda invoke --function-name MyServiceModelIndexSync-dev out.json
1483
+ ```
1484
+
1485
+ ---
1486
+
1336
1487
  ## 🏗️ Architecture Overview
1337
1488
 
1338
1489
  Typical flow for event-driven architectures using this framework:
@@ -19,6 +19,7 @@ Mini-framework to create REST APIs, SQS Consumers, SNS Publishers, Fargate Tasks
19
19
  - **LambdaInvoker**: Invoke other Lambda functions (sync or async) with built-in error handling
20
20
  - **ApiClient**: HTTP client for inter-service communication — resolves service URLs and auth token from environment variables automatically
21
21
  - **ModelQueryLambda**: Base class for MongoDB query-proxy Lambdas — declare an `allowed_collections` whitelist and the framework handles `find` and `aggregate` operations generically
22
+ - **ModelIndexSyncLambda**: Base class for index-synchronization Lambdas — declare your `repositories` and the framework creates the declared indexes **and prunes orphans** across the `core` database and all active state databases, idempotently and in parallel, in a single run (run once per deploy)
22
23
  - **JSON utilities**: Automatic serialization of MongoDB types
23
24
  - **Type hints**: Modern Python with type annotations
24
25
  - **Async/await**: Full support for asynchronous operations
@@ -47,6 +48,7 @@ All available classes and functions:
47
48
  | `fargate_handler` | `aws_python_helper.fargate.handler` | Entry point handler for Fargate |
48
49
  | `Repository` | `aws_python_helper.repository.base` | Base class for MongoDB repositories |
49
50
  | `ModelQueryLambda` | `aws_python_helper` | Generic MongoDB query-proxy Lambda base class |
51
+ | `ModelIndexSyncLambda` | `aws_python_helper` | Index-sync Lambda base class (creates declared indexes + prunes orphans across `core` + all state DBs, in parallel) |
50
52
  | `LambdaInvoker` | `aws_python_helper` | Invoke Lambda functions (sync/async) with error handling |
51
53
  | `ApiClient` | `aws_python_helper` | HTTP client for inter-service communication |
52
54
  | `LambdaInvocationError` | `aws_python_helper` | Raised when boto3 fails to invoke a Lambda |
@@ -57,6 +59,8 @@ All available classes and functions:
57
59
  | `Session` | `aws_python_helper` | Request-scoped session object (state + user) |
58
60
  | `get_session` | `aws_python_helper` | Read the current Session from async context |
59
61
  | `set_session` | `aws_python_helper` | Set the current Session in async context |
62
+ | `StateValidator` | `aws_python_helper` | Validates a state against `core.states` (cached, active states only) |
63
+ | `InvalidStateError` | `aws_python_helper` | Raised when a state is not found or not active |
60
64
  | `MongoJSONEncoder` | `aws_python_helper.utils.json_encoder` | JSON encoder for MongoDB types |
61
65
  | `mongo_json_dumps` | `aws_python_helper.utils.json_encoder` | Helper to serialize MongoDB types |
62
66
  | `serialize_mongo_types` | `aws_python_helper.utils.serializer` | Recursively serialize MongoDB types |
@@ -230,6 +234,15 @@ class GenerateRouteLambda(Lambda):
230
234
  }
231
235
  ```
232
236
 
237
+ > **`requires_state` (session requirement)** — By default (`requires_state = True`) the framework requires the event to carry a `session` with a valid `state` and validates it against `core.states` before running. Override `requires_state` to return `False` for Lambdas that operate across **all** states and must not be tied to a single one (e.g. index synchronization) — the `session`/`state` then becomes optional. See [ModelIndexSyncLambda](#-modelindexsynclambda).
238
+ >
239
+ > ```python
240
+ > class SyncAllStatesLambda(Lambda):
241
+ > @property
242
+ > def requires_state(self) -> bool:
243
+ > return False # no session.state required; runs across every state
244
+ > ```
245
+
233
246
  **2. Configure the handler** in `src/handlers/lambda_handler.py`:
234
247
 
235
248
  ```python
@@ -491,7 +504,9 @@ class AddressAPI(API):
491
504
 
492
505
  ## 🗂️ Repository Pattern
493
506
 
494
- The framework provides a `Repository` base class that eliminates repetitive boilerplate in data access layers. Each repository only declares what collection it uses, whether it belongs to an external cluster, and what indexes to create. The base class handles the MongoDB connection and index creation automatically.
507
+ The framework provides a `Repository` base class that eliminates repetitive boilerplate in data access layers. Each repository only declares what collection it uses, whether it belongs to an external cluster, and what indexes it needs. The base class handles the MongoDB connection automatically.
508
+
509
+ > **Index creation is not triggered at request time.** Declaring `indexes` only *describes* the indexes; it does not create them on collection access (this avoids per-request overhead and half-finished index builds in short-lived Lambda/API runtimes). Indexes are created by the [`ModelIndexSyncLambda`](#-modelindexsynclambda) — run once per deploy — or by calling `await repo.ensure_indexes()` explicitly.
495
510
 
496
511
  ### Properties to override
497
512
 
@@ -522,7 +537,15 @@ def indexes(self):
522
537
  ]
523
538
  ```
524
539
 
525
- Indexes are created automatically in the background on first collection access no need to call any initialization method.
540
+ These definitions are consumed by `ensure_indexes()`, which the [`ModelIndexSyncLambda`](#-modelindexsynclambda) runs across the relevant databases at deploy time. `ensure_indexes()` is idempotent (safe to re-run) and accepts an explicit `database_name` so the same state-scoped repository can be materialized in every state database:
541
+
542
+ ```python
543
+ # Create this repository's indexes in a specific database (what the index-sync Lambda does):
544
+ await repo.ensure_indexes(database_name="connecticut")
545
+
546
+ # Or, with no argument, in the repository's resolved database (database_key or session.state):
547
+ await repo.ensure_indexes()
548
+ ```
526
549
 
527
550
  ### Repository with a fixed database
528
551
 
@@ -649,7 +672,7 @@ The framework propagates a `Session` object automatically across the entire asyn
649
672
  | Entry point | How the session is read |
650
673
  |-------------|-------------------------|
651
674
  | **API Gateway** | `constitution-state` header → `session.state` (when `AUTHORIZATION` includes `state`); auth middleware → `session.user` (when includes `user`). Returns `400` if required header is missing |
652
- | **Standalone Lambda** | `session` dict in the event payload — **required** (must include `state`), raises `ValueError` if missing |
675
+ | **Standalone Lambda** | `session` dict in the event payload — **required by default** (must include `state`), raises `ValueError` if missing. A Lambda can opt out via `requires_state = False` (e.g. `ModelIndexSyncLambda`), making the session optional |
653
676
  | **SQS Consumer (single mode)** | Per-record: reads `session` from SNS `MessageAttributes` (Base64-encoded JSON) |
654
677
  | **SQS Consumer (batch mode)** | Groups records by `session.state`; calls `process_batch()` once per group with the correct session in context |
655
678
  | **Fargate Task** | `SESSION` env var (JSON) — auto-injected by `FargateExecutor` |
@@ -1159,13 +1182,15 @@ Create `src/lambda/model-query/main.py` in the microservice that owns the data:
1159
1182
  ```python
1160
1183
  from aws_python_helper import ModelQueryLambda
1161
1184
 
1162
- class ModelQueryLambda(ModelQueryLambda):
1185
+ class OrdersModelQueryLambda(ModelQueryLambda):
1163
1186
 
1164
1187
  @property
1165
1188
  def allowed_collections(self) -> list:
1166
1189
  return ["orders", "customers"] # whitelist — unlisted collections are rejected
1167
1190
  ```
1168
1191
 
1192
+ > **Class discovery:** name your subclass distinctly from the imported base (e.g. `OrdersModelQueryLambda`, not `ModelQueryLambda`). The framework loads the class **defined in the file** — not the imported base — so subclassing an imported concrete base (`ModelQueryLambda`, `ModelIndexSyncLambda`) works without name collisions.
1193
+
1169
1194
  Register the handler in `src/handlers/lambda_handler.py`:
1170
1195
 
1171
1196
  ```python
@@ -1313,6 +1338,132 @@ Always pass `self.session.to_dict()` when calling from an API or Lambda to ensur
1313
1338
 
1314
1339
  ---
1315
1340
 
1341
+ ## 🧱 ModelIndexSyncLambda
1342
+
1343
+ `ModelIndexSyncLambda` is a ready-to-use base class for **synchronizing all of a service's MongoDB indexes deterministically**, in one run. Because index creation is no longer triggered at request time (see the [Repository Pattern](#️-repository-pattern) note), this Lambda is the single source of truth for materializing indexes — typically invoked **once per deploy**.
1344
+
1345
+ **Why:** in short-lived Lambda/API runtimes, creating indexes lazily on first access often never finishes (the function responds and freezes). This Lambda creates every declared index up front, `await`-ing each one, idempotently — and across **every** active state database, not just the one of the current request.
1346
+
1347
+ It does a **two-way sync** per collection: it creates the declared indexes **and prunes orphans** — any index present in MongoDB that is no longer declared in the repository's `indexes` is dropped (the default `_id_` index and special text/geo/hashed indexes are always preserved).
1348
+
1349
+ > ⚠️ **Orphan pruning drops indexes not declared in code.** Indexes created out-of-band (by a DBA, Atlas, or another tool) that aren't in any repository's `indexes` will be removed on every run. Declare every index you want to keep.
1350
+
1351
+ **Concurrency & fault tolerance:** each `(collection, database)` unit is synced **in parallel** (bounded by `max_concurrency`, default 10); index work *within* a unit stays sequential. A failure in one unit is **captured and isolated** — it never aborts the others, so a single broken collection doesn't stop the rest of the sync. The top-level response is always `success: true`; per-unit outcomes are reported in `results[].success`.
1352
+
1353
+ ### Creating an index-sync Lambda
1354
+
1355
+ Create `src/lambda/model-index-sync/main.py` and declare which repositories to sync:
1356
+
1357
+ ```python
1358
+ import aws_python_helper
1359
+
1360
+ from repositories.parcels import ParcelsRepository # state-scoped (database_key = None)
1361
+ from repositories.users import UsersRepository # core (database_key = "core")
1362
+ from repositories.tokens import TokensRepository # core
1363
+
1364
+ class OrdersModelIndexSyncLambda(aws_python_helper.ModelIndexSyncLambda):
1365
+
1366
+ @property
1367
+ def repositories(self) -> list:
1368
+ return [ParcelsRepository, UsersRepository, TokensRepository]
1369
+ ```
1370
+
1371
+ Register the handler in `src/handlers/lambda_handler.py`:
1372
+
1373
+ ```python
1374
+ from aws_python_helper.lambda_standalone.handler import lambda_handler
1375
+
1376
+ index_sync_handler = lambda_handler('model-index-sync')
1377
+
1378
+ __all__ = ["index_sync_handler"]
1379
+ ```
1380
+
1381
+ > Declare repositories **explicitly** (don't auto-discover) so the synced set is intentional. List your own collections; **external** repositories (`is_external = True`) belong to another cluster/service and are typically excluded.
1382
+
1383
+ ### How it routes each repository
1384
+
1385
+ For every repository in the list, the Lambda decides where to create its indexes:
1386
+
1387
+ | Repository kind | `database_key` | Target databases |
1388
+ |-----------------|----------------|------------------|
1389
+ | **Core / fixed** | `"core"` (or any string) | that single database |
1390
+ | **State-scoped** | `None` | **every active state**, read from `core.states` where `is_active = true` (e.g. `connecticut`, `new_jersey`, …) |
1391
+
1392
+ A state-scoped collection such as `parcels` therefore gets its indexes created in `connecticut`, `new_jersey`, and any other active state — automatically. Creation is idempotent, so re-running it is safe.
1393
+
1394
+ This Lambda sets `requires_state = False`: it is not tied to a single state and does not require a `session` in the event.
1395
+
1396
+ ### Response format
1397
+
1398
+ The top-level `success` is **always `true`** (a failing collection no longer aborts the run). Inspect `data.summary` for the OK/failed counts and `data.results[].success` for each unit; failed units include an `error` field.
1399
+
1400
+ ```json
1401
+ {
1402
+ "success": true,
1403
+ "data": {
1404
+ "summary": { "total": 4, "succeeded": 3, "failed": 1 },
1405
+ "results": [
1406
+ {"repository": "ParcelsRepository", "database": "connecticut", "collection": "parcels", "indexes": ["parcel_id_1", "..."], "orphan_indexes_dropped": ["old_field_1"], "success": true},
1407
+ {"repository": "ParcelsRepository", "database": "new_jersey", "collection": "parcels", "indexes": ["parcel_id_1", "..."], "orphan_indexes_dropped": [], "success": true},
1408
+ {"repository": "UsersRepository", "database": "core", "collection": "users", "indexes": ["email_1"], "orphan_indexes_dropped": [], "success": true},
1409
+ {"repository": "TokensRepository", "database": "core", "collection": "tokens", "indexes": [], "orphan_indexes_dropped": [], "success": false, "error": "..."}
1410
+ ]
1411
+ }
1412
+ }
1413
+ ```
1414
+
1415
+ ### Properties to override / configure
1416
+
1417
+ | Property | Default | Purpose |
1418
+ |----------|---------|---------|
1419
+ | `repositories` | `[]` | **Required.** List of `Repository` subclasses (the classes, not instances) to sync. Empty raises a validation error |
1420
+ | `states_database` | `"core"` | Database holding the states collection |
1421
+ | `states_collection` | `"states"` | Collection listing the states (filtered by `is_active = true`, field `name`) |
1422
+ | `max_concurrency` | `10` | Max `(collection, database)` units synced in parallel. `0` (or negative) = unlimited |
1423
+ | `requires_state` | `False` | Inherited override — the Lambda runs across all states, no session required |
1424
+
1425
+ ### Running it once per deploy (Terraform)
1426
+
1427
+ Since deploys are applied manually with Terraform, attach an `aws_lambda_invocation` that runs **on every `apply`**, right after the function code is updated. `timestamp()` changes on every plan, so it always re-runs (creation is idempotent and also picks up any newly-activated state).
1428
+
1429
+ > **Detecting failures in the deploy.** The top-level `success` is now **always `true`** — a single failed collection no longer aborts the run, so checking `success` alone won't catch partial failures. If you want the `apply` to fail when any collection failed, point the `postcondition` at `data.summary.failed` instead (shown below). If you'd rather the deploy never fail on index errors and only review them afterward (in the response/logs), simply omit the `lifecycle` block.
1430
+
1431
+ ```hcl
1432
+ module "lambda" {
1433
+ source = "../../../../../modules/lambda"
1434
+ function_name = "${...service_name}${var.lambda_name}-${...env}"
1435
+ source_handler = "handlers.lambda_handler.index_sync_handler"
1436
+ timeout = 300
1437
+ memory_size = 512
1438
+ # ... mongo + vpc config (same as any other Lambda)
1439
+ }
1440
+
1441
+ resource "aws_lambda_invocation" "sync_on_deploy" {
1442
+ function_name = module.lambda.lambda_function_name
1443
+ input = jsonencode({ _invoked_at = timestamp() }) # always changes → always re-runs
1444
+
1445
+ depends_on = [module.lambda] # run after the code is updated
1446
+
1447
+ # Optional: fail the apply if any collection failed to sync.
1448
+ # Omit this whole lifecycle block if you prefer the deploy to never fail on
1449
+ # index errors (review data.results[].success / logs instead).
1450
+ lifecycle {
1451
+ postcondition {
1452
+ condition = try(jsondecode(self.result).data.summary.failed, 1) == 0
1453
+ error_message = "Index sync had failures during apply: ${self.result}"
1454
+ }
1455
+ }
1456
+ }
1457
+ ```
1458
+
1459
+ You can also invoke it manually at any time (e.g. after activating a new state without a code deploy):
1460
+
1461
+ ```bash
1462
+ aws lambda invoke --function-name MyServiceModelIndexSync-dev out.json
1463
+ ```
1464
+
1465
+ ---
1466
+
1316
1467
  ## 🏗️ Architecture Overview
1317
1468
 
1318
1469
  Typical flow for event-driven architectures using this framework:
@@ -2,7 +2,7 @@
2
2
  AWS Python Framework
3
3
  """
4
4
 
5
- __version__ = "0.1.0"
5
+ __version__ = "1.0.2"
6
6
 
7
7
  # All classes
8
8
  from .api.base import API
@@ -34,6 +34,9 @@ from .context.state_validator import StateValidator, InvalidStateError
34
34
  # Model Query
35
35
  from .model_query import ModelQueryLambda
36
36
 
37
+ # Model Index Sync
38
+ from .model_index_sync import ModelIndexSyncLambda
39
+
37
40
  # Invoker
38
41
  from .invoker import (
39
42
  LambdaInvoker,
@@ -69,6 +72,7 @@ __all__ = [
69
72
  'StateValidator',
70
73
  'InvalidStateError',
71
74
  'ModelQueryLambda',
75
+ 'ModelIndexSyncLambda',
72
76
  'LambdaInvoker',
73
77
  'ApiClient',
74
78
  'LambdaInvocationError',
@@ -190,9 +190,10 @@ class Fetcher:
190
190
  controller_class = None
191
191
  for item_name in dir(module):
192
192
  item = getattr(module, item_name)
193
- if (isinstance(item, type) and
194
- hasattr(item, 'process') and
195
- item.__name__ not in ['API', 'ABC']):
193
+ if (isinstance(item, type) and
194
+ hasattr(item, 'process') and
195
+ item.__name__ not in ['API', 'ABC'] and
196
+ item.__module__ == module.__name__):
196
197
  controller_class = item
197
198
  break
198
199
 
@@ -98,9 +98,10 @@ class FargateTaskFetcher:
98
98
  task_class = None
99
99
  for item_name in dir(module):
100
100
  item = getattr(module, item_name)
101
- if (isinstance(item, type) and
102
- hasattr(item, 'execute') and
103
- item.__name__ not in ['FargateTask', 'ABC']):
101
+ if (isinstance(item, type) and
102
+ hasattr(item, 'execute') and
103
+ item.__name__ not in ['FargateTask', 'ABC'] and
104
+ item.__module__ == module.__name__):
104
105
  task_class = item
105
106
  break
106
107
 
@@ -47,6 +47,21 @@ class Lambda(ABC):
47
47
  """
48
48
  return get_session()
49
49
 
50
+ @property
51
+ def requires_state(self) -> bool:
52
+ """
53
+ Whether this Lambda requires a valid 'state' in the session.
54
+
55
+ Default True: run() enforces that the event carries session.state and
56
+ validates it against core.states before processing.
57
+
58
+ Override to return False for Lambdas that operate across all states and
59
+ must not be tied to a single one (e.g. index synchronization). When
60
+ False, a session is still set if provided, but state is not required
61
+ nor validated.
62
+ """
63
+ return True
64
+
50
65
  @property
51
66
  def schema(self):
52
67
  """
@@ -188,11 +203,15 @@ class Lambda(ABC):
188
203
  try:
189
204
  # Step 0: Setup session context
190
205
  session_data = self.event.get('session')
191
- if not session_data or not session_data.get('state'):
192
- raise ValueError("'session' with 'state' is required in the event")
193
- session = Session.from_dict(session_data)
194
- await StateValidator.validate(session.state)
195
- set_session(session)
206
+ if self.requires_state:
207
+ if not session_data or not session_data.get('state'):
208
+ raise ValueError("'session' with 'state' is required in the event")
209
+ session = Session.from_dict(session_data)
210
+ await StateValidator.validate(session.state)
211
+ set_session(session)
212
+ elif session_data:
213
+ # state not required, but honor a session if one was provided
214
+ set_session(Session.from_dict(session_data))
196
215
 
197
216
  # Step 1: Schema validation (automatic, if schema property is defined)
198
217
  if self.schema is not None:
@@ -100,9 +100,10 @@ class LambdaFetcher:
100
100
  lambda_class = None
101
101
  for item_name in dir(module):
102
102
  item = getattr(module, item_name)
103
- if (isinstance(item, type) and
104
- issubclass(item, Lambda) and
105
- item.__name__ not in ['Lambda', 'ABC']):
103
+ if (isinstance(item, type) and
104
+ issubclass(item, Lambda) and
105
+ item.__name__ not in ['Lambda', 'ABC'] and
106
+ item.__module__ == module.__name__):
106
107
  lambda_class = item
107
108
  break
108
109
 
@@ -0,0 +1,3 @@
1
+ from .base import ModelIndexSyncLambda
2
+
3
+ __all__ = ["ModelIndexSyncLambda"]