v0.1.1
All checks were successful
Build and Deploy Docker Image / build-and-push (push) Successful in 3m56s

-- added automativ queue reconenction (querying for queues to join from
server)
This commit is contained in:
2026-05-26 11:44:13 +03:00
parent 96548e60ea
commit fdeec1bf2e
8 changed files with 212 additions and 33 deletions

View File

@@ -0,0 +1,54 @@
name: Build and Deploy Docker Image
# Controls when the workflow will run. Here, it runs on every push to the 'main' branch.
on:
push:
branches: ["main"]
# Environment variables used across the workflow
env:
# The URL of your Gitea instance (without http:// or https://)
GITEA_INSTANCE_URL: git.deowl.ru
# The full name of your image (e.g., 'myusername/myproject')
IMAGE_NAME: vkrb/client
jobs:
build-and-push:
# Runs the job on a runner with the 'ubuntu-latest' label.
runs-on: ubuntu-latest
# Optional but recommended: specifies the container image to use for the job.
# This ensures a consistent environment with Docker tools pre-installed.
container:
image: catthehacker/ubuntu:act-latest
steps:
# 1. Check out your repository code so the workflow can access it.
- name: Checkout code
uses: actions/checkout@v4
# 2. Set up Docker Buildx, which is needed for building images.
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# 3. Log in to your Gitea instance's Container Registry.
# It uses secrets you must define in your repository settings.
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.GITEA_INSTANCE_URL }}
username: ${{ gitea.repository_owner }}
# Use a secret for the password/token. See Step 3 for setup.
password: ${{ secrets.REGISTRY_TOKEN }}
# 4. Build the Docker image from your 'dockerfile_build' directory
# and push it to the Gitea registry.
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
# The path to the directory containing your Dockerfile
context: ./Dockerfile_build
push: true
# Tag the image with the Gitea instance, image name, and the git commit SHA.
tags: |
${{ env.GITEA_INSTANCE_URL }}/${{ env.IMAGE_NAME }}:latest
${{ env.GITEA_INSTANCE_URL }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}

View File

@@ -5,7 +5,7 @@ import requests
# Global var from env # Global var from env
QUANTUM_BACKEND_URL = os.getenv( QUANTUM_BACKEND_URL = os.getenv(
"QUANTUM_BACKEND_URL", os.environ["QUNATUM_BACKEND_URL"] "QUANTUM_BACKEND_URL", os.environ["QUANTUM_BACKEND_URL"]
) )
@@ -25,19 +25,29 @@ def get_or_create_device_by_name(
Raises: Raises:
requests.RequestException: If the request fails requests.RequestException: If the request fails
""" """
url = f"{QUANTUM_BACKEND_URL}/machine/"
url = f"{QUANTUM_BACKEND_URL}/machine"
payload = {"system_name": system_name, "max_qubits": max_qubits} payload = {"system_name": system_name, "max_qubits": max_qubits}
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {access_token}", "Authorization": f"Bearer {access_token}",
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
"Referer": url,
"Host": "quantum-backend.deowl.ru",
} }
try: try:
response = requests.post(url, json=payload, headers=headers) response = requests.post(url, json=payload, headers=headers)
print(response.content)
print(response.headers)
print(headers)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
except requests.RequestException as e: except Exception as e:
print(f"Failed to get or create device: {e}")
raise Exception(f"Failed to get or create device: {e}") from e raise Exception(f"Failed to get or create device: {e}") from e
@@ -60,6 +70,11 @@ def get_device_by_id(system_id: int, access_token) -> Dict[str, Any]:
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {access_token}", "Authorization": f"Bearer {access_token}",
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
"Referer": url,
"Host": "quantum-backend.deowl.ru",
} }
try: try:
@@ -103,6 +118,11 @@ def update_device_data(
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {access_token}", "Authorization": f"Bearer {access_token}",
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
"Referer": url,
"Host": "quantum-backend.deowl.ru",
} }
try: try:

View File

