Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
45941de6b1
|
|||
| 5075f510e9 | |||
| 99b7f73447 | |||
| 03983306b1 | |||
| d0b580b136 | |||
| 287395a015 | |||
|
38e5b532cf
|
|||
|
771154facd
|
|||
| fde6f17b58 | |||
| ec7cf6b924 | |||
| 783c8a42d9 | |||
|
ed75f5d002
|
|||
| 2ab2509a55 | |||
| 98129b86d0 | |||
| 00018c79ac | |||
| 7fd1c62596 | |||
| a2d76aca2f | |||
| f13fd4153a | |||
| e280a3e13a | |||
| b5918dcf45 | |||
| e180cbe2df | |||
| 83edca0624 |
@@ -0,0 +1,26 @@
|
||||
.git
|
||||
.github
|
||||
.forgejo
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
.venv/
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.log
|
||||
.git*
|
||||
tests/
|
||||
container-data/
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
@@ -0,0 +1,41 @@
|
||||
on: [push]
|
||||
|
||||
env:
|
||||
REGISTRY_URL: "git.cleverthis.com"
|
||||
REPOSITORY: "clevermicro/amq-adapter-python-demo"
|
||||
DOCKER_HOST: "tcp://dind:2375"
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: general
|
||||
services:
|
||||
dind:
|
||||
image: docker:dind
|
||||
cmd:
|
||||
- dockerd
|
||||
- -H
|
||||
- tcp://0.0.0.0:2375
|
||||
- --tls=false
|
||||
container:
|
||||
image: "ghcr.io/catthehacker/ubuntu:js-22.04"
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref_name }}
|
||||
|
||||
- name: docker login
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY_URL }}
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: docker build & push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
no-cache: true
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY_URL }}/${{ env.REPOSITORY }}:20250715-01
|
||||
+2
-1
@@ -56,7 +56,8 @@ amq_adapter.egg-info/
|
||||
build/
|
||||
|
||||
unused-code/
|
||||
|
||||
.vscode/
|
||||
amq_adapter.egg-info/
|
||||
build/
|
||||
coverage.xml
|
||||
.aider*
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
# Build stage
|
||||
FROM python:3.11-slim AS builder
|
||||
WORKDIR /app
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
# Install system dependencies for building
|
||||
RUN apt-get update && apt-get install -y gcc && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy project files for building
|
||||
COPY pyproject.toml .
|
||||
COPY . .
|
||||
|
||||
RUN pip install --no-cache-dir build && python -m build --wheel
|
||||
|
||||
# Runtime stage
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
RUN apt-get update && apt-get install -y libpq-dev && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install the built package from builder
|
||||
COPY --from=builder /app/dist/*.whl /tmp/
|
||||
RUN pip install --no-cache-dir /tmp/*.whl
|
||||
|
||||
# Install additional runtime dependencies for demo.py
|
||||
RUN pip install --no-cache-dir \
|
||||
uvicorn[standard]==0.29.0 \
|
||||
python-multipart==0.0.20 \
|
||||
httpx==0.27.0 \
|
||||
opentelemetry-api==1.34.1 \
|
||||
opentelemetry-sdk==1.34.1 \
|
||||
opentelemetry-exporter-otlp-proto-grpc==1.34.1 \
|
||||
opentelemetry-exporter-prometheus==0.55b1 \
|
||||
opentelemetry-instrumentation-fastapi==0.55b1 \
|
||||
opentelemetry-instrumentation-httpx==0.55b1 \
|
||||
protobuf==5.28.3
|
||||
|
||||
# Copy the application code if needed
|
||||
COPY ./amqp ./amqp
|
||||
COPY ./otdemo ./otdemo
|
||||
COPY ./demo.py .
|
||||
|
||||
# Create directory for uploaded files
|
||||
RUN mkdir -p /tmp/clevermicro_documents
|
||||
|
||||
EXPOSE 8000 8001
|
||||
|
||||
CMD ["uvicorn", "demo:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -116,3 +116,36 @@ AMQ_VERBOSE_LOGGING - if set to 1, the adapter will indicate in the log next to
|
||||
|
||||
As an open-source project we run entierly off donations. Buy one of our hardworking developers a beer by [donating at OpenCollective](https://opencollective.com/cleverthis). All donations go to our bounty fund and allow us to place bounties on important bugs and enhancements. You may also use Beerpay linked via the above badges.
|
||||
|
||||
## Running with Docker Compose
|
||||
|
||||
This project includes a Docker Compose configuration for easy local development and testing.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker and Docker Compose installed on your system
|
||||
|
||||
### Starting the services
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
This will start:
|
||||
- The demo application (accessible at http://localhost:8000)
|
||||
- Jaeger for distributed tracing (UI at http://localhost:16686)
|
||||
- Prometheus for metrics collection (UI at http://localhost:9090)
|
||||
- Alertmanager for alert handling (UI at http://localhost:9093)
|
||||
- RabbitMQ for messaging (Management UI at http://localhost:15672)
|
||||
|
||||
### Stopping the services
|
||||
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
To remove volumes as well:
|
||||
|
||||
```bash
|
||||
docker-compose down -v
|
||||
```
|
||||
|
||||
|
||||
@@ -4,15 +4,17 @@ import time
|
||||
from asyncio import AbstractEventLoop
|
||||
from threading import Thread
|
||||
|
||||
import psutil
|
||||
from aio_pika import Message
|
||||
from aio_pika.abc import AbstractRobustChannel
|
||||
|
||||
from amqp.adapter.logging_utils import logging_info, logging_warning
|
||||
from amqp.adapter.logging_utils import logging_info
|
||||
from amqp.config.amq_configuration import AMQConfiguration
|
||||
from amqp.model.model import ScalingRequestAlert
|
||||
from amqp.router.utils import await_future, await_result
|
||||
|
||||
CPU_IDLE, CPU_ACTIVE, CPU_OVERLOAD = 0, 1, 2
|
||||
IDLE_THRESHOLD, OVERLOAD_THRESHOLD = 0.15, 0.90
|
||||
RESOURCE_UNSET, RESOURCE_IDLE, RESOURCE_ACTIVE, RESOURCE_OVERLOAD = -1, 0, 1, 2
|
||||
|
||||
|
||||
class ScaleRequestV1:
|
||||
|
||||
@@ -20,16 +22,12 @@ class ScaleRequestV1:
|
||||
self,
|
||||
serviceId: str,
|
||||
taskId: str,
|
||||
maxAvailability: int,
|
||||
currentAvailability: int,
|
||||
requestType: ScalingRequestAlert,
|
||||
):
|
||||
self.version = 1 # Version of the request, currently 1
|
||||
self.serviceId = serviceId
|
||||
self.taskId = taskId
|
||||
self.maxAvailability = maxAvailability
|
||||
self.currentAvailability = currentAvailability
|
||||
self.requestType = requestType
|
||||
self.thread = None
|
||||
|
||||
def to_json(self) -> str:
|
||||
@@ -39,9 +37,7 @@ class ScaleRequestV1:
|
||||
"version": self.version,
|
||||
"serviceId": self.serviceId,
|
||||
"taskId": self.taskId,
|
||||
"maxAvailability": self.maxAvailability,
|
||||
"currentAvailability": self.currentAvailability,
|
||||
"requestType": self.requestType.value, # Using .value for Enum serialization
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
@@ -91,11 +87,10 @@ class RunningAverage:
|
||||
class BackpressureHandler:
|
||||
# Track the number of messages currently being processed
|
||||
current_parallel_executions = 0
|
||||
# helps detect IDLE condition
|
||||
last_data_message_time = 0
|
||||
# helps prevent flooding the system with backpressure events
|
||||
last_backpressure_event_time = 0
|
||||
last_backpressure_event = ScalingRequestAlert.UPDATE
|
||||
# report when remain availability goes down
|
||||
last_usage_report_time = 0
|
||||
# report when remain availability goes up
|
||||
last_recover_report_time = 0
|
||||
# _callback_list is used in unit tests to record the invoked callbacks
|
||||
_callback_list = None
|
||||
|
||||
@@ -113,7 +108,6 @@ class BackpressureHandler:
|
||||
self.swarm_service_id = self.config.amq_adapter.swarm_service_id
|
||||
self.swarm_task_id = self.config.amq_adapter.swarm_task_id
|
||||
self.parallel_workers = self.config.backpressure.threshold_threads
|
||||
self.average_cpu_usage = None
|
||||
self.do_loop = -1
|
||||
|
||||
def _do_loop(self) -> bool:
|
||||
@@ -127,177 +121,66 @@ class BackpressureHandler:
|
||||
self.do_loop -= 1
|
||||
return _val
|
||||
|
||||
def increase_parallel_executions(self):
|
||||
async def increase_parallel_executions(self):
|
||||
"""Increase the number of parallel executions"""
|
||||
logging_info(
|
||||
"Backpressure: Increase parallel executions, current=%s",
|
||||
self.current_parallel_executions,
|
||||
)
|
||||
self.current_parallel_executions += 1
|
||||
|
||||
def decrease_parallel_executions(self):
|
||||
"""Decrease the number of parallel executions"""
|
||||
logging_info(
|
||||
"Backpressure: Decrease parallel executions, current=%s",
|
||||
"Backpressure: Increased parallel executions, new value: %s",
|
||||
self.current_parallel_executions,
|
||||
)
|
||||
# increase parallel -> reduce in remaining availability -> usage
|
||||
# report usage at least every 1s
|
||||
# TODO: add config for the interval
|
||||
if self.last_usage_report_time + 1 < time.time():
|
||||
await self.send_backpressure_report()
|
||||
self.last_usage_report_time = time.time()
|
||||
|
||||
async def decrease_parallel_executions(self):
|
||||
"""Decrease the number of parallel executions"""
|
||||
if self.current_parallel_executions > 0:
|
||||
self.current_parallel_executions -= 1
|
||||
|
||||
def update_parallel_executions(self, count: int):
|
||||
"""Update the number of parallel executions"""
|
||||
self.current_parallel_executions = count
|
||||
|
||||
def update_last_data_message_time(self):
|
||||
logging_info("Backpressure: Update last data message time")
|
||||
"""Update the last data message time"""
|
||||
self.last_data_message_time = time.time()
|
||||
logging_info(
|
||||
"Backpressure: Decreased parallel executions, new value=%s",
|
||||
self.current_parallel_executions,
|
||||
)
|
||||
# decrease parallel -> add to remaining availability -> recover
|
||||
# report recover at every 3s
|
||||
# TODO: add config for the interval
|
||||
if self.last_recover_report_time + 3 < time.time():
|
||||
await self.send_backpressure_report()
|
||||
self.last_recover_report_time = time.time()
|
||||
|
||||
def start_backpressure_monitor(self) -> Thread:
|
||||
# Start the Backpressure monitor loop
|
||||
self.thread = Thread(target=self.backpressure_monitor_loop)
|
||||
self.thread = Thread(target=self.regular_report_loop_wrapper)
|
||||
self.thread.daemon = True # This makes it a daemon thread
|
||||
self.thread.start()
|
||||
return self.thread
|
||||
|
||||
async def check_overload_condition(self):
|
||||
"""Check if the current parallel executions exceed the limit"""
|
||||
self.update_last_data_message_time()
|
||||
logging_info(
|
||||
"Backpressure: Check overload condition, current=%s, max=%s",
|
||||
self.current_parallel_executions,
|
||||
self.parallel_workers,
|
||||
)
|
||||
if self.current_parallel_executions >= self.parallel_workers - 1:
|
||||
# Check if the last backpressure event was not an overload
|
||||
if self.last_backpressure_event != ScalingRequestAlert.OVERLOAD:
|
||||
# Trigger the overload event
|
||||
await self.handle_backpressure_overload_event()
|
||||
self.last_backpressure_event_time = time.time()
|
||||
self.last_backpressure_event = ScalingRequestAlert.OVERLOAD
|
||||
|
||||
async def _backpressure_monitor(self):
|
||||
"""Monitor the backpressure conditions and trigger events accordingly"""
|
||||
_time_window_millis = self.config.backpressure.time_window
|
||||
_idle_duration_millis = self.config.backpressure.idle_duration
|
||||
_overload_duration_millis = self.config.backpressure.cpu_overload_duration
|
||||
_monitor_interval = 0.5 # Monitor every 500ms
|
||||
CPU_IDLE, CPU_ACTIVE, CPU_OVERLOAD = 0, 1, 2
|
||||
IDLE_THRESHOLD, OVERLOAD_THRESHOLD = 15, 90
|
||||
if self.average_cpu_usage is None:
|
||||
self.average_cpu_usage = RunningAverage(
|
||||
int(max(_overload_duration_millis, _idle_duration_millis) // _monitor_interval)
|
||||
)
|
||||
_cpu_usage_state = CPU_ACTIVE
|
||||
_cpu_usage_changed = time.time()
|
||||
async def _regular_report_loop(self):
|
||||
"""Regularly report the availability in a fixed interval"""
|
||||
while self._do_loop():
|
||||
_current_cpu_usage = psutil.cpu_percent(interval=None)
|
||||
self.average_cpu_usage.add(_current_cpu_usage)
|
||||
_average_cpu_usage = self.average_cpu_usage.get_average()
|
||||
_now = time.time()
|
||||
# logging_info(
|
||||
# "Backpressure: current CPU[pct]=%s, avg=%s, state=%s, last dataMsg=%s, last event=%s, last eventTime=%s",
|
||||
# _current_cpu_usage,
|
||||
# _average_cpu_usage,
|
||||
# _cpu_usage_state,
|
||||
# self.last_data_message_time,
|
||||
# self.last_backpressure_event,
|
||||
# self.last_backpressure_event_time,
|
||||
# )
|
||||
if _average_cpu_usage < IDLE_THRESHOLD:
|
||||
if _cpu_usage_state != CPU_IDLE:
|
||||
_cpu_usage_changed = _now
|
||||
_cpu_usage_state = CPU_IDLE
|
||||
elif _average_cpu_usage > OVERLOAD_THRESHOLD:
|
||||
if _cpu_usage_state != CPU_OVERLOAD:
|
||||
_cpu_usage_changed = _now
|
||||
_cpu_usage_state = CPU_OVERLOAD
|
||||
else:
|
||||
if _cpu_usage_state != CPU_ACTIVE:
|
||||
_cpu_usage_changed = _now
|
||||
_cpu_usage_state = CPU_ACTIVE
|
||||
await self.send_backpressure_report()
|
||||
# report recover at every 5s
|
||||
# TODO: add config for the interval
|
||||
await asyncio.sleep(5)
|
||||
|
||||
# Check if the current time exceeds the last backpressure event time
|
||||
if (
|
||||
_cpu_usage_state == CPU_OVERLOAD
|
||||
and _now - _cpu_usage_changed > _overload_duration_millis
|
||||
and (
|
||||
_now - self.last_backpressure_event_time > _time_window_millis
|
||||
or self.last_backpressure_event != ScalingRequestAlert.OVERLOAD
|
||||
)
|
||||
):
|
||||
# Trigger the overload event
|
||||
await self.handle_backpressure_overload_event()
|
||||
self.last_backpressure_event_time = _now
|
||||
self.last_backpressure_event = ScalingRequestAlert.OVERLOAD
|
||||
elif (
|
||||
_cpu_usage_state == CPU_IDLE
|
||||
and _now - _cpu_usage_changed > _idle_duration_millis
|
||||
and _now - self.last_data_message_time > _idle_duration_millis
|
||||
and (
|
||||
_now - self.last_backpressure_event_time > _time_window_millis
|
||||
or self.last_backpressure_event != ScalingRequestAlert.IDLE
|
||||
)
|
||||
):
|
||||
# Trigger the idle event
|
||||
await self._handle_backpressure_idle_event()
|
||||
self.last_backpressure_event = ScalingRequestAlert.IDLE
|
||||
self.last_backpressure_event_time = _now
|
||||
else:
|
||||
# Trigger update event
|
||||
if _now - self.last_backpressure_event_time > _time_window_millis:
|
||||
# Trigger the update event
|
||||
_scaling_request: ScaleRequestV1 = ScaleRequestV1(
|
||||
self.swarm_service_id,
|
||||
self.swarm_task_id,
|
||||
100,
|
||||
_average_cpu_usage,
|
||||
ScalingRequestAlert.UPDATE,
|
||||
)
|
||||
# Address the message to any adapter capable of supporting BACKPRESSURE request
|
||||
await self.publish_backpressure_request(_scaling_request)
|
||||
self.last_backpressure_event_time = _now
|
||||
self.last_backpressure_event = ScalingRequestAlert.UPDATE
|
||||
await asyncio.sleep(_monitor_interval)
|
||||
|
||||
def backpressure_monitor_loop(self):
|
||||
def regular_report_loop_wrapper(self):
|
||||
_loop = asyncio.new_event_loop()
|
||||
_loop.run_until_complete(self._backpressure_monitor())
|
||||
_loop.run_until_complete(self._regular_report_loop())
|
||||
|
||||
async def handle_backpressure_overload_event(self):
|
||||
logging_warning("Backpressure: Capacity close to depleted!")
|
||||
# Send an Overload event to the Management service
|
||||
async def send_backpressure_report(self):
|
||||
scaling_request: ScaleRequestV1 = ScaleRequestV1(
|
||||
self.swarm_service_id,
|
||||
self.swarm_task_id,
|
||||
self.parallel_workers,
|
||||
self.parallel_workers - self.current_parallel_executions,
|
||||
ScalingRequestAlert.OVERLOAD,
|
||||
serviceId=self.swarm_service_id,
|
||||
taskId=self.swarm_task_id,
|
||||
currentAvailability=self.parallel_workers - self.current_parallel_executions,
|
||||
)
|
||||
# Address the message to any management service instance capable of supporting BACKPRESSURE request
|
||||
await self.publish_backpressure_request(scaling_request)
|
||||
# update / reset time-window so that the OVERLOAD is not sent too often
|
||||
self.last_data_message_time = time.time()
|
||||
|
||||
async def _handle_backpressure_idle_event(self):
|
||||
logging_warning("Backpressure: Service is idle.")
|
||||
# Send an Idle event to the Management service
|
||||
scaling_request: ScaleRequestV1 = ScaleRequestV1(
|
||||
self.swarm_service_id,
|
||||
self.swarm_task_id,
|
||||
self.parallel_workers,
|
||||
self.parallel_workers - self.current_parallel_executions,
|
||||
ScalingRequestAlert.IDLE,
|
||||
)
|
||||
# Address the message to any adapter capable of supporting BACKPRESSURE request
|
||||
await self.publish_backpressure_request(scaling_request)
|
||||
# update / reset time-window so that the IDLE is not sent too often
|
||||
self.last_data_message_time = time.time()
|
||||
|
||||
async def publish_backpressure_request(self, scaling_request: ScaleRequestV1):
|
||||
# Publish the backpressure request to the management service
|
||||
logging_info(
|
||||
f"Publishing backpressure for {scaling_request.serviceId} with request type {scaling_request.requestType}"
|
||||
f"Publishing backpressure for {scaling_request.serviceId} with availability {scaling_request.currentAvailability}"
|
||||
)
|
||||
if not self.exchange:
|
||||
|
||||
|
||||
@@ -491,6 +491,9 @@ class CleverThisServiceAdapter:
|
||||
_routes = self.routes.get(method, [])
|
||||
_path, _args = AMQDataParser.parse_get_url(message.path())
|
||||
for _route in _routes:
|
||||
# TODO: here the regex will match with path prefix,
|
||||
# but it might not be a stable feature that we could rely on.
|
||||
# Need to figure out something stable for the prefix path.
|
||||
_match = _route.path_regex.match(_path)
|
||||
if _match:
|
||||
_args.update(_match.groupdict())
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import os
|
||||
import logging
|
||||
import random
|
||||
import socket
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import consul
|
||||
from amqp.config.amq_configuration import AMQConfiguration
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default configuration values for Adapter
|
||||
DEFAULT_CONSUL_HOST = 'localhost'
|
||||
DEFAULT_CONSUL_PORT = 8500
|
||||
DEFAULT_CONSUL_COUNTER_KEY = 'adapter/ids/counter'
|
||||
DEFAULT_MAX_RETRIES = 5
|
||||
DEFAULT_RETRY_DELAY_SECONDS = 0.1
|
||||
|
||||
def get_config_values():
|
||||
"""
|
||||
Get configuration values from AMQConfiguration or environment variables.
|
||||
|
||||
Returns:
|
||||
tuple: (consul_host, consul_port, consul_counter_key, max_retries, retry_delay_seconds)
|
||||
"""
|
||||
try:
|
||||
config = AMQConfiguration("application.properties")
|
||||
consul_host = os.environ.get('CONSUL_HOST', DEFAULT_CONSUL_HOST)
|
||||
consul_port = int(os.environ.get('CONSUL_PORT', DEFAULT_CONSUL_PORT))
|
||||
consul_counter_key = os.environ.get('CONSUL_COUNTER_KEY', DEFAULT_CONSUL_COUNTER_KEY)
|
||||
max_retries = int(os.environ.get('CONSUL_MAX_RETRIES', DEFAULT_MAX_RETRIES))
|
||||
retry_delay_seconds = float(os.environ.get('CONSUL_RETRY_DELAY_SECONDS', DEFAULT_RETRY_DELAY_SECONDS))
|
||||
return consul_host, consul_port, consul_counter_key, max_retries, retry_delay_seconds
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load configuration: {str(e)}. Using default values.")
|
||||
return (
|
||||
os.environ.get('CONSUL_HOST', DEFAULT_CONSUL_HOST),
|
||||
int(os.environ.get('CONSUL_PORT', DEFAULT_CONSUL_PORT)),
|
||||
os.environ.get('CONSUL_COUNTER_KEY', DEFAULT_CONSUL_COUNTER_KEY),
|
||||
int(os.environ.get('CONSUL_MAX_RETRIES', DEFAULT_MAX_RETRIES)),
|
||||
float(os.environ.get('CONSUL_RETRY_DELAY_SECONDS', DEFAULT_RETRY_DELAY_SECONDS))
|
||||
)
|
||||
def generate_fallback_id() -> int:
|
||||
"""
|
||||
Generate a fallback ID when Consul is not available.
|
||||
Uses a combination of timestamp, hostname hash, and random number.
|
||||
|
||||
Returns:
|
||||
int: A reasonably unique integer ID
|
||||
"""
|
||||
timestamp = int(time.time() * 1000)
|
||||
hostname = socket.gethostname()
|
||||
hostname_hash = hash(hostname) % 10000
|
||||
random_part = random.randint(0, 9999)
|
||||
unique_id = (timestamp * 100000000) + (hostname_hash * 10000) + random_part
|
||||
logger.warning(f"Using fallback ID generation method: {unique_id}")
|
||||
return unique_id
|
||||
|
||||
def get_unique_instance_id() -> int:
|
||||
"""
|
||||
Get a globally unique ID from Consul.
|
||||
Uses Consul's atomic Compare-And-Set operations to safely increment a counter.
|
||||
Falls back to a local generation method if Consul is unavailable.
|
||||
|
||||
Returns:
|
||||
int: A globally unique integer ID
|
||||
"""
|
||||
consul_host, consul_port, consul_counter_key, max_retries, retry_delay_seconds = get_config_values()
|
||||
try:
|
||||
c = consul.Consul(host=consul_host, port=consul_port)
|
||||
index, data = c.kv.get(consul_counter_key)
|
||||
if data is None:
|
||||
logger.info(f"Initializing Consul counter at {consul_counter_key}")
|
||||
if c.kv.put(consul_counter_key, "1"):
|
||||
return 1
|
||||
else:
|
||||
logger.error("Failed to initialize Consul counter")
|
||||
return generate_fallback_id()
|
||||
current_value = int(data['Value'].decode('utf-8'))
|
||||
new_value = current_value + 1
|
||||
for attempt in range(max_retries):
|
||||
success = c.kv.put(
|
||||
consul_counter_key,
|
||||
str(new_value),
|
||||
cas=data['ModifyIndex']
|
||||
)
|
||||
if success:
|
||||
logger.debug(f"Successfully obtained unique ID: {new_value}")
|
||||
return new_value
|
||||
logger.debug(f"CAS update failed on attempt {attempt+1}, retrying...")
|
||||
time.sleep(retry_delay_seconds)
|
||||
index, data = c.kv.get(consul_counter_key)
|
||||
if data is None:
|
||||
logger.error("Counter disappeared during update")
|
||||
return generate_fallback_id()
|
||||
current_value = int(data['Value'].decode('utf-8'))
|
||||
new_value = current_value + 1
|
||||
logger.error(f"Failed to update counter after {max_retries} attempts")
|
||||
return generate_fallback_id()
|
||||
except Exception as e:
|
||||
logger.error(f"Error accessing Consul: {str(e)}")
|
||||
return generate_fallback_id()
|
||||
@@ -7,10 +7,11 @@ import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from io import BytesIO
|
||||
from typing import Dict, List
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import amqp.adapter.file_handler
|
||||
from amqp.adapter.file_handler import FileHandler, StreamingFileHandler
|
||||
from amqp.adapter.pydantic_serializer import python_type_to_json
|
||||
from amqp.adapter.serializer import (
|
||||
map_as_string,
|
||||
map_with_list_as_string,
|
||||
@@ -112,6 +113,32 @@ class DataMessageFactory:
|
||||
payload,
|
||||
)
|
||||
|
||||
def create_rpc_message(
|
||||
self,
|
||||
fq_method_name: str,
|
||||
swarm_id: str,
|
||||
args: Dict[str, Any] = None,
|
||||
) -> DataMessage:
|
||||
"""
|
||||
Create a RPC message with the given parameters.
|
||||
:param fq_method_name: fully qualified method name (e.g. 'com.example.ServiceClass.method_name')
|
||||
:param args: arguments whose serialized form will form the request body
|
||||
:return: DataMessage object
|
||||
"""
|
||||
package, class_name, method = fq_method_name.rsplit(".", 2)
|
||||
headers = {"Content-Type": ["application/octet-stream"], "Return-Address": [swarm_id]}
|
||||
trace_info = {}
|
||||
return DataMessage(
|
||||
DataMessageMagicByte.RPC_REQUEST.value,
|
||||
self.generate_snowflake_id(),
|
||||
package,
|
||||
class_name,
|
||||
method,
|
||||
headers,
|
||||
trace_info,
|
||||
python_type_to_json(args) if args else b"",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def amq_message_routing_key(message: DataMessage) -> str:
|
||||
"""
|
||||
|
||||
@@ -40,6 +40,11 @@ class AMQAdapter:
|
||||
self.PREFIX + "require-authenticated-user",
|
||||
fallback=False,
|
||||
)
|
||||
self.backpressure_enabled = config.getboolean(
|
||||
"CleverMicro-AMQ",
|
||||
self.PREFIX + "default-backpressure-enabled",
|
||||
fallback=False,
|
||||
)
|
||||
# these 2 shall be provided by Docker swarm, format is a random alphanumeric string
|
||||
self.swarm_service_id = os.getenv("SWARM_SERVICE_ID", default="clever-amqp-service")
|
||||
self.swarm_task_id = os.getenv("SWARM_TASK_ID", default="python-adapter")
|
||||
|
||||
@@ -14,6 +14,8 @@ cm.amq-adapter.generator-id=1
|
||||
cm.amq-adapter.route-mapping=
|
||||
# Indicate if AMQ Adapter should respond with HTTP 401 Unauthorized if the request does not contain valid JWT token
|
||||
cm.amq-adapter.require-authenticated-user=false
|
||||
# Indicate if AMQ Adapter should use inbuilt backpressure monitor (True) or rely only on the service backpressure updates (False)
|
||||
cm.amq-adapter.default-backpressure-enabled=false
|
||||
|
||||
# CleverMicro AMQ Adapter dispatcher, settings related to RabbitMQ connection and optional REST forwarding
|
||||
cm.dispatch.use-dlq=true
|
||||
@@ -27,7 +29,8 @@ cm.dispatch.download-dir=/tmp/downloaded_files
|
||||
#cm.dispatch.service-port=8080
|
||||
|
||||
# Backpressure monitor settings
|
||||
# Define maximum number of messages of being processed in parallel before triggering backpressure OVERLOAD alert
|
||||
# Define maximum number of resources that service can use before triggering backpressure OVERLOAD alert
|
||||
# 'resource' is a generic term that can mean CPU, memory, parallel requests, etc.
|
||||
cm.backpressure.threshold=5
|
||||
# Define maximum CPU usage (%) before triggering backpressure OVERLOAD alert
|
||||
cm.backpressure.threshold-cpu-overload=90
|
||||
@@ -39,4 +42,3 @@ cm.backpressure.time-window=10
|
||||
cm.backpressure.cpu-overload-duration=30
|
||||
# Define the time window length, in seconds, during which, if no request is made to the Service, the system will trigger IDLE alert
|
||||
cm.backpressure.cpu-idle-duration=30
|
||||
|
||||
|
||||
+3
-14
@@ -91,6 +91,9 @@ class CleverMicroMessage:
|
||||
def content_type(self) -> str:
|
||||
return self.headers().get("Content-Type", self.DEFAULT_CONTENT_TYPE)[0]
|
||||
|
||||
def return_address(self) -> str:
|
||||
return self.headers().get("Return-Address", "")[0]
|
||||
|
||||
|
||||
class DataMessage(CleverMicroMessage):
|
||||
"""
|
||||
@@ -288,21 +291,11 @@ class ServiceMessage:
|
||||
return self.message_type is not None
|
||||
|
||||
|
||||
class ScalingRequestAlert(Enum):
|
||||
OVERLOAD = "OVERLOAD"
|
||||
IDLE = "IDLE"
|
||||
UPDATE = "UPDATE"
|
||||
SCALE_UP = "SCALE_UP"
|
||||
SCALE_DOWN = "SCALE_DOWN"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScalingRequest:
|
||||
service_id: str
|
||||
task_id: str
|
||||
max_availability: int
|
||||
current_availability: int
|
||||
request_type: ScalingRequestAlert
|
||||
version: int = 1
|
||||
|
||||
def to_json(self) -> str:
|
||||
@@ -312,9 +305,7 @@ class ScalingRequest:
|
||||
"version": self.version,
|
||||
"serviceId": self.service_id,
|
||||
"taskId": self.task_id,
|
||||
"maxAvailability": self.max_availability,
|
||||
"currentAvailability": self.current_availability,
|
||||
"requestType": self.request_type.value, # Using .value for Enum serialization
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
@@ -326,9 +317,7 @@ class ScalingRequest:
|
||||
return cls(
|
||||
service_id=data["serviceId"],
|
||||
task_id=data["taskId"],
|
||||
max_availability=data["maxAvailability"],
|
||||
current_availability=data["currentAvailability"],
|
||||
request_type=ScalingRequestAlert(data["requestType"]), # Convert string to Enum
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ from amqp.router.router_base import (
|
||||
AMQ_RPC_EXCHANGE,
|
||||
DLQ_EXCHANGE,
|
||||
)
|
||||
from amqp.router.router_consumer import RouterConsumer
|
||||
from amqp.router.utils import sanitize_as_rabbitmq_name
|
||||
|
||||
|
||||
@@ -25,7 +24,7 @@ class RabbitMQClient:
|
||||
PREFETCH_COUNT = 1
|
||||
DLQ_ARGS = {"x-dead-letter-exchange": DLQ_EXCHANGE}
|
||||
|
||||
def __init__(self, configuration):
|
||||
def __init__(self, configuration, router):
|
||||
self.channel: aio_pika.RobustChannel | None = None
|
||||
self.connection: aio_pika.RobustConnection | None = None
|
||||
self.rpc_exchange: aio_pika.RobustExchange | None = None
|
||||
@@ -37,7 +36,7 @@ class RabbitMQClient:
|
||||
self.amq_configuration.dispatch.amq_host,
|
||||
)
|
||||
logging.info("*******************************************")
|
||||
self.router = RouterConsumer(configuration)
|
||||
self.router = router
|
||||
# this.meterRegistry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT);
|
||||
|
||||
self.reply_to_exchange: AbstractRobustExchange | None = (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import re
|
||||
from typing import Set
|
||||
from typing import Optional, Set
|
||||
|
||||
from amqp.adapter.logging_utils import logging_info
|
||||
from amqp.model.model import AMQRoute
|
||||
@@ -55,6 +55,13 @@ class RouteDatabase:
|
||||
return route
|
||||
return None
|
||||
|
||||
def find_route_by_service(self, service_name: str) -> Optional[AMQRoute]:
|
||||
logging_info(f"findRoute service_name:{service_name} in:{self.wrap_routes()}")
|
||||
for route in self.routes:
|
||||
if route.component_name == service_name:
|
||||
return route
|
||||
return None
|
||||
|
||||
def add_route(self, route: AMQRoute) -> bool:
|
||||
if route not in self.routes:
|
||||
self.routes.add(route)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Callable, List, Set
|
||||
from typing import Callable, List, Optional, Set
|
||||
|
||||
from amqp.adapter.amq_route_factory import NULL_ROUTE, AMQRouteFactory
|
||||
from amqp.adapter.logging_utils import logging_debug, logging_error, logging_info
|
||||
@@ -49,6 +49,9 @@ class RouterBase:
|
||||
def find_route_by_message(self, message: DataMessage) -> AMQRoute | None:
|
||||
return self.route_database.find_route(message.domain(), message.path())
|
||||
|
||||
def find_route_by_service(self, service_name: str) -> Optional[AMQRoute]:
|
||||
return self.route_database.find_route_by_service(service_name)
|
||||
|
||||
def add_routes(self, message: str) -> None:
|
||||
try:
|
||||
amq_route_list = self.unwrap_route_list(message)
|
||||
|
||||
@@ -124,7 +124,7 @@ class DataMessageHandler:
|
||||
:return: DataResponse
|
||||
"""
|
||||
# Increment the counter for parallel executions
|
||||
self.backpressure_handler.increase_parallel_executions()
|
||||
await self.backpressure_handler.increase_parallel_executions()
|
||||
logging_info(
|
||||
f"PARALLEL: Current Capacity [{self.backpressure_handler.current_parallel_executions}] of [{self.backpressure_handler.parallel_workers}]"
|
||||
)
|
||||
@@ -132,7 +132,6 @@ class DataMessageHandler:
|
||||
self.reply_to = message.reply_to
|
||||
|
||||
try:
|
||||
await self.backpressure_handler.check_overload_condition()
|
||||
_a_message = AMQMessage(amq_message, self.rabbit_mq_client.connection)
|
||||
_response: DataResponse = await self.service_adapter.on_message(_a_message)
|
||||
logging_debug(f"Result in thread: {_response}")
|
||||
@@ -154,8 +153,8 @@ class DataMessageHandler:
|
||||
logging_error(f"Deleting file {filename}: {e}")
|
||||
except Exception as e:
|
||||
logging_error(f"Request handler: {e}")
|
||||
|
||||
self.backpressure_handler.decrease_parallel_executions()
|
||||
# recover availability
|
||||
await self.backpressure_handler.decrease_parallel_executions()
|
||||
|
||||
async def reconstitute_data_message(
|
||||
self, message: AbstractIncomingMessage
|
||||
|
||||
@@ -14,8 +14,9 @@ from amqp.adapter.data_message_factory import DataMessageFactory
|
||||
from amqp.adapter.logging_utils import logging_info, logging_warning
|
||||
from amqp.adapter.service_message_factory import ServiceMessageFactory
|
||||
from amqp.config.amq_configuration import AMQConfiguration
|
||||
from amqp.model.model import DataMessage
|
||||
from amqp.model.model import AMQRoute, DataMessage
|
||||
from amqp.rabbitmq.rabbit_mq_client import RabbitMQClient
|
||||
from amqp.router.router_consumer import RouterConsumer
|
||||
from amqp.service.amq_message_handler import DataMessageHandler
|
||||
from amqp.service.tracing import TracingConfig, initialize_telemetry
|
||||
|
||||
@@ -45,7 +46,8 @@ class AMQService:
|
||||
self.data_message_factory = DataMessageFactory.get_instance(
|
||||
self.amq_configuration.amq_adapter.generator_id
|
||||
)
|
||||
self.rabbit_mq_client = RabbitMQClient(self.amq_configuration)
|
||||
self.router = RouterConsumer(self.amq_configuration)
|
||||
self.rabbit_mq_client = RabbitMQClient(self.amq_configuration, self.router)
|
||||
self.tracer = initialize_telemetry(
|
||||
tracing_config=TracingConfig(
|
||||
service_name=amq_configuration.amq_adapter.service_name,
|
||||
@@ -57,8 +59,17 @@ class AMQService:
|
||||
self.amq_configuration.amq_adapter.service_name
|
||||
)
|
||||
self.backpressure_thread: Optional[Thread] = None
|
||||
self.backpressure_handler: Optional[BackpressureHandler] = None
|
||||
|
||||
# =======================================================================
|
||||
# =======================================================================
|
||||
# ====================== A P I M e t h o d s =========================
|
||||
# =======================================================================
|
||||
# =======================================================================
|
||||
def run(self):
|
||||
"""
|
||||
Start the AMQ service, initializing RabbitMQ connection and message handlers.
|
||||
"""
|
||||
# self.init(loop) will initialize RabbitMQ connection
|
||||
asyncio.set_event_loop(self.loop)
|
||||
self.loop.run_until_complete(self.init(self.loop, self.service_adapter))
|
||||
@@ -68,9 +79,38 @@ class AMQService:
|
||||
logging.info("***********************************************")
|
||||
self.loop.run_forever()
|
||||
|
||||
def rpc(self, service_name: str, fq_method_name: str, **kwargs) -> Future:
|
||||
"""
|
||||
Send a Remote Procedure Call (RPC) message to the AMQ service.
|
||||
:param service_name: The service name to receive the RPC message.
|
||||
:param fq_method_name: The fully qualified method name to execute the RPC message.
|
||||
:param kwargs: Additional keyword arguments to be passed to the RPC method.
|
||||
:return: A Future that resolves when the RPC response is received.
|
||||
"""
|
||||
route: Optional[AMQRoute] = self.router.find_route_by_service(service_name)
|
||||
if route is not None:
|
||||
message: DataMessage = self.data_message_factory.create_rpc_message(
|
||||
fq_method_name=fq_method_name,
|
||||
swarm_id=self.amq_configuration.amq_adapter.swarm_task_id,
|
||||
**kwargs,
|
||||
)
|
||||
result: Future = self.amq_data_message_handler.send_rpc(route, message)
|
||||
self.set_outstanding_future(message, result)
|
||||
return result
|
||||
|
||||
result = asyncio.Future()
|
||||
result.set_exception(
|
||||
NameError(f"No route exists. Service '{service_name}' not found in routing table.")
|
||||
)
|
||||
return result
|
||||
|
||||
# =======================================================================
|
||||
# ====================== Internal Methods ============================
|
||||
# =======================================================================
|
||||
|
||||
async def init(self, loop, service_adapter):
|
||||
_reply_to_exchange: AbstractRobustExchange = await self.rabbit_mq_client.init(loop)
|
||||
backpressure_handler = BackpressureHandler(
|
||||
self.backpressure_handler = BackpressureHandler(
|
||||
channel=self.rabbit_mq_client.channel,
|
||||
loop=loop,
|
||||
config=self.amq_configuration,
|
||||
@@ -84,11 +124,12 @@ class AMQService:
|
||||
self.rabbit_mq_client,
|
||||
self.amq_configuration,
|
||||
loop=loop,
|
||||
backpressure_handler=backpressure_handler,
|
||||
backpressure_handler=self.backpressure_handler,
|
||||
)
|
||||
await self.register_routes()
|
||||
await self.rabbit_mq_client.request_routes()
|
||||
self.backpressure_thread = backpressure_handler.start_backpressure_monitor()
|
||||
logging_info("Starting backpressure monitor thread")
|
||||
self.backpressure_thread = self.backpressure_handler.start_backpressure_monitor()
|
||||
|
||||
def cleanup(self):
|
||||
logging_info("AMQService.cleanup() rmqc=%s", self.rabbit_mq_client)
|
||||
@@ -97,7 +138,7 @@ class AMQService:
|
||||
|
||||
async def reinitialize_if_disconnected(self):
|
||||
if not self.rabbit_mq_client.channel or self.rabbit_mq_client.channel.is_closed:
|
||||
self.rabbit_mq_client = RabbitMQClient(self.amq_configuration)
|
||||
self.rabbit_mq_client = RabbitMQClient(self.amq_configuration, self.router)
|
||||
await self.rabbit_mq_client.init(loop=self.loop)
|
||||
await self.register_routes()
|
||||
await self.rabbit_mq_client.request_routes()
|
||||
@@ -122,7 +163,7 @@ class AMQService:
|
||||
)
|
||||
return None
|
||||
|
||||
def send_message(self, message: DataMessage, future: Future):
|
||||
def set_outstanding_future(self, message: DataMessage, future: Future):
|
||||
self.amq_data_message_handler.outstanding[str(message.id())] = future
|
||||
|
||||
def remove_outstanding_future(self, message_id: str):
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import consul_kv
|
||||
import socket
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
|
||||
# --- Configuration ---
|
||||
CONSUL_HOST = os.environ.get('CONSUL_HOST', 'localhost')
|
||||
CONSUL_PORT = int(os.environ.get('CONSUL_PORT', 8500))
|
||||
CONSUL_COUNTER_KEY = 'service/ids/counter'
|
||||
MAX_RETRIES = 5 # Max attempts for atomic update
|
||||
RETRY_DELAY_SECONDS = 0.1 # Delay between retries for atomic update
|
||||
|
||||
# --- Logging Setup ---
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(threadName)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# --- Fallback ID Generation ---
|
||||
def generate_fallback_id() -> int:
|
||||
"""
|
||||
Generates a fallback ID based on hostname and PID if Consul is unavailable.
|
||||
This provides a unique-ish ID for local testing/development but is not guaranteed
|
||||
to be globally unique across different machines or after restarts.
|
||||
"""
|
||||
hostname = socket.gethostname()
|
||||
pid = os.getpid()
|
||||
# A simple way to combine them into a number.
|
||||
# Hash hostname to an integer and combine with PID.
|
||||
# Note: This is a simple fallback. For production, consider a more robust
|
||||
# local ID generation or a different strategy if Consul is critical.
|
||||
fallback_id = (hash(hostname) % 1000000) * 100000 + pid
|
||||
logger.warning(
|
||||
f"Consul is unavailable. Generating fallback ID: {fallback_id} (based on hostname: {hostname}, PID: {pid})")
|
||||
return fallback_id
|
||||
|
||||
|
||||
# --- Unique Instance ID Generation Function ---
|
||||
def get_unique_instance_id() -> int:
|
||||
"""
|
||||
Reads and atomically increments a counter in Consul KV to get a unique instance ID.
|
||||
If Consul is unavailable or the atomic update fails after retries,
|
||||
it falls back to generating an ID based on hostname and PID.
|
||||
"""
|
||||
try:
|
||||
# Initialize Consul client
|
||||
client = consul_kv.Connection(endpoint=f"{CONSUL_HOST}:{CONSUL_PORT}")
|
||||
logger.info(f"Attempting to connect to Consul at {CONSUL_HOST}:{CONSUL_PORT}")
|
||||
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
# 1. Read the current value and its modifyIndex (for CAS)
|
||||
# client.get returns (value, modifyIndex)
|
||||
current_value_str, modify_index = client.get(CONSUL_COUNTER_KEY)
|
||||
|
||||
# If key doesn't exist, initialize it to 0
|
||||
if current_value_str is None:
|
||||
current_value = 0
|
||||
modify_index = 0 # For a non-existent key, modify_index is 0 for initial CAS
|
||||
logger.info(f"Consul key '{CONSUL_COUNTER_KEY}' not found. Initializing to 0.")
|
||||
else:
|
||||
current_value = int(current_value_str)
|
||||
|
||||
new_value = current_value + 1
|
||||
|
||||
# 2. Attempt atomic update using CAS (Check-And-Set)
|
||||
# client.set returns True on success, False on failure (if modify_index changed)
|
||||
success = client.put(CONSUL_COUNTER_KEY, str(new_value), cas=modify_index)
|
||||
|
||||
if success:
|
||||
logger.info(f"Successfully obtained unique ID: {new_value} (Attempt {attempt + 1}/{MAX_RETRIES})")
|
||||
return new_value
|
||||
else:
|
||||
logger.warning(
|
||||
f"CAS failed for '{CONSUL_COUNTER_KEY}' (modify_index changed). Retrying... (Attempt {attempt + 1}/{MAX_RETRIES})")
|
||||
time.sleep(RETRY_DELAY_SECONDS * (attempt + 1)) # Exponential backoff for retries
|
||||
except ValueError:
|
||||
# Handle case where the stored value is not an integer
|
||||
logger.error(
|
||||
f"Value stored at '{CONSUL_COUNTER_KEY}' is not a valid integer. Please check Consul KV. Attempting to overwrite.")
|
||||
# To recover, you might force a set without CAS, but that risks data loss.
|
||||
# For this demo, we'll just let it retry or fall back.
|
||||
time.sleep(RETRY_DELAY_SECONDS * (attempt + 1))
|
||||
except Exception as e:
|
||||
# Catch specific ConsulKV errors (e.g., connection issues, invalid response)
|
||||
logger.error(f"ConsulKV error during atomic update (Attempt {attempt + 1}/{MAX_RETRIES}): {e}")
|
||||
time.sleep(RETRY_DELAY_SECONDS * (attempt + 1)) # Exponential backoff
|
||||
|
||||
logger.error(f"Failed to obtain unique ID from Consul after {MAX_RETRIES} attempts.")
|
||||
return generate_fallback_id() # Fallback if all retries fail
|
||||
|
||||
except Exception as e:
|
||||
# Catch broader connection errors or unexpected issues with Consul client initialization
|
||||
logger.error(f"Could not connect to Consul or an unexpected error occurred: {e}")
|
||||
return generate_fallback_id() # Fallback if Consul is completely unreachable
|
||||
|
||||
import os
|
||||
import logging
|
||||
import random
|
||||
import socket
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import consul
|
||||
|
||||
from amqp.config.amq_configuration import AMQConfiguration
|
||||
|
||||
from amqp.config.amq_configuration import AMQConfiguration
|
||||
|
||||
# Configuration from environment variables with defaults
|
||||
CONSUL_HOST = os.environ.get('CONSUL_HOST', 'localhost')
|
||||
CONSUL_PORT = int(os.environ.get('CONSUL_PORT', 8500))
|
||||
CONSUL_COUNTER_KEY = 'service/ids/counter'
|
||||
MAX_RETRIES = 5 # Max attempts for atomic update
|
||||
RETRY_DELAY_SECONDS = 0.1 # Delay between retries for atomic update
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_fallback_id() -> int:
|
||||
"""
|
||||
Generate a fallback ID when Consul is not available.
|
||||
Uses a combination of timestamp, hostname hash, and random number.
|
||||
|
||||
Returns:
|
||||
int: A reasonably unique integer ID
|
||||
"""
|
||||
# Get current timestamp in milliseconds
|
||||
timestamp = int(time.time() * 1000)
|
||||
|
||||
# Get hostname and hash it to an integer
|
||||
hostname = socket.gethostname()
|
||||
hostname_hash = hash(hostname) % 10000 # Limit to 4 digits
|
||||
|
||||
# Generate a random number
|
||||
random_part = random.randint(0, 9999) # 4 digits
|
||||
|
||||
# Combine all parts into a single integer
|
||||
# Format: TTTTTTTTTTT-HHHH-RRRR (T=timestamp, H=hostname hash, R=random)
|
||||
unique_id = (timestamp * 100000000) + (hostname_hash * 10000) + random_part
|
||||
|
||||
logger.warning(f"Using fallback ID generation method: {unique_id}")
|
||||
return unique_id
|
||||
|
||||
|
||||
def get_unique_instance_id() -> int:
|
||||
"""
|
||||
Get a globally unique ID from Consul.
|
||||
Uses Consul's atomic Compare-And-Set operations to safely increment a counter.
|
||||
Falls back to a local generation method if Consul is unavailable.
|
||||
|
||||
Returns:
|
||||
int: A globally unique integer ID
|
||||
"""
|
||||
consul_host, consul_port, consul_counter_key, max_retries, retry_delay_seconds = get_config_values()
|
||||
try:
|
||||
# Connect to Consul
|
||||
c = consul.Consul(host=consul_host, port=consul_port)
|
||||
|
||||
# Try to get the current counter value
|
||||
index, data = c.kv.get(CONSUL_COUNTER_KEY)
|
||||
|
||||
# If the key doesn't exist yet, create it with initial value
|
||||
if data is None:
|
||||
logger.info(f"Initializing Consul counter at {CONSUL_COUNTER_KEY}")
|
||||
if c.kv.put(CONSUL_COUNTER_KEY, "1"):
|
||||
return 1
|
||||
else:
|
||||
logger.error("Failed to initialize Consul counter")
|
||||
return generate_fallback_id()
|
||||
|
||||
# Get the current value
|
||||
current_value = int(data['Value'].decode('utf-8'))
|
||||
new_value = current_value + 1
|
||||
|
||||
# Try to atomically update the counter
|
||||
for attempt in range(MAX_RETRIES):
|
||||
# Use Compare-And-Set to ensure atomicity
|
||||
success = c.kv.put(
|
||||
CONSUL_COUNTER_KEY,
|
||||
str(new_value),
|
||||
cas=data['ModifyIndex']
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.debug(f"Successfully obtained unique ID: {new_value}")
|
||||
return new_value
|
||||
|
||||
# If CAS failed, someone else updated the value, retry
|
||||
logger.debug(f"CAS update failed on attempt {attempt+1}, retrying...")
|
||||
time.sleep(RETRY_DELAY_SECONDS)
|
||||
|
||||
# Get the latest value for the next attempt
|
||||
index, data = c.kv.get(CONSUL_COUNTER_KEY)
|
||||
if data is None:
|
||||
logger.error("Counter disappeared during update")
|
||||
return generate_fallback_id()
|
||||
|
||||
current_value = int(data['Value'].decode('utf-8'))
|
||||
new_value = current_value + 1
|
||||
|
||||
# If we've exhausted all retries, use the fallback
|
||||
logger.error(f"Failed to update counter after {MAX_RETRIES} attempts")
|
||||
return generate_fallback_id()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error accessing Consul: {str(e)}")
|
||||
return generate_fallback_id()
|
||||
def get_config_values():
|
||||
"""
|
||||
Get configuration values from AMQConfiguration or environment variables.
|
||||
|
||||
Returns:
|
||||
tuple: (consul_host, consul_port, consul_counter_key, max_retries, retry_delay_seconds)
|
||||
"""
|
||||
try:
|
||||
config = AMQConfiguration("application.properties")
|
||||
consul_host = os.environ.get('CONSUL_HOST', 'localhost')
|
||||
consul_port = int(os.environ.get('CONSUL_PORT', 8500))
|
||||
consul_counter_key = os.environ.get('CONSUL_COUNTER_KEY', 'service/ids/counter')
|
||||
max_retries = int(os.environ.get('CONSUL_MAX_RETRIES', 5))
|
||||
retry_delay_seconds = float(os.environ.get('CONSUL_RETRY_DELAY_SECONDS', 0.1))
|
||||
return consul_host, consul_port, consul_counter_key, max_retries, retry_delay_seconds
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load configuration: {str(e)}. Using default values.")
|
||||
return (
|
||||
os.environ.get('CONSUL_HOST', 'localhost'),
|
||||
int(os.environ.get('CONSUL_PORT', 8500)),
|
||||
os.environ.get('CONSUL_COUNTER_KEY', 'service/ids/counter'),
|
||||
int(os.environ.get('CONSUL_MAX_RETRIES', 5)),
|
||||
float(os.environ.get('CONSUL_RETRY_DELAY_SECONDS', 0.1))
|
||||
)
|
||||
@@ -0,0 +1,337 @@
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import time
|
||||
|
||||
import httpx # For emulating remote service call
|
||||
|
||||
# import uvicorn
|
||||
from fastapi import FastAPI, File, Form, UploadFile
|
||||
|
||||
# OpenTelemetry imports
|
||||
from opentelemetry import metrics, trace
|
||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
||||
from opentelemetry.exporter.prometheus import PrometheusMetricReader
|
||||
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
||||
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
||||
from opentelemetry.sdk.metrics import MeterProvider
|
||||
|
||||
# from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
|
||||
|
||||
from amqp.config.amq_configuration import AMQConfiguration
|
||||
from otdemo.otdemo_adapter import OTDemoAmqpAdapter
|
||||
|
||||
# --- OpenTelemetry Configuration ---
|
||||
|
||||
# Configure Resource: Defines common attributes for traces and metrics
|
||||
resource = Resource.create(
|
||||
attributes={
|
||||
"service.name": "fastapi-document-service",
|
||||
"application": "CleverMicroDemo",
|
||||
"environment": "development",
|
||||
}
|
||||
)
|
||||
|
||||
# Configure TracerProvider: Responsible for creating and managing Tracers
|
||||
trace_provider = TracerProvider(resource=resource)
|
||||
|
||||
# OTLPSpanExporter: Exports spans to an OTLP collector (e.g., Jaeger) via gRPC
|
||||
# Endpoint can be overridden by OTEL_EXPORTER_OTLP_ENDPOINT environment variable
|
||||
otlp_exporter = OTLPSpanExporter(
|
||||
endpoint=os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")
|
||||
)
|
||||
# BatchSpanProcessor: Batches spans for efficient export
|
||||
trace_provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
|
||||
|
||||
# Optional: ConsoleSpanExporter for local debugging to print traces to console
|
||||
if os.environ.get("OTEL_DEBUG_CONSOLE_TRACES", "false").lower() == "true":
|
||||
trace_provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
|
||||
|
||||
# Set the configured TracerProvider globally
|
||||
trace.set_tracer_provider(trace_provider)
|
||||
|
||||
# Configure MeterProvider: Responsible for creating and managing Meters for metrics
|
||||
# PrometheusMetricReader: Exposes metrics via an HTTP endpoint for Prometheus to scrape
|
||||
# Host and port can be overridden by OTEL_EXPORTER_PROMETHEUS_HOST and OTEL_EXPORTER_PROMETHEUS_PORT
|
||||
prometheus_reader = PrometheusMetricReader(
|
||||
# handler_address=os.environ.get("OTEL_EXPORTER_PROMETHEUS_HOST", "0.0.0.0"),
|
||||
# handler_port=int(os.environ.get("OTEL_EXPORTER_PROMETHEUS_PORT", "8001"))
|
||||
)
|
||||
# MeterProvider: Uses the PrometheusMetricReader to periodically export metrics
|
||||
metric_provider = MeterProvider(resource=resource, metric_readers=[prometheus_reader])
|
||||
# Set the configured MeterProvider globally
|
||||
metrics.set_meter_provider(metric_provider)
|
||||
|
||||
# Get tracer and meter instances from the global providers
|
||||
tracer = trace.get_tracer("document-service.tracer")
|
||||
meter = metrics.get_meter("document-service.meter")
|
||||
|
||||
# --- Custom Metrics Definition ---
|
||||
# Counters: For cumulative sums (e.g., total requests)
|
||||
documents_uploaded_counter = meter.create_counter(
|
||||
name="documents_uploaded_total", description="Total number of documents uploaded", unit="1"
|
||||
)
|
||||
metadata_processed_counter = meter.create_counter(
|
||||
name="documents_metadata_processed_total",
|
||||
description="Total number of document metadata processed",
|
||||
unit="1",
|
||||
)
|
||||
documents_retrieved_counter = meter.create_counter(
|
||||
name="documents_retrieved_total", description="Total number of documents retrieved", unit="1"
|
||||
)
|
||||
# Histograms: For distributions of values (e.g., request durations)
|
||||
internal_api_call_duration_histogram = meter.create_histogram(
|
||||
name="internal_api_call_duration_seconds",
|
||||
description="Duration of internal API calls to metadata endpoint",
|
||||
unit="s",
|
||||
)
|
||||
|
||||
# --- FastAPI Application Setup ---
|
||||
app = FastAPI(
|
||||
title="CleverMicro Document Service Demo",
|
||||
description="A FastAPI application demonstrating OpenTelemetry integration with Jaeger and Prometheus.",
|
||||
# root_path="/otdemo", # This won't affect router path
|
||||
)
|
||||
|
||||
# Instrument FastAPI: Automatically creates spans for incoming requests
|
||||
FastAPIInstrumentor.instrument_app(app)
|
||||
# Instrument httpx: Automatically propagates trace context for outgoing HTTP calls
|
||||
HTTPXClientInstrumentor().instrument()
|
||||
|
||||
# Directory for storing uploaded files (temporary for demo)
|
||||
UPLOAD_DIR = "/tmp/clevermicro_documents"
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
|
||||
@app.post("/api/v3/documents")
|
||||
async def upload_document(
|
||||
file: UploadFile = File(...), # Multipart file upload
|
||||
name: str = Form(...), # Form field for document name
|
||||
description: str = Form(...), # Form field for document description
|
||||
):
|
||||
# Create a custom span for this specific endpoint's logic
|
||||
with tracer.start_as_current_span("upload_document_endpoint_logic"):
|
||||
# Save the uploaded file to the temporary directory
|
||||
file_location = os.path.join(UPLOAD_DIR, file.filename)
|
||||
try:
|
||||
with open(file_location, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
print(f"File '{file.filename}' saved to {file_location}")
|
||||
|
||||
# Increment the documents_uploaded_total metric
|
||||
documents_uploaded_counter.add(1, {"file.name": file.filename, "document.name": name})
|
||||
|
||||
# Emulate a REST call to a "remote" service (which is actually this same app)
|
||||
# This demonstrates trace context propagation across HTTP calls.
|
||||
metadata_payload = {"name": name, "description": description}
|
||||
print(
|
||||
f"Emulating remote call to /api/v3/documents/metadata with: {json.dumps(metadata_payload)}"
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
async with httpx.AsyncClient() as client:
|
||||
# httpx instrumentation ensures the current trace context is propagated
|
||||
response = await client.post(
|
||||
# IMPORTANT: Use the actual host and port where your FastAPI app is running
|
||||
# If running directly on host, use http://localhost:8000
|
||||
# If running inside Docker and calling itself, use http://host.docker.internal:8000
|
||||
# "http://localhost:8000/api/v3/documents/metadata",
|
||||
"http://app:8000/api/v3/documents/metadata",
|
||||
json=metadata_payload,
|
||||
)
|
||||
response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
|
||||
end_time = time.time()
|
||||
duration = end_time - start_time
|
||||
# Record the duration of the internal API call
|
||||
internal_api_call_duration_histogram.record(
|
||||
duration, {"endpoint": "/api/v3/documents/metadata"}
|
||||
)
|
||||
|
||||
print(f"Internal metadata service responded: {response.json()}")
|
||||
|
||||
return {
|
||||
"message": "Document uploaded and metadata processing initiated",
|
||||
"filename": file.filename,
|
||||
"name": name,
|
||||
"description": description,
|
||||
}
|
||||
except httpx.HTTPStatusError as e:
|
||||
print(f"Error calling metadata service: {e.response.status_code} - {e.response.text}")
|
||||
# Propagate the error in the trace
|
||||
current_span = trace.get_current_span()
|
||||
current_span.set_attribute("error.type", "HTTPStatusError")
|
||||
current_span.record_exception(e)
|
||||
return {
|
||||
"message": "Document uploaded but metadata processing failed",
|
||||
"detail": str(e),
|
||||
}, 500
|
||||
except Exception as e:
|
||||
print(f"An unexpected error occurred during document upload: {e}")
|
||||
# Propagate the error in the trace
|
||||
current_span = trace.get_current_span()
|
||||
current_span.set_attribute("error.type", "UnexpectedError")
|
||||
current_span.record_exception(e)
|
||||
return {"message": "Error processing document", "detail": str(e)}, 500
|
||||
|
||||
|
||||
@app.post("/api/v3/documents/metadata")
|
||||
async def process_document_metadata(metadata: dict):
|
||||
# This endpoint is automatically instrumented by FastAPIInstrumentor
|
||||
# A new span will be created, and its parent will be the span from the calling /api/v3/documents
|
||||
# due to trace context propagation via httpx.
|
||||
print(f"Received metadata for processing: {json.dumps(metadata, indent=2)}")
|
||||
|
||||
# Increment the metadata_processed_total metric
|
||||
metadata_processed_counter.add(1, {"document.name": metadata.get("name")})
|
||||
|
||||
# Simulate some processing time to make traces more interesting
|
||||
time.sleep(random.uniform(0.05, 0.2))
|
||||
|
||||
return {"status": "metadata processed", "received_data": metadata}
|
||||
|
||||
|
||||
@app.get("/api/v3/documents")
|
||||
async def get_documents():
|
||||
# Create a custom span for the logic within this endpoint
|
||||
with tracer.start_as_current_span("get_documents_endpoint_logic"):
|
||||
documents = []
|
||||
# Generate several random documents to emulate a list
|
||||
for i in range(random.randint(2, 5)):
|
||||
documents.append(
|
||||
{
|
||||
"id": f"doc-{random.randint(1000, 9999)}",
|
||||
"name": f"Document {random.randint(1, 100)}",
|
||||
"description": f"Description for document {random.randint(1, 1000)}",
|
||||
"uploaded_at": time.strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ",
|
||||
time.gmtime(time.time() - random.randint(0, 86400 * 30)),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# Increment the documents_retrieved_total metric
|
||||
documents_retrieved_counter.add(1)
|
||||
|
||||
# Simulate some processing time
|
||||
time.sleep(random.uniform(0.01, 0.1))
|
||||
|
||||
return {"documents": documents}
|
||||
|
||||
|
||||
# Need to place below all route methods, otherwise the routes are incomplete
|
||||
otadapter = OTDemoAmqpAdapter(AMQConfiguration("otdemo/otdemo.properties"), app.routes)
|
||||
# --- How to Run the Demo ---
|
||||
# 1. Save the code:
|
||||
# Save the Python code above as `main.py` in a new directory.
|
||||
|
||||
# 2. Create `requirements.txt`:
|
||||
# Create a file named `requirements.txt` in the same directory with the following content:
|
||||
# ```
|
||||
# fastapi==0.111.0
|
||||
# uvicorn[standard]==0.29.0
|
||||
# python-multipart==0.0.20
|
||||
# httpx==0.27.0
|
||||
# opentelemetry-api==1.25.0
|
||||
# opentelemetry-sdk==1.25.0
|
||||
# opentelemetry-exporter-otlp-proto-grpc==1.25.0 # For Jaeger traces
|
||||
# opentelemetry-exporter-prometheus==1.25.0 # For Prometheus metrics
|
||||
# opentelemetry-instrumentation-fastapi==0.46b0 # IMPORTANT: Check compatibility with your FastAPI version!
|
||||
# opentelemetry-instrumentation-httpx==0.46b0 # IMPORTANT: Check compatibility with your httpx version!
|
||||
# protobuf==4.25.3 # Required by OTLP exporter, ensure version compatibility if issues arise
|
||||
# ```
|
||||
|
||||
# 3. Install dependencies:
|
||||
# Open your terminal in the directory where you saved `main.py` and `requirements.txt`, then run:
|
||||
# `pip install -r requirements.txt`
|
||||
|
||||
# 4. Run Jaeger and Prometheus (using Docker Compose for simplicity):
|
||||
# Create a file named `docker-compose.yaml` in the same directory:
|
||||
# ```yaml
|
||||
# version: '3.8'
|
||||
# services:
|
||||
# jaeger:
|
||||
# image: jaegertracing/all-in-one:latest
|
||||
# ports:
|
||||
# - "6831:6831/udp" # UDP Thrift
|
||||
# - "14268:14268" # HTTP Thrift
|
||||
# - "14250:14250" # gRPC
|
||||
# - "4317:4317" # OTLP gRPC collector (for traces from our app)
|
||||
# - "4318:4318" # OTLP HTTP collector
|
||||
# - "16686:16686" # Jaeger UI
|
||||
# environment:
|
||||
# - COLLECTOR_OTLP_ENABLED=true # Enable OTLP reception
|
||||
#
|
||||
# prometheus:
|
||||
# image: prom/prometheus:latest
|
||||
# volumes:
|
||||
# - ./prometheus.yml:/etc/prometheus/prometheus.yml # Mount Prometheus config
|
||||
# ports:
|
||||
# - "9090:9090" # Prometheus UI
|
||||
# command:
|
||||
# - '--config.file=/etc/prometheus/prometheus.yml'
|
||||
# ```
|
||||
|
||||
# Create a file named `prometheus.yml` in the same directory:
|
||||
# ```yaml
|
||||
# global:
|
||||
# scrape_interval: 15s # How frequently to scrape targets
|
||||
#
|
||||
# scrape_configs:
|
||||
# - job_name: 'fastapi_app'
|
||||
# # The 'metrics' endpoint of our FastAPI app exposed by PrometheusMetricReader
|
||||
# # 'host.docker.internal' allows Docker containers to connect to the host machine's localhost
|
||||
# static_configs:
|
||||
# - targets: ['host.docker.internal:8001']
|
||||
# ```
|
||||
|
||||
# Start Jaeger and Prometheus:
|
||||
# `docker-compose up -d` (the `-d` runs them in the background)
|
||||
|
||||
# 5. Run the FastAPI application:
|
||||
# In your terminal (where `main.py` is located), run:
|
||||
# `uvicorn main:app --host 0.0.0.0 --port 8000`
|
||||
# (Optional: To see traces printed to console, set `export OTEL_DEBUG_CONSOLE_TRACES=true` before `uvicorn`)
|
||||
|
||||
# --- Accessing the UIs ---
|
||||
# * **Jaeger UI:** Open your web browser and go to `http://localhost:16686`
|
||||
# * **Prometheus UI:** Open your web browser and go to `http://localhost:9090`
|
||||
# * **FastAPI App (Root):** `http://localhost:8000`
|
||||
# * **FastAPI Metrics Endpoint:** `http://localhost:8001/metrics` (Prometheus scrapes this, you can also view it directly)
|
||||
|
||||
# --- How to Test and Observe ---
|
||||
# 1. Ensure all services are running (`docker-compose ps` should show `jaeger` and `prometheus` up, and `uvicorn` running in your terminal).
|
||||
# 2. **Trigger GET requests:**
|
||||
# Open your browser or use `curl` to hit: `http://localhost:8000/api/v3/documents`
|
||||
# Refresh a few times.
|
||||
# 3. **Trigger POST requests (for file upload and internal call):**
|
||||
# You'll need a tool like Postman, Insomnia, or `curl` (more complex for multipart) for this.
|
||||
# **Using Postman/Insomnia:**
|
||||
# * Method: `POST`
|
||||
# * URL: `http://localhost:8000/api/v3/documents`
|
||||
# * Body: Select `form-data`
|
||||
# * Add a key `file`, Type `File`, Value: Choose any small file from your computer (e.g., a `.txt` or `.png`).
|
||||
# * Add a key `name`, Type `Text`, Value: `My Demo Document`
|
||||
# * Add a key `description`, Type `Text`, Value: `A test document for OpenTelemetry.`
|
||||
# * Send the request multiple times.
|
||||
|
||||
# 4. **Observe in Jaeger UI (`http://localhost:16686`):**
|
||||
# * Select "Service": `fastapi-document-service`
|
||||
# * Click "Find Traces".
|
||||
# * You should see traces for `/api/v3/documents` (POST) and `/api/v3/documents` (GET).
|
||||
# * Crucially, for POST requests, expand the trace: you will see the main `/api/v3/documents` span, and nested within it, a child span for the `POST /api/v3/documents/metadata` HTTP request. This demonstrates automatic context propagation.
|
||||
|
||||
# 5. **Observe in Prometheus UI (`http://localhost:9090`):**
|
||||
# * Go to the "Graph" tab.
|
||||
# * In the expression bar, type and execute queries like:
|
||||
# * `documents_uploaded_total`
|
||||
# * `documents_metadata_processed_total`
|
||||
# * `documents_retrieved_total`
|
||||
# * `internal_api_call_duration_seconds_sum`
|
||||
# * `internal_api_call_duration_seconds_count`
|
||||
# * You will see the values of these metrics increasing with your requests.
|
||||
|
||||
# This setup provides a clear, runnable demo for OpenTelemetry traces and metrics in a FastAPI application, including automatic context propagation for internal HTTP calls.
|
||||
@@ -0,0 +1,65 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
app:
|
||||
# build: .
|
||||
image: git.cleverthis.com/clevermicro/amq-adapter-python-demo:latest
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "8001:8001"
|
||||
environment:
|
||||
- OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4317
|
||||
depends_on:
|
||||
- jaeger
|
||||
- prometheus
|
||||
- rabbitmq
|
||||
- alertmanager
|
||||
|
||||
jaeger:
|
||||
image: jaegertracing/all-in-one:latest
|
||||
ports:
|
||||
- "6831:6831/udp"
|
||||
- "14268:14268"
|
||||
- "14250:14250"
|
||||
- "4317:4317"
|
||||
- "4318:4318"
|
||||
- "16686:16686"
|
||||
environment:
|
||||
- COLLECTOR_OTLP_ENABLED=true
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
volumes:
|
||||
- ./container-data/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
- ./container-data/prometheus/alert-rules.yml:/etc/prometheus/alert-rules.yml
|
||||
ports:
|
||||
- "9090:9090"
|
||||
command:
|
||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||
depends_on:
|
||||
- alertmanager
|
||||
|
||||
alertmanager:
|
||||
image: prom/alertmanager:latest
|
||||
ports:
|
||||
- "9093:9093"
|
||||
volumes:
|
||||
- ./container-data/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml
|
||||
command:
|
||||
- '--config.file=/etc/alertmanager/alertmanager.yml'
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:3-management-alpine
|
||||
ports:
|
||||
- "5672:5672"
|
||||
- "15672:15672"
|
||||
- "15692:15692"
|
||||
environment:
|
||||
- RABBITMQ_DEFAULT_USER=guest
|
||||
- RABBITMQ_DEFAULT_PASS=guest
|
||||
volumes:
|
||||
- rabbitmq_data:/var/lib/rabbitmq
|
||||
labels:
|
||||
prometheus-job: rabbitmq
|
||||
|
||||
volumes:
|
||||
rabbitmq_data:
|
||||
@@ -0,0 +1,44 @@
|
||||
# ====================================================================
|
||||
# CleverMicro AMQ Adapter settings
|
||||
# ====================================================================
|
||||
[CleverMicro-AMQ]
|
||||
# CleverMicro AMQ Adapter settings that identify this AMQ Adapter instance to other adapters
|
||||
|
||||
# A name to identify this service for the AMQ Adapter
|
||||
cm.amq-adapter.service-name=cleverswarm
|
||||
# The CleverMicro AMQ Adapter instance ID, used to identify this instance in the CleverMicro AMQ Adapter cluster
|
||||
cm.amq-adapter.generator-id=1
|
||||
# Route mapping. For route detail description see section 3.1 in Onboarding Guide for Python:
|
||||
# https://docs.cleverthis.com/en/architecture/microservices/onboarding/python
|
||||
# NOTE: this is a comma-separated list of route mappings (multiple, comma-separated routes are allowed here)
|
||||
# cm-dev route
|
||||
cm.amq-adapter.route-mapping=otdemo:otdemo-exchange:otdemo-queue:api.cm.dev.cleverthis.com.test.python.#:5:0
|
||||
# local route
|
||||
#cm.amq-adapter.route-mapping=otdemo::otdemo:api.dev.localhost.otdemo.#:5:0
|
||||
# Indicate if AMQ Adapter should respond with HTTP 401 Unauthorized if the request does not contain valid JWT token
|
||||
cm.amq-adapter.require-authenticated-user=false
|
||||
|
||||
# CleverMicro AMQ Adapter dispatcher, settings related to RabbitMQ connection and optional REST forwarding
|
||||
cm.dispatch.use-dlq=true
|
||||
cm.dispatch.amq-host=rabbitmq
|
||||
cm.dispatch.amq-port=5672
|
||||
cm.dispatch.amq-port-tls=5671
|
||||
cm.dispatch.download-dir=/tmp/downloaded_files
|
||||
# Not used for CleveSwarm nor CleverBRAG or in tight coupling mode.
|
||||
# applicable only in loose coupling mode, specify the destination where to forward the REST request
|
||||
#cm.dispatch.service-host=cleverthis-service-name.localhost
|
||||
#cm.dispatch.service-port=8080
|
||||
|
||||
# Backpressure monitor settings
|
||||
# Define maximum number of messages of being processed in parallel before triggering backpressure OVERLOAD alert
|
||||
cm.backpressure.threshold=1
|
||||
# Define maximum CPU usage (%) before triggering backpressure OVERLOAD alert
|
||||
cm.backpressure.threshold-cpu-overload=90
|
||||
# Define maximum CPU usage (%) before triggering backpressure IDLE alert
|
||||
cm.backpressure.threshold-cpu-idle=10
|
||||
# Define the time window between the Backpressure reports, in seconds
|
||||
cm.backpressure.time-window=10
|
||||
# Define the time window length, in seconds, during which, if CPU usage is consistently above the threshold, the system will trigger OVERLOAD alert
|
||||
cm.backpressure.cpu-overload-duration=30
|
||||
# Define the time window length, in seconds, during which, if no request is made to the Service, the system will trigger IDLE alert
|
||||
cm.backpressure.cpu-idle-duration=30
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
This module provides the CleverSwarm AMQP Adapter for handling API requests via AMQP.
|
||||
The code IS MEANT to be part of CleverSwarm codebase, and has direct dependencies on the CleverSwarm codebase.
|
||||
It is NOT MEANT to be used as a standalone module. It WILL NOT run outside the CleverSwarm endpoints context.
|
||||
"""
|
||||
|
||||
from threading import Thread
|
||||
from typing import Any, List
|
||||
|
||||
# CleverSwarm specific imports
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
from amqp.adapter.cleverthis_service_adapter import CleverThisServiceAdapter
|
||||
from amqp.config.amq_configuration import AMQConfiguration
|
||||
from amqp.service.amq_service import AMQService
|
||||
|
||||
PREFERRED_SUFFIX = "_json"
|
||||
|
||||
|
||||
# =================================================================================================
|
||||
# ======================== C l e v e r S w a r m A M Q A d a p t e r ========================
|
||||
# =================================================================================================
|
||||
class OTDemoAmqpAdapter(CleverThisServiceAdapter):
|
||||
"""
|
||||
CleverSwarm AMQP Adapter for CleverSwarm API. Implements the CleverSwarm specific logic that overrides
|
||||
common logic defined by AMQ Adapter library. The specific logic here is:
|
||||
|
||||
1. Extract subject from JWT token and convert it to UserSchema to avail authorized user argument
|
||||
2. Find JSON wrapper for endpoint Callable, and if exists, use it instead of the original endpoint.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, amq_configuration: AMQConfiguration, routes: List[APIRoute]):
|
||||
super().__init__(amq_configuration, routes=routes, session=None)
|
||||
_amq_service: AMQService = AMQService(amq_configuration, self)
|
||||
self.thread = Thread(target=_amq_service.run)
|
||||
self.thread.start()
|
||||
|
||||
async def get_current_user(self, token: str) -> str:
|
||||
"""
|
||||
Overrides the default get_current_user method to use the token from the AMQP message.
|
||||
In CleverSwarm context, it means simply to call provided get_current_user function.
|
||||
|
||||
:param token: JWT token from the AMQP message
|
||||
|
||||
:return: UserSchema object
|
||||
"""
|
||||
return token
|
||||
|
||||
def get_endpoint(self, endpoint: Any) -> Any:
|
||||
"""
|
||||
If the specific endpoint has a matching JSON wrapper (identified as {endpoint.name}_json ),
|
||||
then use it as preferred way to invoke the endpoint.
|
||||
|
||||
:param endpoint: the Callable for the endpoint
|
||||
|
||||
:return: new Callable representing the JSON wrapper endpoint, if it exists or the original Callable.
|
||||
"""
|
||||
return endpoint
|
||||
+3
-2
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "amq_adapter"
|
||||
version = "0.2.33"
|
||||
version = "0.2.34"
|
||||
description = "The Python implementation of the AMQ Adapter for CleverMicro"
|
||||
readme = "README.md"
|
||||
authors = [
|
||||
@@ -29,7 +29,8 @@ dependencies = [
|
||||
"opentelemetry-instrumentation-pika",
|
||||
"pika",
|
||||
"psutil",
|
||||
"python_multipart"
|
||||
"python-multipart",
|
||||
"consul-kv"
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.29.0
|
||||
python-multipart==0.0.20
|
||||
httpx==0.27.0
|
||||
opentelemetry-api==1.34.1
|
||||
opentelemetry-sdk==1.34.1
|
||||
opentelemetry-exporter-otlp-proto-grpc==1.34.1
|
||||
opentelemetry-exporter-prometheus==0.55b1
|
||||
opentelemetry-instrumentation-fastapi==0.55b1
|
||||
opentelemetry-instrumentation-httpx==0.55b1
|
||||
protobuf==5.28.3
|
||||
consul-kv==0.7.4
|
||||
@@ -0,0 +1,77 @@
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
|
||||
from amqp.service.consul_global_id_generator import get_unique_instance_id
|
||||
|
||||
CONSUL_HOST = os.environ.get("CONSUL_HOST", "localhost")
|
||||
CONSUL_PORT = int(os.environ.get("CONSUL_PORT", 8500))
|
||||
CONSUL_COUNTER_KEY = "service/ids/counter"
|
||||
MAX_RETRIES = 5 # Max attempts for atomic update
|
||||
RETRY_DELAY_SECONDS = 0.1 # Delay between retries for atomic update
|
||||
|
||||
# --- Logging Setup ---
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(threadName)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# --- Demo: Simulate Concurrent Access ---
|
||||
def worker_thread(results_list):
|
||||
"""A function to be run by multiple threads to get unique IDs."""
|
||||
instance_id = get_unique_instance_id()
|
||||
results_list.append(instance_id)
|
||||
logger.info(f"Thread {threading.current_thread().name} got ID: {instance_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
num_threads = 10 # Number of concurrent service instances to simulate
|
||||
all_generated_ids = []
|
||||
|
||||
print(f"\n--- Simulating {num_threads} concurrent service instances requesting unique IDs ---")
|
||||
|
||||
threads = []
|
||||
for i in range(num_threads):
|
||||
thread = threading.Thread(
|
||||
target=worker_thread, args=(all_generated_ids,), name=f"ServiceInstance-{i + 1}"
|
||||
)
|
||||
threads.append(thread)
|
||||
thread.start()
|
||||
|
||||
# Wait for all threads to complete
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
print("\n--- All unique IDs generated ---")
|
||||
print(f"Generated IDs: {sorted(all_generated_ids)}")
|
||||
|
||||
# Verify uniqueness
|
||||
if len(all_generated_ids) == len(set(all_generated_ids)):
|
||||
print("All generated IDs are unique (as expected).")
|
||||
else:
|
||||
print(
|
||||
"WARNING: Duplicate IDs found! This might indicate a problem with atomicity or fallback logic."
|
||||
)
|
||||
# Identify duplicates
|
||||
seen = set()
|
||||
duplicates = set()
|
||||
for x in all_generated_ids:
|
||||
if x in seen:
|
||||
duplicates.add(x)
|
||||
seen.add(x)
|
||||
print(f"Duplicate IDs: {duplicates}")
|
||||
|
||||
print("\n--- Testing Consul Unavailability Fallback ---")
|
||||
# Temporarily change Consul port to simulate unavailability
|
||||
original_consul_port = CONSUL_PORT
|
||||
CONSUL_PORT = 1 # Use an unlikely port
|
||||
|
||||
# Clear any existing ConsulKV client cache if applicable (consul_kv typically creates new client per call)
|
||||
# If using a persistent client, you'd need to invalidate/reinitialize it here.
|
||||
|
||||
fallback_id_1 = get_unique_instance_id()
|
||||
fallback_id_2 = get_unique_instance_id()
|
||||
|
||||
print(f"Fallback ID 1: {fallback_id_1}")
|
||||
print(f"Fallback ID 2: {fallback_id_2}")
|
||||
@@ -18,7 +18,6 @@ from amqp.adapter.backpressure_handler import (
|
||||
ScaleRequestV1,
|
||||
)
|
||||
from amqp.config.amq_configuration import AMQConfiguration
|
||||
from amqp.model.model import ScalingRequestAlert
|
||||
|
||||
|
||||
class TestScaleRequestV1:
|
||||
@@ -32,24 +31,18 @@ class TestScaleRequestV1:
|
||||
"""
|
||||
service_id = "test-service"
|
||||
task_id = "test-task"
|
||||
max_availability = 10
|
||||
current_availability = 5
|
||||
request_type = ScalingRequestAlert.UPDATE
|
||||
|
||||
scale_request = ScaleRequestV1(
|
||||
service_id,
|
||||
task_id,
|
||||
max_availability,
|
||||
current_availability,
|
||||
request_type,
|
||||
)
|
||||
|
||||
assert scale_request.version == 1
|
||||
assert scale_request.serviceId == service_id
|
||||
assert scale_request.taskId == task_id
|
||||
assert scale_request.maxAvailability == max_availability
|
||||
assert scale_request.currentAvailability == current_availability
|
||||
assert scale_request.requestType == request_type
|
||||
assert scale_request.thread is None
|
||||
|
||||
def test_scale_request_v1_to_json(self):
|
||||
@@ -58,16 +51,12 @@ class TestScaleRequestV1:
|
||||
"""
|
||||
service_id = "test-service"
|
||||
task_id = "test-task"
|
||||
max_availability = 10
|
||||
current_availability = 5
|
||||
request_type = ScalingRequestAlert.UPDATE
|
||||
|
||||
scale_request = ScaleRequestV1(
|
||||
service_id,
|
||||
task_id,
|
||||
max_availability,
|
||||
current_availability,
|
||||
request_type,
|
||||
)
|
||||
|
||||
json_output = scale_request.to_json()
|
||||
@@ -76,9 +65,7 @@ class TestScaleRequestV1:
|
||||
"version": 1,
|
||||
"serviceId": service_id,
|
||||
"taskId": task_id,
|
||||
"maxAvailability": max_availability,
|
||||
"currentAvailability": current_availability,
|
||||
"requestType": request_type.value,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
@@ -90,16 +77,12 @@ class TestScaleRequestV1:
|
||||
"""
|
||||
service_id = "test-service"
|
||||
task_id = "test-task"
|
||||
max_availability = 10
|
||||
current_availability = 5
|
||||
request_type = ScalingRequestAlert.OVERLOAD # Change the alert type
|
||||
|
||||
scale_request = ScaleRequestV1(
|
||||
service_id,
|
||||
task_id,
|
||||
max_availability,
|
||||
current_availability,
|
||||
request_type,
|
||||
)
|
||||
|
||||
json_output = scale_request.to_json()
|
||||
@@ -108,9 +91,7 @@ class TestScaleRequestV1:
|
||||
"version": 1,
|
||||
"serviceId": service_id,
|
||||
"taskId": task_id,
|
||||
"maxAvailability": max_availability,
|
||||
"currentAvailability": current_availability,
|
||||
"requestType": request_type.value, # Ensure the value is correctly serialized
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
@@ -285,47 +266,43 @@ class TestBackpressureHandler:
|
||||
assert handler.swarm_service_id == "clever-amqp-service"
|
||||
assert handler.swarm_task_id == "python-adapter"
|
||||
assert handler.parallel_workers == mock_config.backpressure.threshold_threads
|
||||
assert handler.average_cpu_usage is None
|
||||
|
||||
def test_increase_parallel_executions(self, mock_channel, mock_loop, mock_config):
|
||||
@pytest.mark.asyncio
|
||||
async def test_increase_parallel_executions(self, mock_channel, mock_loop, mock_config):
|
||||
"""
|
||||
Test the increase_parallel_executions method.
|
||||
"""
|
||||
handler = BackpressureHandler(mock_channel, mock_loop, mock_config)
|
||||
handler.send_backpressure_report = AsyncMock()
|
||||
initial_executions = handler.current_parallel_executions
|
||||
handler.increase_parallel_executions()
|
||||
assert handler.current_parallel_executions == initial_executions + 1
|
||||
|
||||
def test_decrease_parallel_executions(self, mock_channel, mock_loop, mock_config):
|
||||
with patch("time.time") as mock_time:
|
||||
mock_time.return_value = 12345
|
||||
await handler.increase_parallel_executions()
|
||||
assert handler.current_parallel_executions == initial_executions + 1
|
||||
assert handler.last_usage_report_time == 12345
|
||||
assert handler.send_backpressure_report.call_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decrease_parallel_executions(self, mock_channel, mock_loop, mock_config):
|
||||
"""
|
||||
Test the decrease_parallel_executions method.
|
||||
"""
|
||||
handler = BackpressureHandler(mock_channel, mock_loop, mock_config)
|
||||
handler.send_backpressure_report = AsyncMock()
|
||||
handler.current_parallel_executions = 5
|
||||
handler.decrease_parallel_executions()
|
||||
assert handler.current_parallel_executions == 4
|
||||
|
||||
handler.current_parallel_executions = 0
|
||||
handler.decrease_parallel_executions()
|
||||
assert handler.current_parallel_executions == 0 # Should not go below 0
|
||||
|
||||
def test_update_parallel_executions(self, mock_channel, mock_loop, mock_config):
|
||||
"""
|
||||
Test the update_parallel_executions method.
|
||||
"""
|
||||
handler = BackpressureHandler(mock_channel, mock_loop, mock_config)
|
||||
handler.update_parallel_executions(100)
|
||||
assert handler.current_parallel_executions == 100
|
||||
|
||||
def test_update_last_data_message_time(self, mock_channel, mock_loop, mock_config):
|
||||
"""
|
||||
Test the update_last_data_message_time method.
|
||||
"""
|
||||
handler = BackpressureHandler(mock_channel, mock_loop, mock_config)
|
||||
with patch("time.time") as mock_time:
|
||||
mock_time.return_value = 12345
|
||||
handler.update_last_data_message_time()
|
||||
assert handler.last_data_message_time == 12345
|
||||
await handler.decrease_parallel_executions()
|
||||
assert handler.current_parallel_executions == 4
|
||||
assert handler.last_recover_report_time == 12345
|
||||
assert handler.send_backpressure_report.call_count == 1
|
||||
|
||||
handler.last_recover_report_time = 0
|
||||
handler.current_parallel_executions = 0
|
||||
await handler.decrease_parallel_executions()
|
||||
assert handler.current_parallel_executions == 0 # Should not go below 0
|
||||
|
||||
def test_start_backpressure_monitor(self, mock_channel, mock_loop, mock_config):
|
||||
"""
|
||||
@@ -338,130 +315,29 @@ class TestBackpressureHandler:
|
||||
assert handler.thread == thread
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_overload_condition_no_overload(self, mock_channel, mock_loop, mock_config):
|
||||
async def test_regular_report_loop(self, mock_channel, mock_loop, mock_config):
|
||||
"""
|
||||
Test the check_overload_condition method when there is no overload.
|
||||
Test the _regular_report_loop method.
|
||||
"""
|
||||
handler = BackpressureHandler(mock_channel, mock_loop, mock_config)
|
||||
handler.current_parallel_executions = 1
|
||||
handler.parallel_workers = 5
|
||||
handler.last_backpressure_event = ScalingRequestAlert.UPDATE
|
||||
with patch.object(handler, "handle_backpressure_overload_event") as mock_handle_overload:
|
||||
await handler.check_overload_condition()
|
||||
mock_handle_overload.assert_not_called()
|
||||
handler.send_backpressure_report = AsyncMock()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_overload_condition_overload(self, mock_channel, mock_loop, mock_config):
|
||||
"""
|
||||
Test the check_overload_condition method when there is an overload.
|
||||
"""
|
||||
handler = BackpressureHandler(mock_channel, mock_loop, mock_config)
|
||||
handler.current_parallel_executions = 9 # Assuming parallel_workers is 10
|
||||
handler.last_backpressure_event = ScalingRequestAlert.UPDATE
|
||||
with patch.object(handler, "handle_backpressure_overload_event") as mock_handle_overload:
|
||||
with patch("time.time") as mock_time:
|
||||
mock_time.return_value = 12345
|
||||
await handler.check_overload_condition()
|
||||
mock_handle_overload.assert_called_once()
|
||||
assert handler.last_backpressure_event_time == 12345
|
||||
assert handler.last_backpressure_event == ScalingRequestAlert.OVERLOAD
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_overload_condition_already_overload(
|
||||
self, mock_channel, mock_loop, mock_config
|
||||
):
|
||||
"""
|
||||
Test the check_overload_condition method when the last event was already an overload.
|
||||
"""
|
||||
handler = BackpressureHandler(mock_channel, mock_loop, mock_config)
|
||||
handler.current_parallel_executions = 10
|
||||
handler.last_backpressure_event = ScalingRequestAlert.OVERLOAD
|
||||
with patch.object(handler, "handle_backpressure_overload_event") as mock_handle_overload:
|
||||
await handler.check_overload_condition()
|
||||
mock_handle_overload.assert_not_called()
|
||||
|
||||
# backpressure_monitor_loop is difficult to test directly without significant mocking
|
||||
# and potentially long-running tests. The following is a basic outline of how
|
||||
# you might approach testing it, focusing on verifying that the correct methods
|
||||
# are called under specific conditions. This is NOT a complete, runnable test.
|
||||
@pytest.mark.asyncio
|
||||
async def test_backpressure_monitor_loop(self, mock_channel, mock_loop, mock_config):
|
||||
"""
|
||||
Test the backpressure_monitor_loop method (outline).
|
||||
"""
|
||||
handler = BackpressureHandler(mock_channel, mock_loop, mock_config)
|
||||
handler.average_cpu_usage = RunningAverage(
|
||||
100, initial_value=99
|
||||
) # Mock the RunningAverage in OVERLOAD state
|
||||
handler.average_cpu_usage.is_filled = True # Together with 0 values ensures OVERLOAD state
|
||||
# Setup mocks for dependent methods
|
||||
handler.handle_backpressure_overload_event = AsyncMock()
|
||||
handler._handle_backpressure_idle_event = AsyncMock()
|
||||
handler.publish_backpressure_request = AsyncMock()
|
||||
|
||||
# 1. Simulate an overload condition:
|
||||
handler.last_backpressure_event_time = 0 # Force event trigger
|
||||
handler.last_data_message_time = 0
|
||||
# test 1 loop
|
||||
handler.do_loop = 1
|
||||
handler.config.backpressure.cpu_overload_duration = (
|
||||
-1
|
||||
) # Force overload condition even for recent change
|
||||
await handler._backpressure_monitor()
|
||||
handler.handle_backpressure_overload_event.assert_called_once()
|
||||
|
||||
# 2. Simulate an idle condition
|
||||
handler.average_cpu_usage = RunningAverage(
|
||||
100, initial_value=0
|
||||
) # Mock the RunningAverage in IDLE state
|
||||
handler.average_cpu_usage.is_filled = True # Together with 0 values ensures IDLE state
|
||||
handler.last_backpressure_event_time = 0
|
||||
handler.last_data_message_time = 0
|
||||
handler.do_loop = 1
|
||||
handler.config.backpressure.idle_duration = (
|
||||
-1
|
||||
) # Force idle condition even for recent change
|
||||
await handler._backpressure_monitor()
|
||||
handler._handle_backpressure_idle_event.assert_called_once()
|
||||
|
||||
# 3. Simulate neither idle nor overload, but time_window passed
|
||||
handler.handle_backpressure_overload_event.call_count = 0
|
||||
handler._handle_backpressure_idle_event.call_count = 0
|
||||
handler.average_cpu_usage = RunningAverage(100) # Mock the RunningAverage in pritine state
|
||||
handler.last_backpressure_event_time = 0
|
||||
handler.last_data_message_time = 0
|
||||
handler.do_loop = 1
|
||||
await handler._backpressure_monitor()
|
||||
assert (
|
||||
handler.publish_backpressure_request.call_count
|
||||
+ handler.handle_backpressure_overload_event.call_count
|
||||
+ handler._handle_backpressure_idle_event.call_count
|
||||
) > 0
|
||||
await handler._regular_report_loop()
|
||||
handler.send_backpressure_report.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_backpressure_overload_event(self, mock_channel, mock_loop, mock_config):
|
||||
async def test_send_backpressure_report(self, mock_channel, mock_loop, mock_config):
|
||||
"""
|
||||
Test the handle_backpressure_overload_event method.
|
||||
Test the send_backpressure_report method.
|
||||
"""
|
||||
handler = BackpressureHandler(mock_channel, mock_loop, mock_config)
|
||||
handler.publish_backpressure_request = AsyncMock()
|
||||
with patch("time.time") as mock_time:
|
||||
mock_time.return_value = 12345
|
||||
await handler.handle_backpressure_overload_event()
|
||||
await handler.send_backpressure_report()
|
||||
assert handler.publish_backpressure_request.call_count == 1
|
||||
assert handler.last_data_message_time == 12345
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_backpressure_idle_event(self, mock_channel, mock_loop, mock_config):
|
||||
"""
|
||||
Test the _handle_backpressure_idle_event method.
|
||||
"""
|
||||
handler = BackpressureHandler(mock_channel, mock_loop, mock_config)
|
||||
handler.publish_backpressure_request = AsyncMock()
|
||||
with patch("time.time") as mock_time:
|
||||
mock_time.return_value = 12345
|
||||
await handler._handle_backpressure_idle_event()
|
||||
assert handler.publish_backpressure_request.call_count == 1
|
||||
assert handler.last_data_message_time == 12345
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_backpressure_request_exchange_exists(
|
||||
@@ -476,7 +352,7 @@ class TestBackpressureHandler:
|
||||
mock_async_run.return_value = asyncio.Future()
|
||||
mock_async_run.return_value.set_result(None)
|
||||
await handler.publish_backpressure_request(
|
||||
ScaleRequestV1("test_service", "test_task", 100, 50, ScalingRequestAlert.UPDATE)
|
||||
ScaleRequestV1("test_service", "test_task", 50)
|
||||
)
|
||||
assert mock_async_run.call_count == 1
|
||||
# handler.exchange.publish.assert_called_once()
|
||||
@@ -500,7 +376,7 @@ class TestBackpressureHandler:
|
||||
mock_exchange
|
||||
) # Make it return the mock exchange
|
||||
await handler.publish_backpressure_request(
|
||||
ScaleRequestV1("test_service", "test_task", 100, 50, ScalingRequestAlert.UPDATE)
|
||||
ScaleRequestV1("test_service", "test_task", 50)
|
||||
)
|
||||
assert mock_async_run.call_count == 2
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from amqp.model.model import (
|
||||
DataMessage,
|
||||
DataResponse,
|
||||
ScalingRequest,
|
||||
ScalingRequestAlert,
|
||||
)
|
||||
from amqp.model.snowflake_id import SnowflakeId
|
||||
|
||||
@@ -159,9 +158,7 @@ class TestScalingRequest(unittest.TestCase):
|
||||
self.request = ScalingRequest(
|
||||
service_id="test_service",
|
||||
task_id="test_task",
|
||||
max_availability=5,
|
||||
current_availability=3,
|
||||
request_type=ScalingRequestAlert.OVERLOAD,
|
||||
version=1,
|
||||
)
|
||||
|
||||
@@ -171,9 +168,7 @@ class TestScalingRequest(unittest.TestCase):
|
||||
|
||||
self.assertEqual(data["serviceId"], "test_service")
|
||||
self.assertEqual(data["taskId"], "test_task")
|
||||
self.assertEqual(data["maxAvailability"], 5)
|
||||
self.assertEqual(data["currentAvailability"], 3)
|
||||
self.assertEqual(data["requestType"], "OVERLOAD")
|
||||
self.assertEqual(data["version"], 1)
|
||||
|
||||
def test_from_json(self):
|
||||
@@ -181,9 +176,7 @@ class TestScalingRequest(unittest.TestCase):
|
||||
{
|
||||
"serviceId": "test_service",
|
||||
"taskId": "test_task",
|
||||
"maxAvailability": 5,
|
||||
"currentAvailability": 3,
|
||||
"requestType": "OVERLOAD",
|
||||
"version": 1,
|
||||
}
|
||||
)
|
||||
@@ -192,9 +185,7 @@ class TestScalingRequest(unittest.TestCase):
|
||||
|
||||
self.assertEqual(request.service_id, "test_service")
|
||||
self.assertEqual(request.task_id, "test_task")
|
||||
self.assertEqual(request.max_availability, 5)
|
||||
self.assertEqual(request.current_availability, 3)
|
||||
self.assertEqual(request.request_type, ScalingRequestAlert.OVERLOAD)
|
||||
self.assertEqual(request.version, 1)
|
||||
|
||||
|
||||
|
||||
@@ -13,10 +13,10 @@ class TestRabbitMQClient(IsolatedAsyncioTestCase):
|
||||
self.mock_config.dispatch.rabbit_mq_user = "user"
|
||||
self.mock_config.dispatch.rabbit_mq_password = "pass"
|
||||
self.mock_config.dispatch.use_dlq = False
|
||||
patcher = patch("amqp.rabbitmq.rabbit_mq_client.RouterConsumer")
|
||||
patcher = patch("amqp.router.router_consumer.RouterConsumer")
|
||||
self.addCleanup(patcher.stop)
|
||||
self.mock_router = patcher.start()
|
||||
self.client = RabbitMQClient(self.mock_config)
|
||||
self.client = RabbitMQClient(self.mock_config, self.mock_router)
|
||||
|
||||
async def test_init_sets_up_channel_and_reply_exchange(self):
|
||||
"""Test that init sets up the channel, reply queue, and reply exchange."""
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from amqp.config.amq_configuration import AMQAdapter, AMQConfiguration
|
||||
from amqp.model.model import DataMessage
|
||||
from amqp.service.amq_service import AMQService
|
||||
|
||||
|
||||
@@ -146,21 +145,21 @@ class TestAMQService(unittest.IsolatedAsyncioTestCase):
|
||||
# Verify that client was reinitialized
|
||||
mock_client.init.assert_called_once()
|
||||
|
||||
def test_send_message(self):
|
||||
# Create mock message and future
|
||||
mock_message = Mock(spec=DataMessage)
|
||||
mock_message.id.return_value = "test-id"
|
||||
mock_future = MagicMock()
|
||||
|
||||
# Setup mock message handler
|
||||
self.service.amq_data_message_handler = Mock()
|
||||
self.service.amq_data_message_handler.outstanding = {}
|
||||
|
||||
# Send message
|
||||
self.service.send_message(mock_message, mock_future)
|
||||
|
||||
# Verify message was added to outstanding
|
||||
self.assertEqual(self.service.amq_data_message_handler.outstanding["test-id"], mock_future)
|
||||
# def test_send_message(self):
|
||||
# # Create mock message and future
|
||||
# mock_message = Mock(spec=DataMessage)
|
||||
# mock_message.id.return_value = "test-id"
|
||||
# mock_future = MagicMock()
|
||||
#
|
||||
# # Setup mock message handler
|
||||
# self.service.amq_data_message_handler = Mock()
|
||||
# self.service.amq_data_message_handler.outstanding = {}
|
||||
#
|
||||
# # Send message
|
||||
# self.service.send_message(mock_message, mock_future)
|
||||
#
|
||||
# # Verify message was added to outstanding
|
||||
# self.assertEqual(self.service.amq_data_message_handler.outstanding["test-id"], mock_future)
|
||||
|
||||
def test_remove_outstanding_future(self):
|
||||
# Setup mock message handler with outstanding message
|
||||
|
||||
Reference in New Issue
Block a user