boreal-push-queue 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <module type="PYTHON_MODULE" version="4">
3
+ <component name="NewModuleRootManager">
4
+ <content url="file://$MODULE_DIR$">
5
+ <excludeFolder url="file://$MODULE_DIR$/.venv" />
6
+ </content>
7
+ <orderEntry type="jdk" jdkName="Python 3.14 (boreal-push-queue)" jdkType="Python SDK" />
8
+ <orderEntry type="sourceFolder" forTests="false" />
9
+ </component>
10
+ </module>
@@ -0,0 +1,50 @@
1
+ <component name="InspectionProjectProfileManager">
2
+ <profile version="1.0">
3
+ <option name="myName" value="Project Default" />
4
+ <inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
5
+ <option name="ignoredPackages">
6
+ <list>
7
+ <option value="amqp" />
8
+ <option value="billiard" />
9
+ <option value="celery" />
10
+ <option value="certifi" />
11
+ <option value="charset-normalizer" />
12
+ <option value="click" />
13
+ <option value="click-didyoumean" />
14
+ <option value="click-plugins" />
15
+ <option value="click-repl" />
16
+ <option value="colorama" />
17
+ <option value="cron-descriptor" />
18
+ <option value="django-celery-beat" />
19
+ <option value="django-environ" />
20
+ <option value="django-fsm" />
21
+ <option value="django-redis" />
22
+ <option value="django-timezone-field" />
23
+ <option value="django-unfold" />
24
+ <option value="idna" />
25
+ <option value="kombu" />
26
+ <option value="prompt_toolkit" />
27
+ <option value="python-crontab" />
28
+ <option value="python-dateutil" />
29
+ <option value="redis" />
30
+ <option value="requests" />
31
+ <option value="six" />
32
+ <option value="tzdata" />
33
+ <option value="urllib3" />
34
+ <option value="vine" />
35
+ <option value="watchdog" />
36
+ <option value="wcwidth" />
37
+ <option value="django-admin-sso" />
38
+ <option value="django-cors-headers" />
39
+ <option value="psycopg2-binary" />
40
+ <option value="drf-yasg" />
41
+ <option value="beautifulsoup4" />
42
+ <option value="weasyprint" />
43
+ <option value="pika" />
44
+ <option value="django-filter" />
45
+ <option value="paho-mqtt" />
46
+ </list>
47
+ </option>
48
+ </inspection_tool>
49
+ </profile>
50
+ </component>
@@ -0,0 +1,6 @@
1
+ <component name="InspectionProjectProfileManager">
2
+ <settings>
3
+ <option name="USE_PROJECT_PROFILE" value="false" />
4
+ <version value="1.0" />
5
+ </settings>
6
+ </component>
@@ -0,0 +1,7 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="Black">
4
+ <option name="sdkName" value="Python 3.14 (boreal-push-queue)" />
5
+ </component>
6
+ <component name="ProjectRootManager" version="2" project-jdk-name="Python 3.14 (boreal-push-queue)" project-jdk-type="Python SDK" />
7
+ </project>
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="ProjectModuleManager">
4
+ <modules>
5
+ <module fileurl="file://$PROJECT_DIR$/.idea/boreal-push-queue.iml" filepath="$PROJECT_DIR$/.idea/boreal-push-queue.iml" />
6
+ </modules>
7
+ </component>
8
+ </project>
@@ -0,0 +1,6 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="VcsDirectoryMappings">
4
+ <mapping directory="$PROJECT_DIR$" vcs="Git" />
5
+ </component>
6
+ </project>
@@ -0,0 +1,68 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="AutoImportSettings">
4
+ <option name="autoReloadType" value="SELECTIVE" />
5
+ </component>
6
+ <component name="ChangeListManager">
7
+ <list default="true" id="b945df4d-0a45-4467-a660-0cc68c15cae1" name="Changes" comment="" />
8
+ <option name="SHOW_DIALOG" value="false" />
9
+ <option name="HIGHLIGHT_CONFLICTS" value="true" />
10
+ <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
11
+ <option name="LAST_RESOLUTION" value="IGNORE" />
12
+ </component>
13
+ <component name="FileTemplateManagerImpl">
14
+ <option name="RECENT_TEMPLATES">
15
+ <list>
16
+ <option value="Python Script" />
17
+ </list>
18
+ </option>
19
+ </component>
20
+ <component name="Git.Settings">
21
+ <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
22
+ </component>
23
+ <component name="ProjectColorInfo"><![CDATA[{
24
+ "associatedIndex": 5
25
+ }]]></component>
26
+ <component name="ProjectId" id="39MfYVIrUc8h6scLLSS6qrsWiLK" />
27
+ <component name="ProjectViewState">
28
+ <option name="hideEmptyMiddlePackages" value="true" />
29
+ <option name="showLibraryContents" value="true" />
30
+ </component>
31
+ <component name="PropertiesComponent"><![CDATA[{
32
+ "keyToString": {
33
+ "ModuleVcsDetector.initialDetectionPerformed": "true",
34
+ "RunOnceActivity.ShowReadmeOnStart": "true",
35
+ "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
36
+ "RunOnceActivity.git.unshallow": "true",
37
+ "git-widget-placeholder": "main",
38
+ "node.js.detected.package.eslint": "true",
39
+ "node.js.detected.package.tslint": "true",
40
+ "node.js.selected.package.eslint": "(autodetect)",
41
+ "node.js.selected.package.tslint": "(autodetect)",
42
+ "nodejs_package_manager_path": "npm",
43
+ "vue.rearranger.settings.migration": "true"
44
+ }
45
+ }]]></component>
46
+ <component name="SharedIndexes">
47
+ <attachedChunks>
48
+ <set>
49
+ <option value="bundled-js-predefined-d6986cc7102b-3aa1da707db6-JavaScript-PY-252.27397.106" />
50
+ <option value="bundled-python-sdk-4e2b1448bda8-9a97661f3031-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-252.27397.106" />
51
+ </set>
52
+ </attachedChunks>
53
+ </component>
54
+ <component name="TaskManager">
55
+ <task active="true" id="Default" summary="Default task">
56
+ <changelist id="b945df4d-0a45-4467-a660-0cc68c15cae1" name="Changes" comment="" />
57
+ <created>1770510908123</created>
58
+ <option name="number" value="Default" />
59
+ <option name="presentableId" value="Default" />
60
+ <updated>1770510908123</updated>
61
+ <workItem from="1770510909208" duration="1855000" />
62
+ </task>
63
+ <servers />
64
+ </component>
65
+ <component name="TypeScriptGeneratedFilesManager">
66
+ <option name="version" value="3" />
67
+ </component>
68
+ </project>
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: boreal-push-queue
3
+ Version: 0.1.0
4
+ Summary: RabbitMQ-based Push Queue client for HTTP task dispatching
5
+ Requires-Python: >=3.8
6
+ Requires-Dist: httpx>=0.24.0
7
+ Requires-Dist: pika>=1.3.0
8
+ Description-Content-Type: text/markdown
9
+
10
+ # Boreal Push Queue 🌲🐇
11
+
12
+ **Boreal Push Queue** é uma biblioteca Python que simula o comportamento de *Push Queues* do Google Cloud Tasks, utilizando o **RabbitMQ** como infraestrutura de mensageria.
13
+
14
+ A ideia é simples: você enfileira uma tarefa (Publisher) e um serviço (Worker) escuta essa fila, disparando automaticamente uma requisição HTTP para um endpoint pré-definido.
15
+
16
+ ## ✨ Funcionalidades
17
+
18
+ - **Abstração Simples**: Interface amigável para enfileirar tarefas.
19
+ - **Push Pattern**: O Worker converte mensagens da fila em chamadas HTTP (POST/GET).
20
+ - **Concorrência Controlada**: Define o limite de requisições simultâneas.
21
+ - **Configurável**: Suporte a diferentes métodos HTTP e cabeçalhos.
22
+
23
+ ## 🚀 Instalação
24
+
25
+ ```bash
26
+ pip install boreal-push-queue
27
+ ```
28
+
29
+ ## 🛠️ Como usar
30
+
31
+ ### 1. Enfileirando Tarefas (Publisher)
32
+
33
+ ```python
34
+ from boreal_push_queue import BorealPushQueue
35
+
36
+ # Inicializa a fila definindo o destino das tarefas
37
+ boreal_queue = BorealPushQueue(
38
+ queue_name="minha-fila-de-emails",
39
+ url="https://api.meuservico.com/v1/send-email",
40
+ method="POST",
41
+ max_requests=10,
42
+ )
43
+
44
+ # Define os dados da tarefa
45
+ task = {
46
+ "id": "12345",
47
+ "name": "Enviar Boas-vindas",
48
+ }
49
+
50
+ # Envia para o RabbitMQ
51
+ boreal_queue.add(task)
52
+ ```
53
+
54
+ ### 2. Executando o Worker
55
+
56
+ O Worker é responsável por ler as mensagens e disparar os gatilhos HTTP.
57
+
58
+ ```python
59
+ from boreal_push_queue import BorealWorker
60
+
61
+ worker = BorealWorker(queue_name="minha-fila-de-emails")
62
+ worker.start()
63
+ ```
64
+
65
+ Ou via CLI:
66
+
67
+ ```bash
68
+ boreal-worker --queue-name minha-fila-de-emails
69
+ ```
70
+
71
+ ## ⚙️ Configurações Necessárias
72
+
73
+ A biblioteca utiliza variáveis de ambiente para conexão e comportamento:
74
+
75
+ - `BOREAL_RABBITMQ_URL`: URL de conexão (Ex: `amqp://guest:guest@localhost:5672/`)
76
+ - `BOREAL_MAX_REQUESTS`: concorrência/prefetch do Worker (opcional; se não setado, pode ser inferido do `max_requests` enviado no envelope)
77
+ - `BOREAL_HTTP_TIMEOUT`: timeout HTTP em segundos (default: `10`)
78
+ - `BOREAL_REQUEUE_ON_FAILURE`: `true/false` (default: `true`)
79
+
80
+ ---
81
+
82
+ ## 🏗️ Arquitetura e Funcionamento (Documentação Interna)
83
+
84
+ ### Fluxo de Dados
85
+
86
+ 1. **Instanciação**: Ao criar o `BorealPushQueue`, os metadados do endpoint (URL, Método) são armazenados.
87
+ 2. **Produção (add)**: Quando `add(task)` é chamado, a biblioteca encapsula a tarefa junto com as informações de destino em um envelope JSON e envia para o RabbitMQ.
88
+ 3. **Persistência**: O RabbitMQ garante que a mensagem não seja perdida se o serviço de destino estiver fora do ar.
89
+ 4. **Consumo (Worker)**: O `BorealWorker` fica em loop (consumidor persistente). Ao receber uma mensagem:
90
+ - Extrai os dados da tarefa e a URL de destino.
91
+ - Executa a chamada HTTP com controle de vazão/concorrência via `prefetch_count`.
92
+ - Se o endpoint responder com sucesso (2xx), envia um `ACK` para o RabbitMQ remover a mensagem da fila.
93
+
94
+ ### Componentes Principais
95
+
96
+ #### `BorealPushQueue` (Client)
97
+
98
+ Responsável pela interface com o desenvolvedor. Ele não faz chamadas HTTP, apenas prepara a mensagem para que alguém a execute no futuro.
99
+
100
+ #### `BorealWorker` (Dispatcher)
101
+
102
+ É o motor de execução. Ele utiliza internamente um cliente HTTP (`httpx`) para transformar eventos de fila em requisições Web.
103
+
104
+ #### Gerenciamento de Falhas
105
+
106
+ Caso a requisição HTTP falhe (ex: erro 500 ou Timeout), a mensagem é **re-enfileirada** por padrão (`nack` com `requeue=True`), garantindo o modelo de entrega *at-least-once*.
107
+
108
+ ---
109
+
110
+ ## 📄 Licença
111
+
112
+ Distribuído sob a licença MIT.
113
+
@@ -0,0 +1,104 @@
1
+ # Boreal Push Queue 🌲🐇
2
+
3
+ **Boreal Push Queue** é uma biblioteca Python que simula o comportamento de *Push Queues* do Google Cloud Tasks, utilizando o **RabbitMQ** como infraestrutura de mensageria.
4
+
5
+ A ideia é simples: você enfileira uma tarefa (Publisher) e um serviço (Worker) escuta essa fila, disparando automaticamente uma requisição HTTP para um endpoint pré-definido.
6
+
7
+ ## ✨ Funcionalidades
8
+
9
+ - **Abstração Simples**: Interface amigável para enfileirar tarefas.
10
+ - **Push Pattern**: O Worker converte mensagens da fila em chamadas HTTP (POST/GET).
11
+ - **Concorrência Controlada**: Define o limite de requisições simultâneas.
12
+ - **Configurável**: Suporte a diferentes métodos HTTP e cabeçalhos.
13
+
14
+ ## 🚀 Instalação
15
+
16
+ ```bash
17
+ pip install boreal-push-queue
18
+ ```
19
+
20
+ ## 🛠️ Como usar
21
+
22
+ ### 1. Enfileirando Tarefas (Publisher)
23
+
24
+ ```python
25
+ from boreal_push_queue import BorealPushQueue
26
+
27
+ # Inicializa a fila definindo o destino das tarefas
28
+ boreal_queue = BorealPushQueue(
29
+ queue_name="minha-fila-de-emails",
30
+ url="https://api.meuservico.com/v1/send-email",
31
+ method="POST",
32
+ max_requests=10,
33
+ )
34
+
35
+ # Define os dados da tarefa
36
+ task = {
37
+ "id": "12345",
38
+ "name": "Enviar Boas-vindas",
39
+ }
40
+
41
+ # Envia para o RabbitMQ
42
+ boreal_queue.add(task)
43
+ ```
44
+
45
+ ### 2. Executando o Worker
46
+
47
+ O Worker é responsável por ler as mensagens e disparar os gatilhos HTTP.
48
+
49
+ ```python
50
+ from boreal_push_queue import BorealWorker
51
+
52
+ worker = BorealWorker(queue_name="minha-fila-de-emails")
53
+ worker.start()
54
+ ```
55
+
56
+ Ou via CLI:
57
+
58
+ ```bash
59
+ boreal-worker --queue-name minha-fila-de-emails
60
+ ```
61
+
62
+ ## ⚙️ Configurações Necessárias
63
+
64
+ A biblioteca utiliza variáveis de ambiente para conexão e comportamento:
65
+
66
+ - `BOREAL_RABBITMQ_URL`: URL de conexão (Ex: `amqp://guest:guest@localhost:5672/`)
67
+ - `BOREAL_MAX_REQUESTS`: concorrência/prefetch do Worker (opcional; se não setado, pode ser inferido do `max_requests` enviado no envelope)
68
+ - `BOREAL_HTTP_TIMEOUT`: timeout HTTP em segundos (default: `10`)
69
+ - `BOREAL_REQUEUE_ON_FAILURE`: `true/false` (default: `true`)
70
+
71
+ ---
72
+
73
+ ## 🏗️ Arquitetura e Funcionamento (Documentação Interna)
74
+
75
+ ### Fluxo de Dados
76
+
77
+ 1. **Instanciação**: Ao criar o `BorealPushQueue`, os metadados do endpoint (URL, Método) são armazenados.
78
+ 2. **Produção (add)**: Quando `add(task)` é chamado, a biblioteca encapsula a tarefa junto com as informações de destino em um envelope JSON e envia para o RabbitMQ.
79
+ 3. **Persistência**: O RabbitMQ garante que a mensagem não seja perdida se o serviço de destino estiver fora do ar.
80
+ 4. **Consumo (Worker)**: O `BorealWorker` fica em loop (consumidor persistente). Ao receber uma mensagem:
81
+ - Extrai os dados da tarefa e a URL de destino.
82
+ - Executa a chamada HTTP com controle de vazão/concorrência via `prefetch_count`.
83
+ - Se o endpoint responder com sucesso (2xx), envia um `ACK` para o RabbitMQ remover a mensagem da fila.
84
+
85
+ ### Componentes Principais
86
+
87
+ #### `BorealPushQueue` (Client)
88
+
89
+ Responsável pela interface com o desenvolvedor. Ele não faz chamadas HTTP, apenas prepara a mensagem para que alguém a execute no futuro.
90
+
91
+ #### `BorealWorker` (Dispatcher)
92
+
93
+ É o motor de execução. Ele utiliza internamente um cliente HTTP (`httpx`) para transformar eventos de fila em requisições Web.
94
+
95
+ #### Gerenciamento de Falhas
96
+
97
+ Caso a requisição HTTP falhe (ex: erro 500 ou Timeout), a mensagem é **re-enfileirada** por padrão (`nack` com `requeue=True`), garantindo o modelo de entrega *at-least-once*.
98
+
99
+ ---
100
+
101
+ ## 📄 Licença
102
+
103
+ Distribuído sob a licença MIT.
104
+
@@ -0,0 +1,35 @@
1
+ # Boreal Push Queue — Architecture (Deep Dive)
2
+
3
+ ## Filosofia do Projeto
4
+
5
+ O projeto resolve o problema de **Worker Bloqueante**. Em sistemas tradicionais, o worker precisa ter o código da lógica de negócio. No **Boreal Push Queue**, o worker é **genérico**: ele apenas recebe uma instrução de **onde bater** (URL) e **o que levar** (payload).
6
+
7
+ ## Envelope da Mensagem
8
+
9
+ Quando você faz `boreal_queue.add(task)`, a mensagem enviada ao RabbitMQ não é apenas o dicionário `task`. A lib cria um envelope JSON com metadados e payload:
10
+
11
+ ```json
12
+ {
13
+ "metadata": {
14
+ "target_url": "https://api.servico.com/endpoint",
15
+ "method": "POST",
16
+ "max_requests": 10,
17
+ "timestamp": "2023-10-27T10:00:00Z"
18
+ },
19
+ "payload": {
20
+ "id": 123,
21
+ "name": "minha-task"
22
+ }
23
+ }
24
+ ```
25
+
26
+ ## Por que isso é bom?
27
+
28
+ 1. **Desacoplamento Total**: o serviço que envia a tarefa não precisa que o Worker seja atualizado toda vez que um novo endpoint for criado.
29
+ 2. **Escalabilidade**: você pode subir múltiplas instâncias do `BorealWorker` para processar a mesma fila.
30
+ 3. **Simulação de Serverless**: funciona de forma similar ao *Google Cloud Tasks* (push) — o receptor é apenas uma API REST comum.
31
+
32
+ ## Controle de Fluxo (QoS)
33
+
34
+ O `max_requests` define o `basic_qos(prefetch_count=X)` no RabbitMQ, limitando quantas mensagens podem ficar pendentes de ACK ao mesmo tempo, evitando que o Worker tente processar um volume muito alto de requisições simultâneas.
35
+
@@ -0,0 +1,18 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "boreal-push-queue"
7
+ version = "0.1.0"
8
+ description = "RabbitMQ-based Push Queue client for HTTP task dispatching"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ dependencies = [
12
+ "pika>=1.3.0",
13
+ "httpx>=0.24.0",
14
+ ]
15
+
16
+ [project.scripts]
17
+ # Isso permite que o usuário rode o worker via terminal se desejar
18
+ boreal-worker = "boreal_push_queue.worker:main"
@@ -0,0 +1,4 @@
1
+ from .client import BorealPushQueue
2
+ from .worker import BorealWorker
3
+
4
+ __all__ = ["BorealPushQueue", "BorealWorker"]
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime, timezone
5
+ from typing import Any, Dict, Optional
6
+
7
+ from .exceptions import BorealSerializationError
8
+
9
+
10
+ def utc_now_iso() -> str:
11
+ return (
12
+ datetime.now(timezone.utc)
13
+ .replace(microsecond=0)
14
+ .isoformat()
15
+ .replace("+00:00", "Z")
16
+ )
17
+
18
+
19
+ def build_envelope(
20
+ payload: Any,
21
+ *,
22
+ target_url: str,
23
+ method: str,
24
+ headers: Optional[Dict[str, str]] = None,
25
+ max_requests: Optional[int] = None,
26
+ timestamp: Optional[str] = None,
27
+ ) -> Dict[str, Any]:
28
+ metadata: Dict[str, Any] = {
29
+ "target_url": target_url,
30
+ "method": method.upper(),
31
+ "timestamp": timestamp or utc_now_iso(),
32
+ }
33
+ if headers:
34
+ metadata["headers"] = headers
35
+ if max_requests is not None:
36
+ metadata["max_requests"] = int(max_requests)
37
+ return {"metadata": metadata, "payload": payload}
38
+
39
+
40
+ def dumps_envelope(envelope: Dict[str, Any]) -> bytes:
41
+ try:
42
+ return json.dumps(envelope, ensure_ascii=False).encode("utf-8")
43
+ except (TypeError, ValueError) as exc:
44
+ raise BorealSerializationError("Failed to serialize task envelope to JSON.") from exc
45
+
46
+
47
+ def loads_envelope(body: bytes) -> Dict[str, Any]:
48
+ try:
49
+ decoded = body.decode("utf-8")
50
+ data = json.loads(decoded)
51
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
52
+ raise BorealSerializationError("Failed to decode task envelope JSON.") from exc
53
+
54
+ if not isinstance(data, dict):
55
+ raise BorealSerializationError("Task envelope must be a JSON object.")
56
+ if "metadata" not in data or "payload" not in data:
57
+ raise BorealSerializationError("Task envelope must contain 'metadata' and 'payload'.")
58
+ if not isinstance(data.get("metadata"), dict):
59
+ raise BorealSerializationError("'metadata' must be a JSON object.")
60
+ return data
61
+
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Any, Dict, Optional
5
+
6
+ import pika
7
+
8
+ from ._envelope import build_envelope, dumps_envelope
9
+ from .config import get_rabbitmq_url
10
+ from .exceptions import BorealRabbitMQError
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def _normalize_headers(headers: Optional[Dict[str, Any]]) -> Optional[Dict[str, str]]:
16
+ if not headers:
17
+ return None
18
+ normalized: Dict[str, str] = {}
19
+ for key, value in headers.items():
20
+ normalized[str(key)] = str(value)
21
+ return normalized
22
+
23
+
24
+ class BorealPushQueue:
25
+ """
26
+ Publisher: enfileira tarefas para serem executadas no futuro por um Worker.
27
+
28
+ A mensagem enviada ao RabbitMQ é um envelope JSON contendo:
29
+ - metadata: target_url, method, headers, max_requests, timestamp
30
+ - payload: o dicionário/objeto da tarefa
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ *,
36
+ queue_name: str,
37
+ url: str,
38
+ method: str = "POST",
39
+ max_requests: int = 10,
40
+ headers: Optional[Dict[str, Any]] = None,
41
+ rabbitmq_url: Optional[str] = None,
42
+ durable: bool = True,
43
+ ) -> None:
44
+ self.queue_name = queue_name
45
+ self.url = url
46
+ self.method = method.upper()
47
+ self.max_requests = int(max_requests)
48
+ self.headers = _normalize_headers(headers) or {}
49
+ self.rabbitmq_url = get_rabbitmq_url(rabbitmq_url)
50
+ self.durable = durable
51
+
52
+ self._connection: Optional[pika.BlockingConnection] = None
53
+ self._channel: Optional[pika.adapters.blocking_connection.BlockingChannel] = None
54
+
55
+ def _ensure_channel(self) -> pika.adapters.blocking_connection.BlockingChannel:
56
+ if self._connection and self._connection.is_open and self._channel and self._channel.is_open:
57
+ return self._channel
58
+ try:
59
+ params = pika.URLParameters(self.rabbitmq_url)
60
+ self._connection = pika.BlockingConnection(params)
61
+ self._channel = self._connection.channel()
62
+ self._channel.queue_declare(queue=self.queue_name, durable=self.durable)
63
+ return self._channel
64
+ except Exception as exc:
65
+ raise BorealRabbitMQError("Failed to connect/declare queue in RabbitMQ.") from exc
66
+
67
+ def add(
68
+ self,
69
+ payload: Any,
70
+ *,
71
+ url: Optional[str] = None,
72
+ method: Optional[str] = None,
73
+ headers: Optional[Dict[str, Any]] = None,
74
+ max_requests: Optional[int] = None,
75
+ ) -> None:
76
+ channel = self._ensure_channel()
77
+ merged_headers = dict(self.headers)
78
+ merged_headers.update(_normalize_headers(headers) or {})
79
+
80
+ envelope = build_envelope(
81
+ payload,
82
+ target_url=url or self.url,
83
+ method=(method or self.method),
84
+ headers=merged_headers or None,
85
+ max_requests=max_requests if max_requests is not None else self.max_requests,
86
+ )
87
+ body = dumps_envelope(envelope)
88
+
89
+ properties = pika.BasicProperties(
90
+ content_type="application/json",
91
+ delivery_mode=2, # persistent
92
+ )
93
+
94
+ try:
95
+ channel.basic_publish(
96
+ exchange="",
97
+ routing_key=self.queue_name,
98
+ body=body,
99
+ properties=properties,
100
+ )
101
+ except Exception as exc:
102
+ raise BorealRabbitMQError("Failed to publish message to RabbitMQ.") from exc
103
+
104
+ def close(self) -> None:
105
+ try:
106
+ if self._connection and self._connection.is_open:
107
+ self._connection.close()
108
+ except Exception:
109
+ logger.exception("Failed to close RabbitMQ connection.")
110
+ finally:
111
+ self._channel = None
112
+ self._connection = None
113
+
114
+ def __enter__(self) -> "BorealPushQueue":
115
+ return self
116
+
117
+ def __exit__(self, exc_type, exc, tb) -> None:
118
+ self.close()
119
+
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Optional
5
+
6
+ from .exceptions import BorealConfigError
7
+
8
+ ENV_RABBITMQ_URL = "BOREAL_RABBITMQ_URL"
9
+ ENV_QUEUE_NAME = "BOREAL_QUEUE_NAME"
10
+ ENV_MAX_REQUESTS = "BOREAL_MAX_REQUESTS"
11
+ ENV_HTTP_TIMEOUT = "BOREAL_HTTP_TIMEOUT"
12
+ ENV_REQUEUE_ON_FAILURE = "BOREAL_REQUEUE_ON_FAILURE"
13
+
14
+ DEFAULT_RABBITMQ_URL = "amqp://guest:guest@localhost:5672/"
15
+ DEFAULT_MAX_REQUESTS = 10
16
+ DEFAULT_HTTP_TIMEOUT = 10.0
17
+ DEFAULT_REQUEUE_ON_FAILURE = True
18
+
19
+
20
+ def get_rabbitmq_url(explicit: Optional[str] = None) -> str:
21
+ return explicit or os.getenv(ENV_RABBITMQ_URL) or DEFAULT_RABBITMQ_URL
22
+
23
+
24
+ def get_queue_name(explicit: Optional[str] = None) -> str:
25
+ value = explicit or os.getenv(ENV_QUEUE_NAME)
26
+ if not value:
27
+ raise BorealConfigError(
28
+ f"Queue name is required (pass queue_name or set {ENV_QUEUE_NAME})."
29
+ )
30
+ return value
31
+
32
+
33
+ def get_int_env(name: str, default: int) -> int:
34
+ raw = os.getenv(name)
35
+ if raw is None or raw == "":
36
+ return default
37
+ try:
38
+ return int(raw)
39
+ except ValueError as exc:
40
+ raise BorealConfigError(f"Invalid int for {name}: {raw!r}") from exc
41
+
42
+
43
+ def get_float_env(name: str, default: float) -> float:
44
+ raw = os.getenv(name)
45
+ if raw is None or raw == "":
46
+ return default
47
+ try:
48
+ return float(raw)
49
+ except ValueError as exc:
50
+ raise BorealConfigError(f"Invalid float for {name}: {raw!r}") from exc
51
+
52
+
53
+ def get_bool_env(name: str, default: bool) -> bool:
54
+ raw = os.getenv(name)
55
+ if raw is None or raw == "":
56
+ return default
57
+ raw = raw.strip().lower()
58
+ if raw in {"1", "true", "t", "yes", "y", "on"}:
59
+ return True
60
+ if raw in {"0", "false", "f", "no", "n", "off"}:
61
+ return False
62
+ raise BorealConfigError(f"Invalid boolean for {name}: {raw!r}")
63
+
@@ -0,0 +1,19 @@
1
+ class BorealError(Exception):
2
+ """Base exception for boreal-push-queue."""
3
+
4
+
5
+ class BorealConfigError(BorealError):
6
+ """Raised when configuration is missing or invalid."""
7
+
8
+
9
+ class BorealSerializationError(BorealError):
10
+ """Raised when a message cannot be serialized/deserialized."""
11
+
12
+
13
+ class BorealRabbitMQError(BorealError):
14
+ """Raised when RabbitMQ interaction fails."""
15
+
16
+
17
+ class BorealHTTPError(BorealError):
18
+ """Raised when an HTTP dispatch fails."""
19
+
@@ -0,0 +1,293 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+ import os
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ import httpx
10
+ import pika
11
+
12
+ from ._envelope import loads_envelope
13
+ from .config import (
14
+ DEFAULT_HTTP_TIMEOUT,
15
+ DEFAULT_MAX_REQUESTS,
16
+ DEFAULT_REQUEUE_ON_FAILURE,
17
+ ENV_HTTP_TIMEOUT,
18
+ ENV_MAX_REQUESTS,
19
+ ENV_QUEUE_NAME,
20
+ ENV_RABBITMQ_URL,
21
+ ENV_REQUEUE_ON_FAILURE,
22
+ get_bool_env,
23
+ get_float_env,
24
+ get_int_env,
25
+ get_queue_name,
26
+ get_rabbitmq_url,
27
+ )
28
+ from .exceptions import BorealRabbitMQError, BorealSerializationError
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ def _coerce_int(value: Any) -> Optional[int]:
34
+ if value is None:
35
+ return None
36
+ try:
37
+ return int(value)
38
+ except (TypeError, ValueError):
39
+ return None
40
+
41
+
42
+ class BorealWorker:
43
+ """
44
+ Consumer/Dispatcher: consome mensagens do RabbitMQ e dispara requisições HTTP.
45
+
46
+ - QoS (`prefetch_count`) limita quantas mensagens ficam "in-flight" sem ACK.
47
+ - Concorrência é controlada por `max_requests` (threads).
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ *,
53
+ queue_name: str,
54
+ rabbitmq_url: Optional[str] = None,
55
+ max_requests: Optional[int] = None,
56
+ http_timeout: Optional[float] = None,
57
+ requeue_on_failure: Optional[bool] = None,
58
+ durable: bool = True,
59
+ ) -> None:
60
+ self.queue_name = queue_name
61
+ self.rabbitmq_url = get_rabbitmq_url(rabbitmq_url)
62
+ self._max_requests_configured = max_requests
63
+ self.http_timeout = (
64
+ float(http_timeout) if http_timeout is not None else get_float_env(ENV_HTTP_TIMEOUT, DEFAULT_HTTP_TIMEOUT)
65
+ )
66
+ self.requeue_on_failure = (
67
+ bool(requeue_on_failure)
68
+ if requeue_on_failure is not None
69
+ else get_bool_env(ENV_REQUEUE_ON_FAILURE, DEFAULT_REQUEUE_ON_FAILURE)
70
+ )
71
+ self.durable = durable
72
+
73
+ self._connection: Optional[pika.BlockingConnection] = None
74
+ self._channel: Optional[pika.adapters.blocking_connection.BlockingChannel] = None
75
+ self._executor: Optional[ThreadPoolExecutor] = None
76
+ self._http: Optional[httpx.Client] = None
77
+ self._max_requests_effective: Optional[int] = None
78
+
79
+ def _set_concurrency(self, max_requests: int) -> None:
80
+ if max_requests < 1:
81
+ max_requests = 1
82
+ if self._max_requests_effective == max_requests and self._executor is not None:
83
+ return
84
+ if self._executor is not None and self._max_requests_effective is not None:
85
+ logger.warning(
86
+ "Ignoring max_requests change from %s to %s (dynamic resizing not supported).",
87
+ self._max_requests_effective,
88
+ max_requests,
89
+ )
90
+ return
91
+
92
+ if self._channel is None:
93
+ raise BorealRabbitMQError("Worker channel is not initialized.")
94
+
95
+ self._channel.basic_qos(prefetch_count=max_requests)
96
+ self._executor = ThreadPoolExecutor(max_workers=max_requests)
97
+ self._max_requests_effective = max_requests
98
+
99
+ def _ensure_runtime(self) -> None:
100
+ if self._connection and self._connection.is_open and self._channel and self._channel.is_open:
101
+ return
102
+ try:
103
+ params = pika.URLParameters(self.rabbitmq_url)
104
+ self._connection = pika.BlockingConnection(params)
105
+ self._channel = self._connection.channel()
106
+ self._channel.queue_declare(queue=self.queue_name, durable=self.durable)
107
+ except Exception as exc:
108
+ raise BorealRabbitMQError("Failed to connect/declare queue in RabbitMQ.") from exc
109
+
110
+ self._http = httpx.Client(timeout=self.http_timeout)
111
+
112
+ if self._max_requests_configured is not None:
113
+ self._set_concurrency(int(self._max_requests_configured))
114
+ return
115
+
116
+ if os.getenv(ENV_MAX_REQUESTS) not in (None, ""):
117
+ self._set_concurrency(get_int_env(ENV_MAX_REQUESTS, DEFAULT_MAX_REQUESTS))
118
+ return
119
+
120
+ # Infer later from message metadata; start safe.
121
+ self._channel.basic_qos(prefetch_count=1)
122
+
123
+ def _schedule_ack(self, delivery_tag: int) -> None:
124
+ assert self._connection is not None
125
+ assert self._channel is not None
126
+
127
+ def _ack() -> None:
128
+ if self._channel and self._channel.is_open:
129
+ self._channel.basic_ack(delivery_tag=delivery_tag)
130
+
131
+ self._connection.add_callback_threadsafe(_ack)
132
+
133
+ def _schedule_nack(self, delivery_tag: int, *, requeue: bool) -> None:
134
+ assert self._connection is not None
135
+ assert self._channel is not None
136
+
137
+ def _nack() -> None:
138
+ if self._channel and self._channel.is_open:
139
+ self._channel.basic_nack(delivery_tag=delivery_tag, requeue=requeue)
140
+
141
+ self._connection.add_callback_threadsafe(_nack)
142
+
143
+ def _dispatch_http(self, delivery_tag: int, envelope: Dict[str, Any]) -> None:
144
+ assert self._http is not None
145
+
146
+ metadata = envelope.get("metadata") or {}
147
+ payload = envelope.get("payload")
148
+
149
+ target_url = metadata.get("target_url")
150
+ http_method = str(metadata.get("method") or "POST").upper()
151
+ headers = metadata.get("headers") or {}
152
+
153
+ if not target_url:
154
+ logger.error("Dropping message without metadata.target_url.")
155
+ self._schedule_ack(delivery_tag)
156
+ return
157
+
158
+ request_kwargs: Dict[str, Any] = {"headers": {str(k): str(v) for k, v in headers.items()}}
159
+ if http_method == "GET":
160
+ if isinstance(payload, dict):
161
+ request_kwargs["params"] = payload
162
+ else:
163
+ request_kwargs["json"] = payload
164
+
165
+ try:
166
+ response = self._http.request(http_method, str(target_url), **request_kwargs)
167
+ except Exception:
168
+ logger.exception("HTTP dispatch failed (exception).")
169
+ if self.requeue_on_failure:
170
+ self._schedule_nack(delivery_tag, requeue=True)
171
+ else:
172
+ self._schedule_ack(delivery_tag)
173
+ return
174
+
175
+ if 200 <= response.status_code < 300:
176
+ self._schedule_ack(delivery_tag)
177
+ return
178
+
179
+ logger.warning(
180
+ "HTTP dispatch failed (status=%s).",
181
+ response.status_code,
182
+ )
183
+ if self.requeue_on_failure:
184
+ self._schedule_nack(delivery_tag, requeue=True)
185
+ else:
186
+ self._schedule_ack(delivery_tag)
187
+
188
+ def _on_message(self, ch, method, properties, body: bytes) -> None: # noqa: ANN001
189
+ delivery_tag = method.delivery_tag
190
+
191
+ try:
192
+ envelope = loads_envelope(body)
193
+ except BorealSerializationError:
194
+ logger.exception("Dropping message that is not a valid Boreal envelope JSON.")
195
+ ch.basic_ack(delivery_tag=delivery_tag)
196
+ return
197
+
198
+ if self._executor is None:
199
+ message_max = _coerce_int((envelope.get("metadata") or {}).get("max_requests"))
200
+ self._set_concurrency(message_max or DEFAULT_MAX_REQUESTS)
201
+
202
+ assert self._executor is not None
203
+ self._executor.submit(self._dispatch_http, delivery_tag, envelope)
204
+
205
+ def start(self) -> None:
206
+ self._ensure_runtime()
207
+ assert self._channel is not None
208
+
209
+ self._channel.basic_consume(queue=self.queue_name, on_message_callback=self._on_message, auto_ack=False)
210
+ try:
211
+ logger.info("Worker started. Consuming queue=%s", self.queue_name)
212
+ self._channel.start_consuming()
213
+ except KeyboardInterrupt:
214
+ logger.info("Worker stopping (KeyboardInterrupt).")
215
+ finally:
216
+ self.stop()
217
+
218
+ def stop(self) -> None:
219
+ try:
220
+ if self._channel and self._channel.is_open:
221
+ try:
222
+ self._channel.stop_consuming()
223
+ except Exception:
224
+ pass
225
+ finally:
226
+ if self._executor is not None:
227
+ self._executor.shutdown(wait=True)
228
+ self._executor = None
229
+
230
+ if self._http is not None:
231
+ self._http.close()
232
+ self._http = None
233
+
234
+ if self._connection and self._connection.is_open:
235
+ try:
236
+ self._connection.close()
237
+ except Exception:
238
+ logger.exception("Failed to close RabbitMQ connection.")
239
+ self._channel = None
240
+ self._connection = None
241
+
242
+
243
+ def main(argv: Optional[List[str]] = None) -> None:
244
+ parser = argparse.ArgumentParser(prog="boreal-worker", description="Consume a Boreal Push Queue and dispatch HTTP.")
245
+ parser.add_argument("--queue-name", default=os.getenv(ENV_QUEUE_NAME), help=f"Queue name (env: {ENV_QUEUE_NAME}).")
246
+ parser.add_argument(
247
+ "--rabbitmq-url",
248
+ default=os.getenv(ENV_RABBITMQ_URL),
249
+ help=f"RabbitMQ URL (env: {ENV_RABBITMQ_URL}).",
250
+ )
251
+ parser.add_argument(
252
+ "--max-requests",
253
+ type=int,
254
+ default=None,
255
+ help=f"Concurrency/prefetch (env: {ENV_MAX_REQUESTS} or message metadata.max_requests).",
256
+ )
257
+ parser.add_argument(
258
+ "--http-timeout",
259
+ type=float,
260
+ default=None,
261
+ help=f"HTTP timeout seconds (env: {ENV_HTTP_TIMEOUT}).",
262
+ )
263
+ requeue_group = parser.add_mutually_exclusive_group()
264
+ requeue_group.add_argument(
265
+ "--requeue-on-failure",
266
+ dest="requeue_on_failure",
267
+ action="store_true",
268
+ help=f"Requeue message on HTTP failure (env: {ENV_REQUEUE_ON_FAILURE}).",
269
+ )
270
+ requeue_group.add_argument(
271
+ "--no-requeue-on-failure",
272
+ dest="requeue_on_failure",
273
+ action="store_false",
274
+ help="Do not requeue on HTTP failure (ACK and drop).",
275
+ )
276
+ parser.set_defaults(requeue_on_failure=None)
277
+ parser.add_argument("--log-level", default="INFO", help="Logging level (e.g. DEBUG, INFO, WARNING).")
278
+
279
+ args = parser.parse_args(argv)
280
+
281
+ logging.basicConfig(level=getattr(logging, str(args.log_level).upper(), logging.INFO))
282
+
283
+ queue_name = get_queue_name(args.queue_name)
284
+ rabbitmq_url = get_rabbitmq_url(args.rabbitmq_url)
285
+
286
+ worker = BorealWorker(
287
+ queue_name=queue_name,
288
+ rabbitmq_url=rabbitmq_url,
289
+ max_requests=args.max_requests,
290
+ http_timeout=args.http_timeout,
291
+ requeue_on_failure=args.requeue_on_failure,
292
+ )
293
+ worker.start()