@@ -56,6 +56,7 @@ class RabbitMQManager:
await self._consumer_channel.set_qos(prefetch_count=1, global_=True) await self._consumer_channel.set_qos(prefetch_count=1, global_=True)
print("Consumer channel created") print("Consumer channel created")
return self._consumer_channel return self._consumer_channel
return self._consumer_channel
return None return None
async def get_publisher_channel(self): async def get_publisher_channel(self):

View File

@@ -14,6 +14,7 @@ async def start_heartbeat():
while True: while True:
try: try:
await publish_heartbeat() await publish_heartbeat()
except Exception as e: except Exception as e:
print(f"Heartbeat failed: {e}") print(f"Heartbeat failed: {e}")
await asyncio.sleep(5) await asyncio.sleep(5)
@@ -24,6 +25,7 @@ async def start_consumer():
while True: while True:
try: try:
await consume_messages_topic() await consume_messages_topic()
await asyncio.Future()
except Exception as e: except Exception as e:
print(f"Consumer failed: {e}") print(f"Consumer failed: {e}")
await asyncio.sleep(5) await asyncio.sleep(5)

View File

@@ -1,3 +1,4 @@
import asyncio
import logging import logging
import os import os
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -40,6 +41,7 @@ from fasthtml.common import (
from fasthtml.pico import Card, Container, picolink from fasthtml.pico import Card, Container, picolink
from fasthtml.xtend import Style from fasthtml.xtend import Style
from keycloak.pkce_utils import generate_code_challenge, generate_code_verifier from keycloak.pkce_utils import generate_code_challenge, generate_code_verifier
from modules.rabbitmq import consume_messages_topic
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -363,7 +365,7 @@ async def dashboard():
Div( Div(
id="client-status", id="client-status",
hx_get="/client-status", hx_get="/client-status",
hx_trigger="load, every 30s", hx_trigger="load, every 15s",
), ),
), ),
Div(id="memory-stats", hx_get="/memory-stats", hx_trigger="load, every 5s"), Div(id="memory-stats", hx_get="/memory-stats", hx_trigger="load, every 5s"),
@@ -382,10 +384,10 @@ async def dashboard():
@app.route("/client-status", methods="get") @app.route("/client-status", methods="get")
async def status(): async def status():
"""Получить статус клиента из центрального микросервиса""" """Получить статус клиента из центрального микросервиса"""
token_data = load_token() access_token = get_valid_access_token()
client_info = load_client_info() client_info = load_client_info()
if not token_data or not client_info: if not access_token or not client_info:
return P("Клиент не зарегистрирован", style="color: orange;") return P("Клиент не зарегистрирован", style="color: orange;")
if rabbitmq_manager._connection: if rabbitmq_manager._connection:
@@ -395,6 +397,8 @@ async def status():
) # get_client_status(client_name, access_token) ) # get_client_status(client_name, access_token)
if status: if status:
await consume_messages_topic()
return Div( return Div(
P( P(
Strong("Статус клиента: "), Strong("Статус клиента: "),
@@ -413,6 +417,17 @@ async def status():
), ),
style="padding: 10px; border-radius: 5px; margin: 10px 0;", style="padding: 10px; border-radius: 5px; margin: 10px 0;",
) )
else:
return Div(
P(
Strong("Статус клиента: "),
Span("Не подключен", style="color: orange;"),
),
P(
"Не удалось связаться с центральным сервисом или данному клиенту отказано в подключении"
),
style="padding: 10px; border-radius: 5px; margin: 10px 0;",
)
@app.route("/memory-stats", methods="get") @app.route("/memory-stats", methods="get")

View File

