Skip to content

Commit c7c3b75

Browse files
authored
PostgreSQL connection caching and reuse (#131)
1 parent 6dbd1a1 commit c7c3b75

32 files changed

Lines changed: 1539 additions & 806 deletions

.github/copilot-instructions.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Structure
1616
Python style
1717
- Python 3.13
1818
- Type hints for public functions and classes
19+
- Use type aliases for complex types
1920
- Use built-in generics for type hints
2021
- Use `logging.getLogger(__name__)`, not print
2122
- Lazy % formatting in logging: `logger.info("msg %s", var)`
@@ -31,6 +32,7 @@ Python style
3132
Patterns
3233
- `__init__` methods must not raise exceptions; defer validation and connection to first use (lazy init)
3334
- Writers: inherit from `Writer(ABC)`, implement `write(topic, message) -> (bool, str|None)` and `check_health() -> (bool, str)`
35+
- PostgreSQL: `WriterPostgres` and `ReaderPostgres` cache a single connection per instance
3436
- Route dispatch via `ROUTE_MAP` dict mapping routes to handler functions in `event_gate_lambda.py` and `event_stats_lambda.py`
3537
- Separate business logic from environment access (env vars, file I/O, network calls)
3638
- No duplicate validation; centralize parsing in one layer where practical
@@ -50,3 +52,6 @@ Testing
5052
Quality gates (run after changes, fix only if below threshold)
5153
- Run all quality gates at once: `make qa`
5254
- Once a quality gate passes, do not re-run it in different scenarios
55+
56+
Git workflow
57+
- Do NOT create git commits; committing is the developer's responsibility

.github/dependabot.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ updates:
1414
commit-message:
1515
prefix: "chore"
1616
include: "scope"
17+
groups:
18+
github-actions:
19+
patterns:
20+
- "*"
1721

1822
- package-ecosystem: "pip"
1923
directory: "/"
@@ -31,3 +35,7 @@ updates:
3135
include: "scope"
3236
allow:
3337
- dependency-type: "direct"
38+
groups:
39+
python-dependencies:
40+
patterns:
41+
- "*"

.pylintrc

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ prefer-stubs=no
9393

9494
# Minimum Python version to use for version dependent checks. Will default to
9595
# the version used to run pylint.
96-
py-version=3.11
96+
py-version=3.13
9797

9898
# Discover python modules and packages in the file system subtree.
9999
recursive=no
@@ -440,7 +440,8 @@ disable=raw-checker-failed,
440440
deprecated-pragma,
441441
use-symbolic-message-instead,
442442
use-implicit-booleaness-not-comparison-to-string,
443-
use-implicit-booleaness-not-comparison-to-zero
443+
use-implicit-booleaness-not-comparison-to-zero,
444+
useless-return
444445

445446
# Enable the message, report, category or checker with the given id(s). You can
446447
# either give multiple identifier separated by comma (,) or put this option

api.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ paths:
5656
uptime_seconds:
5757
type: integer
5858
example: 12345
59+
dependencies:
60+
type: object
61+
additionalProperties:
62+
type: string
63+
example:
64+
kafka: ok
65+
eventbridge: not configured
66+
postgres: ok
5967
'503':
6068
description: Service is degraded
6169
content:
@@ -74,6 +82,14 @@ paths:
7482
eventbridge: client not initialized
7583
kafka: producer not initialized
7684
postgres: host not configured
85+
dependencies:
86+
type: object
87+
additionalProperties:
88+
type: string
89+
example:
90+
kafka: ok
91+
eventbridge: client not initialized
92+
postgres: host not configured
7793

7894
/topics:
7995
get:

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ jsonschema==4.26.0
1111
PyJWT==2.12.1
1212
requests==2.33.1
1313
boto3==1.43.2
14+
aiosql==15.0
15+
botocore==1.43.2
1416
confluent-kafka==2.14.0
1517
moto[s3,secretsmanager,events]==5.2.0
1618
testcontainers==4.14.2

src/event_gate_lambda.py

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616

1717
"""AWS Lambda entry point for the EventGate service."""
1818

19-
import logging
2019
import os
2120
import sys
2221
from typing import Any
@@ -31,20 +30,14 @@
3130
from src.utils.conf_path import CONF_DIR, INVALID_CONF_ENV
3231
from src.utils.config_loader import load_config
3332
from src.utils.constants import SSL_CA_BUNDLE_KEY
33+
from src.utils.logging_levels import init_root_logger
3434
from src.utils.utils import dispatch_request
3535
from src.writers.writer_eventbridge import WriterEventBridge
3636
from src.writers.writer_kafka import WriterKafka
3737
from src.writers.writer_postgres import WriterPostgres
3838

39-
# Initialize logger
40-
root_logger = logging.getLogger()
41-
if not root_logger.handlers:
42-
root_logger.addHandler(logging.StreamHandler())
43-
44-
log_level = os.environ.get("LOG_LEVEL", "INFO")
45-
root_logger.setLevel(log_level)
46-
logger = logging.getLogger(__name__)
47-
logger.debug("Initialized logger with level %s.", log_level)
39+
logger = init_root_logger(__name__)
40+
logger.debug("Initialized logger with level %s.", os.environ.get("LOG_LEVEL", "INFO"))
4841

4942
# Load main configuration
5043
logger.debug("Using CONF_DIR=%s.", CONF_DIR)

src/event_stats_lambda.py

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616

1717
"""AWS Lambda entry point for the EventStats service."""
1818

19-
import logging
2019
import os
2120
from typing import Any
2221

@@ -25,17 +24,11 @@
2524
from src.readers.reader_postgres import ReaderPostgres
2625
from src.utils.conf_path import CONF_DIR, INVALID_CONF_ENV
2726
from src.utils.config_loader import load_topic_names
27+
from src.utils.logging_levels import init_root_logger
2828
from src.utils.utils import dispatch_request
2929

30-
# Initialize logger
31-
root_logger = logging.getLogger()
32-
if not root_logger.handlers:
33-
root_logger.addHandler(logging.StreamHandler())
34-
35-
log_level = os.environ.get("LOG_LEVEL", "INFO")
36-
root_logger.setLevel(log_level)
37-
logger = logging.getLogger(__name__)
38-
logger.debug("Initialized EventStats logger with level %s.", log_level)
30+
logger = init_root_logger(__name__)
31+
logger.debug("Initialized EventStats logger with level %s.", os.environ.get("LOG_LEVEL", "INFO"))
3932

4033
# Load main configuration
4134
logger.debug("Using CONF_DIR=%s.", CONF_DIR)

src/handlers/handler_health.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,20 @@
2222
from collections.abc import Mapping
2323
from typing import Any, Protocol
2424

25+
from src.writers.writer import HealthCheckError
26+
2527
logger = logging.getLogger(__name__)
2628

2729

2830
class HealthCheckable(Protocol):
2931
"""Protocol for dependencies that support health checks."""
3032

31-
def check_health(self) -> tuple[bool, str]:
33+
def check_health(self) -> str | None:
3234
"""Check dependency health.
3335
Returns:
34-
Tuple of (is_healthy, message).
36+
`None` when healthy, a descriptive string when intentionally disabled.
37+
Raises:
38+
HealthCheckError: If the dependency is unhealthy.
3539
"""
3640

3741

@@ -51,11 +55,15 @@ def get_health(self) -> dict[str, Any]:
5155
logger.debug("Handling GET Health.")
5256

5357
failures: dict[str, str] = {}
58+
statuses: dict[str, str] = {}
5459

5560
for name, dependency in self.dependencies.items():
56-
healthy, msg = dependency.check_health()
57-
if not healthy:
58-
failures[name] = msg
61+
try:
62+
result = dependency.check_health()
63+
statuses[name] = result if result else "ok"
64+
except HealthCheckError as exc:
65+
failures[name] = str(exc)
66+
statuses[name] = str(exc)
5967

6068
uptime_seconds = int((datetime.now(timezone.utc) - self.start_time).total_seconds())
6169

@@ -64,12 +72,12 @@ def get_health(self) -> dict[str, Any]:
6472
return {
6573
"statusCode": 200,
6674
"headers": {"Content-Type": "application/json"},
67-
"body": json.dumps({"status": "ok", "uptime_seconds": uptime_seconds}),
75+
"body": json.dumps({"status": "ok", "uptime_seconds": uptime_seconds, "dependencies": statuses}),
6876
}
6977

7078
logger.debug("Health check degraded: %s.", failures)
7179
return {
7280
"statusCode": 503,
7381
"headers": {"Content-Type": "application/json"},
74-
"body": json.dumps({"status": "degraded", "failures": failures}),
82+
"body": json.dumps({"status": "degraded", "failures": failures, "dependencies": statuses}),
7583
}

src/handlers/handler_stats.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from typing import Any
2222

2323
from src.readers.reader_postgres import ReaderPostgres
24-
from src.utils.constants import POSTGRES_DEFAULT_LIMIT, SUPPORTED_TOPICS
24+
from src.utils.constants import POSTGRES_DEFAULT_LIMIT, SUPPORTED_STATS_TOPICS
2525
from src.utils.utils import build_error_response
2626

2727
logger = logging.getLogger(__name__)
@@ -54,9 +54,9 @@ def handle_request(self, event: dict[str, Any]) -> dict[str, Any]:
5454
if topic_name not in self.topics:
5555
return build_error_response(404, "topic", f"Topic '{topic_name}' not found.")
5656

57-
if topic_name not in SUPPORTED_TOPICS:
57+
if topic_name not in SUPPORTED_STATS_TOPICS:
5858
return build_error_response(
59-
400, "validation", f"Stats are only supported for topics '{', '.join(SUPPORTED_TOPICS)}'."
59+
400, "validation", f"Stats are only supported for topics '{', '.join(SUPPORTED_STATS_TOPICS)}'."
6060
)
6161

6262
# Parse request body
@@ -90,7 +90,7 @@ def handle_request(self, event: dict[str, Any]) -> dict[str, Any]:
9090
cursor=cursor,
9191
limit=limit,
9292
)
93-
except RuntimeError as exc:
93+
except RuntimeError:
9494
logger.exception("Stats query failed for topic %s.", topic_name)
9595
return build_error_response(500, "database", "Stats query failed.")
9696

