System architecture

AWS resources

Everything is declared in template.yaml and deployed as one stack per environment.

Logical id Physical name Role

ActionsToolsApi

actionstools-<env> (REST API)

API Gateway REST API. Two ANY routes (/ and /{proxy+}) proxy everything to ApiFunction. CORS is answered by API Gateway itself, including Access-Control-Max-Age: 10800. Every route except the CORS OPTIONS methods requires the PayabilityAPIGatewayAuthorizer Lambda request authorizer (pyb-api-gateway-authorizer:live in the same account, identity source Host, cached 3600 s).

ApiFunction

actionstools-<env>-api

Handler lambda_handlers.api_handler. Serves HTTP.

WorkerFunction

actionstools-<env>-worker

Handler lambda_handlers.worker_handler. Async tasks, the tasks stream, the cleanup schedule.

WorkerFunction → TasksStream

event source mapping

Reads the em_actions_tasks stream (LATEST, batch 50, 3 retries, bisect on error).

WorkerFunction → RemoveCountersSchedule

EventBridge rule

rate(4 days) with input {"action": "remove_counters"}. Production only.

ApiFunction → KeepWarm

EventBridge rule

rate(4 minutes) with input {"action": "keep_warm"}, which api_handler answers without touching Flask. Keeps one execution environment warm, like Zappa’s keep_warm. Production only.

Both functions share the Globals block: python3.12, 512 MB, 120 s timeout, the same environment variables, and the VPC subnets and security group for that environment (needed to reach the internal API; stage uses the dev VPC shared with Messaging and Documents). Each function gets its own IAM role generated from SAM policy templates: CRUD on the eight em_actions_* tables, read on the reports cache table (API only), GetSecretValue on its own secret and on common/crm, and lambda:InvokeFunction on the Worker.

The DynamoDB tables are not part of the stack. They predate it, hold production data, and are referenced by name. Deleting the stack never deletes data.

Why two functions

The split mirrors what Zappa ran as prod and prod2, without the second deployment:

  • The API must answer fast and scale with traffic.

  • Background work (Slack, emails, event log writes, counter recalculation) must not hold the HTTP response, and the stream consumer must not compete with API concurrency.

Both functions are built from the same artifact and updated by the same deploy. For the few seconds while CloudFormation updates them one after the other, a task queued by the old API code can run on the new Worker code (or vice versa), so keep task signatures backward compatible across one release.

Code layers

lambda_handlers.py           Lambda entry points (api_handler, worker_handler)
run.py                       app = create_app("config"); setup_sentry()
actionstools_app/__init__.py Flask app, CORS, flask-dynamo, table definitions, create_app()
actionstools_app/api.py      flask-restx Api, ApiResource base classes, shared header parsers
actionstools_app/<resource>/ views.py (HTTP) → services.py (DynamoDB, rules) → models.py (schemas)
actionstools_app/tools/      async_task, error handling, permissions helpers, Sentry setup
config.py                    every setting, from os.environ
secrets_loader.py            Secrets Manager → os.environ (imported first)

<resource> is one of notes, tasks, comments, files, settings, notifications, activity, reports; ddb_counts holds the counter machinery. Services extend the CRUD classes from pyb_flask_utils.rest.services, which implement soft delete, timestamps, pagination and owner permission checks.

Rules that hold it together

  • Import order matters. secrets_loader must run before anything imports pyb_flask_utils or config, because both read os.environ at import time. It is the first import in config.py and in actionstools_app/init.py. See Configuration & secrets.

  • Task arguments are JSON. Anything passed to an @async_task function goes through simplejson into a Lambda payload (max 256 KB). Decimals from DynamoDB are converted first (see parse_decimal_to_int in tasks/services.py).

  • Only actionstools_app. functions decorated with @async_task can be run by the Worker.* worker_handler rejects anything else.

  • BASE_PREFIX is /actionstools in production and empty elsewhere, matching the custom domain path. See request-lifecycle.adoc#paths.