@@ -16,7 +16,7 @@ from connections.quantum_backend import (
) )
from connections.rabbitmq import rabbitmq_manager from connections.rabbitmq import rabbitmq_manager
from fastcore.xtras import datetime from fastcore.xtras import datetime
from modules.vqe import prepare_data, run_vqe from modules.vqe import prepare_data, run_computation
from pennylane.devices import Device from pennylane.devices import Device
from uvicorn.main import logger from uvicorn.main import logger
@@ -155,7 +155,7 @@ def run_vqe_process(conn: Connection, dev: Device, data: dict):
"""Run VQE in a separate process - process exits when this function returns.""" """Run VQE in a separate process - process exits when this function returns."""
try: try:
# Run VQE # Run VQE
run_vqe(conn=conn, dev1=dev, data=data) run_computation(conn=conn, dev1=dev, data=data)
# Send sentinel to indicate completion # Send sentinel to indicate completion
conn.send(None) # Signal completion conn.send(None) # Signal completion
@@ -179,6 +179,10 @@ def run_vqe_process(conn: Connection, dev: Device, data: dict):
conn.close() conn.close()
# Global variable to track current subscriptions
_current_subscriptions = {} # Format: {queue_name: {"team_id": team_id, "qubits": qubits, "consumer": consumer, "queue": queue}}
async def consume_messages_topic(): async def consume_messages_topic():
""" """
Subscribe to team-specific qubit queues. Subscribe to team-specific qubit queues.
@@ -186,6 +190,8 @@ async def consume_messages_topic():
where N is the number of qubits this system can handle (1 to max_qubits). where N is the number of qubits this system can handle (1 to max_qubits).
This enables round-robin task distribution among systems with sufficient qubits. This enables round-robin task distribution among systems with sufficient qubits.
Dynamically manages subscriptions: adds new queues and removes obsolete ones.
""" """
channel = await rabbitmq_manager.get_consumer_channel() channel = await rabbitmq_manager.get_consumer_channel()
if not channel: if not channel:
@@ -199,36 +205,114 @@ async def consume_messages_topic():
access_token = get_valid_access_token() access_token = get_valid_access_token()
device_data = get_device_by_id(system_id, access_token) device_data = get_device_by_id(system_id, access_token)
teams = device_data["teams"] teams = device_data["teams"]
if not teams: if not teams:
print(f"System {system_id} is not part of any team. No queues to subscribe.") print(f"System {system_id} is not part of any team. No queues to subscribe.")
# Unsubscribe from all existing queues if no teams
await _unsubscribe_all()
return
# Subscribe to qubit-specific queues for each team # Build expected queues from current teams
subscription_count = 0 expected_queues = set()
for team in teams: for team in teams:
for qubits in range(1, team["num_qubits"] + 1): for qubits in range(1, team["num_qubits"] + 1):
# Queue name format: team_{team_id}.qubits_{qubits}
queue_name = f"team_{team['team']['team_id']}.qubits_{qubits}" queue_name = f"team_{team['team']['team_id']}.qubits_{qubits}"
expected_queues.add(queue_name)
# Declare the queue (durable, shared among multiple consumers) # Get current subscriptions
queue = await channel.declare_queue( current_queues = set(_current_subscriptions.keys())
queue_name,
durable=True,
arguments={
"x-max-priority": 100, # Allow priorities 0-10
},
)
# Start consuming from this queue # Queues to add (in expected but not current)
await queue.consume( queues_to_add = expected_queues - current_queues
process_message,
arguments={"x-priority": qubits, "x-priority-max": 10},
)
subscription_count += 1
print(f"System {system_id} subscribed to queue: {queue_name}") # Queues to remove (in current but not expected)
queues_to_remove = current_queues - expected_queues
print(f"System {system_id} subscribed to {subscription_count} queues") # Unsubscribe from queues that no longer exist
await asyncio.Future() # Keep running for queue_name in queues_to_remove:
subscription_info = _current_subscriptions[queue_name]
queue = subscription_info["queue"]
cons = subscription_info["consumer"]
try:
# Cancel the consumer directly on the consumer object
await queue.cancel(consumer_tag=cons)
print(f"System {system_id} unsubscribed from queue: {queue_name}")
del _current_subscriptions[queue_name]
except Exception as e:
print(f"Error unsubscribing from {queue_name}: {e}")
# Subscribe to new queues
for queue_name in queues_to_add:
# Extract qubits from queue name
qubits = int(queue_name.split(".qubits_")[1])
team_id = queue_name.split(".qubits_")[0].replace("team_", "")
# Declare the queue (durable, shared among multiple consumers)
queue = await channel.declare_queue(
queue_name,
durable=True,
arguments={
"x-max-priority": 100, # Allow priorities 0-10
},
)
# Start consuming from this queue
# In aio-pika, queue.consume returns a Consumer object
consumer = await queue.consume(
process_message,
arguments={"x-priority": qubits, "x-priority-max": 10},
)
# Store subscription info with consumer object
_current_subscriptions[queue_name] = {
"team_id": team_id,
"qubits": qubits,
"consumer": consumer,
"queue": queue,
}
print(f"System {system_id} subscribed to queue: {queue_name}")
# Print summary
if queues_to_add or queues_to_remove:
print(
f"System {system_id} subscription update - Active: {len(_current_subscriptions)} queues, "
f"Added: {len(queues_to_add)}, Removed: {len(queues_to_remove)}"
)
else:
print(
f"System {system_id} subscriptions unchanged - Active: {len(_current_subscriptions)} queues"
)
async def _unsubscribe_all():
"""
Helper function to unsubscribe from all queues.
"""
for queue_name, subscription_info in list(_current_subscriptions.items()):
consumer = subscription_info["consumer"]
try:
# Cancel the consumer directly on the consumer object
await consumer.cancel()
print(f"Unsubscribed from queue: {queue_name}")
del _current_subscriptions[queue_name]
except Exception as e:
print(f"Error unsubscribing from {queue_name}: {e}")
def get_current_subscriptions():
"""
Helper function to get current subscriptions for debugging.
"""
# Return a serializable version without the consumer and queue objects
return {
queue_name: {
"team_id": info["team_id"],
"qubits": info["qubits"],
"consumer_tag": info["consumer"].consumer_tag if info["consumer"] else None,
}
for queue_name, info in _current_subscriptions.items()
}
# --- Heartbeat Publisher Logic --- # --- Heartbeat Publisher Logic ---

