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.
- boreal_push_queue-0.1.0/.idea/boreal-push-queue.iml +10 -0
- boreal_push_queue-0.1.0/.idea/inspectionProfiles/Project_Default.xml +50 -0
- boreal_push_queue-0.1.0/.idea/inspectionProfiles/profiles_settings.xml +6 -0
- boreal_push_queue-0.1.0/.idea/misc.xml +7 -0
- boreal_push_queue-0.1.0/.idea/modules.xml +8 -0
- boreal_push_queue-0.1.0/.idea/vcs.xml +6 -0
- boreal_push_queue-0.1.0/.idea/workspace.xml +68 -0
- boreal_push_queue-0.1.0/PKG-INFO +113 -0
- boreal_push_queue-0.1.0/README.md +104 -0
- boreal_push_queue-0.1.0/docs/architecture.md +35 -0
- boreal_push_queue-0.1.0/pyproject.toml +18 -0
- boreal_push_queue-0.1.0/src/boreal_push_queue/__init__.py +4 -0
- boreal_push_queue-0.1.0/src/boreal_push_queue/_envelope.py +61 -0
- boreal_push_queue-0.1.0/src/boreal_push_queue/client.py +119 -0
- boreal_push_queue-0.1.0/src/boreal_push_queue/config.py +63 -0
- boreal_push_queue-0.1.0/src/boreal_push_queue/exceptions.py +19 -0
- boreal_push_queue-0.1.0/src/boreal_push_queue/worker.py +293 -0
|
@@ -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,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,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,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()
|