src/handlers/handler_topic.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@
2929
from src.handlers.handler_token import HandlerToken
3030
from src.utils.conf_path import CONF_DIR
3131
from src.utils.config_loader import TopicAccessMap, load_access_config
32+
from src.utils.constants import TOPIC_DLCHANGE, TOPIC_RUNS, TOPIC_TEST
3233
from src.utils.utils import build_error_response
33-
from src.writers.writer import Writer
34+
from src.writers.writer import WriteError, Writer
3435

3536
logger = logging.getLogger(__name__)
3637

@@ -69,11 +70,11 @@ def with_load_topic_schemas(self) -> "HandlerTopic":
6970
logger.debug("Loading topic schemas from %s.", topic_schemas_dir)
7071

7172
with open(os.path.join(topic_schemas_dir, "runs.json"), "r", encoding="utf-8") as file:
72-
self.topics["public.cps.za.runs"] = json.load(file)
73+
self.topics[TOPIC_RUNS] = json.load(file)
7374
with open(os.path.join(topic_schemas_dir, "dlchange.json"), "r", encoding="utf-8") as file:
74-
self.topics["public.cps.za.dlchange"] = json.load(file)
75+
self.topics[TOPIC_DLCHANGE] = json.load(file)
7576
with open(os.path.join(topic_schemas_dir, "test.json"), "r", encoding="utf-8") as file:
76-
self.topics["public.cps.za.test"] = json.load(file)
77+
self.topics[TOPIC_TEST] = json.load(file)
7778

7879
logger.debug("Loaded topic schemas successfully.")
7980
return self
@@ -170,9 +171,10 @@ def _post_topic_message(self, topic_name: str, topic_message: dict[str, Any], to
170171

171172
errors = []
172173
for writer_name, writer in self.writers.items():
173-
ok, err = writer.write(topic_name, topic_message)
174-
if not ok:
175-
errors.append({"type": writer_name, "message": err})
174+
try:
175+
writer.write(topic_name, topic_message)
176+
except WriteError as exc:
177+
errors.append({"type": writer_name, "message": str(exc)})
176178

177179
if errors:
178180
return {

0 commit comments

Comments
 (0)