View File

@@ -1,5 +1,6 @@
import os import os
from multiprocessing.connection import Connection from multiprocessing.connection import Connection
from typing import List
import jax import jax
import pennylane as qml import pennylane as qml
@@ -88,10 +89,11 @@ def prepare_data(data):
} }
def run_vqe(conn: Connection, dev1: Device, data: dict): def run_computation(conn: Connection, dev1: Device, data: dict):
coordinates = jnp.array(data.get("coordinates")) coordinates = jnp.array(data.get("coordinates"))
charge = int(data.get("charge")) charge = int(data.get("charge"))
multiplicity = int(data.get("multiplicity")) multiplicity = int(data.get("multiplicity"))
molecule = qchem.Molecule( molecule = qchem.Molecule(
data.get("symbols"), data.get("symbols"),
coordinates, coordinates,
@@ -115,7 +117,10 @@ def run_vqe(conn: Connection, dev1: Device, data: dict):
singles, doubles = qml.qchem.excitations(active_electrons, qubits) singles, doubles = qml.qchem.excitations(active_electrons, qubits)
params = np.array(np.zeros(len(singles) + len(doubles)), requires_grad=True) if False:
params = np.array(last_state, requires_grad=True)
else:
params = np.array(np.zeros(len(singles) + len(doubles)), requires_grad=True)
conn.send( conn.send(
{ {

View File

@@ -4,9 +4,7 @@ services:
image: git.deowl.ru/vkrb/client:0.1.0 image: git.deowl.ru/vkrb/client:0.1.0
container_name: quantum-client container_name: quantum-client
restart: unless-stopped restart: unless-stopped
extra_hosts:
- "auth.localhost:host-gateway"
- "host.docker.internal:host-gateway"
ports: ports:
- ${PORT}:${PORT} - ${PORT}:${PORT}
volumes: volumes: