Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 20 additions & 18 deletions docker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ NGROK_AUTHTOKEN=your-ngrok-authtoken
MONGODB_HOST=controller-db
MONGODB_PORT=27017
MONGODB_NAME=oidccontroller
# Must match mongo/mongo-init.js
OIDC_CONTROLLER_DB_USER=oidccontrolleruser
OIDC_CONTROLLER_DB_USER_PWD=oidccontrollerpass

Expand All @@ -22,11 +23,11 @@ OIDC_CONTROLLER_DB_USER_PWD=oidccontrollerpass
############################################
CONTROLLER_SERVICE_PORT=5000

# Public URLs - CONTROLLER_URL set by ngrok for external access
# CONTROLLER_URL is for external clients (wallets, browsers)
# CONTROLLER_WEB_HOOK_URL is for ACA-Py to call back (internal Docker network)
# CONTROLLER_URL=
CONTROLLER_WEB_HOOK_URL=http://controller-lb:80/webhooks
# Public URLs
# Set automatically by manage script from ngrok for local dev
# CONTROLLER_URL=https://your-public-url.example.com
# Internal Docker network URL for aca-py to reach controller (nginx listens on 80)
CONTROLLER_WEB_HOOK_URL=http://controller-lb/webhooks
CONTROLLER_API_KEY=

# Controller Behavior
Expand Down Expand Up @@ -71,19 +72,17 @@ AGENT_NAME="VC-AuthN Agent"
AGENT_HTTP_PORT=8030
AGENT_ADMIN_PORT=8077

AGENT_HOST=aca-py
AGENT_NAME="VC-AuthN Agent"
# ACA-Py admin endpoint - use Docker container name for local single-tenant mode
AGENT_ADMIN_URL=http://aca-py:8077

AGENT_HTTP_PORT=8030
AGENT_ADMIN_PORT=8077
# Agent public endpoint - leave commented for local dev (manage script sets from ngrok)
# AGENT_ENDPOINT=https://your-agent-endpoint.example.com

# Commented out for local dev - ngrok will set these dynamically
# AGENT_ADMIN_URL=
# AGENT_ENDPOINT=
AGENT_ADMIN_API_KEY=changeme
AGENT_GENESIS_URL=https://test.bcovrin.vonx.io/genesis
# Must be exactly 32 characters
AGENT_WALLET_SEED=vc-authn-oidc-dev-seed-000000000

AGENT_ADMIN_API_KEY=
AGENT_GENESIS_URL=http://test.bcovrin.vonx.io/genesis
AGENT_WALLET_SEED=vc_authn_agent_00000000000000000

########################################################
# ACA-Py Wallet / Tenant Identity
Expand All @@ -96,10 +95,12 @@ AGENT_WALLET_SEED=vc_authn_agent_00000000000000000
# ACAPY_TENANT_WALLET_ID = Traction Tenant ID
# ACAPY_TENANT_WALLET_KEY = Traction Tenant API Key
########################################################
# Use 'single' for local dev, 'traction' or 'multi' for hosted agents
AGENT_TENANT_MODE=single

ACAPY_TENANT_WALLET_ID=your-tenant-id-here
ACAPY_TENANT_WALLET_KEY=your-tenant-key-here
# Only needed for multi/traction modes
# ACAPY_TENANT_WALLET_ID=your-tenant-id-here
# ACAPY_TENANT_WALLET_KEY=your-tenant-key-here

# Cache TTL for tenant tokens in seconds (default: 3600)
ACAPY_TOKEN_CACHE_TTL=3600
Expand All @@ -112,8 +113,9 @@ MT_ACAPY_WALLET_KEY=legacy-wallet-key
##########################################################
# ACA-Py Single-Tenant Settings (AGENT_TENANT_MODE=single)
##########################################################
# Must match AGENT_ADMIN_API_KEY for single-tenant mode
ST_ACAPY_ADMIN_API_KEY_NAME=x-api-key
ST_ACAPY_ADMIN_API_KEY=
ST_ACAPY_ADMIN_API_KEY=changeme


##############################
Expand Down
10 changes: 8 additions & 2 deletions oidc-controller/api/services/cleanup.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Cleanup functions for presentation records and connections."""

import asyncio
from datetime import datetime, timedelta, UTC
from typing import TYPE_CHECKING, TypedDict

Expand Down Expand Up @@ -366,10 +367,15 @@ async def perform_cleanup(

try:
# Phase 1: Clean up old presentation records
presentation_phase_start = _cleanup_presentation_records(client, cleanup_stats)
# Run in thread pool to avoid blocking the event loop (these use sync requests)
presentation_phase_start = await asyncio.to_thread(
_cleanup_presentation_records, client, cleanup_stats
)

# Phase 2: Clean up expired connection invitations
_cleanup_connections(client, cleanup_stats, presentation_phase_start)
await asyncio.to_thread(
_cleanup_connections, client, cleanup_stats, presentation_phase_start
)

except Exception as e:
error_msg = f"Cleanup operation failed: {e}"
Expand Down
172 changes: 172 additions & 0 deletions oidc-controller/api/services/tests/test_cleanup_nonblocking.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
"""Tests that verify cleanup doesn't block the event loop."""

import asyncio
import threading
import time
from datetime import datetime, timedelta, UTC
from unittest.mock import patch, Mock

import pytest

from api.services.cleanup import perform_cleanup


class TestCleanupNonBlocking:
"""Tests that cleanup runs in a thread pool and doesn't block the event loop."""

@patch("api.core.acapy.client.requests.get")
@patch("api.core.acapy.client.requests.delete")
@pytest.mark.asyncio
async def test_event_loop_remains_responsive_during_cleanup(
self, mock_delete, mock_get
):
"""Verify that async tasks can run while cleanup is executing."""

# Track which thread the HTTP calls run in
cleanup_thread_ids = []
main_thread_id = threading.current_thread().ident

def slow_get(*args, **kwargs):
"""Simulate slow HTTP call and record thread ID."""
cleanup_thread_ids.append(threading.current_thread().ident)
time.sleep(0.3) # Simulate network latency
response = Mock()
response.status_code = 200
response.content = b'{"results": []}'
return response

def slow_delete(*args, **kwargs):
"""Simulate slow HTTP delete."""
cleanup_thread_ids.append(threading.current_thread().ident)
time.sleep(0.1)
response = Mock()
response.status_code = 200
return response

mock_get.side_effect = slow_get
mock_delete.side_effect = slow_delete

# Track health check response times
health_check_times = []
cleanup_done = asyncio.Event()

async def simulated_health_check():
"""Simulate health checks during cleanup."""
checks_completed = 0
while not cleanup_done.is_set() and checks_completed < 10:
start = asyncio.get_event_loop().time()
# This simulates what the health endpoint does - just an async sleep
# If the event loop were blocked, this would take much longer
await asyncio.sleep(0.05)
elapsed = asyncio.get_event_loop().time() - start
health_check_times.append(elapsed)
checks_completed += 1

async def run_cleanup():
"""Run cleanup and signal when done."""
try:
await perform_cleanup()
finally:
cleanup_done.set()

# Run cleanup and health checks concurrently
await asyncio.gather(
run_cleanup(),
simulated_health_check(),
)

# Assertions

# 1. HTTP calls should happen in worker threads, not the main thread
assert len(cleanup_thread_ids) > 0, "No HTTP calls were made"
for thread_id in cleanup_thread_ids:
assert thread_id != main_thread_id, (
f"HTTP call ran in main thread {main_thread_id} - "
"asyncio.to_thread() is not working!"
)

# 2. Health checks should complete quickly (not blocked by cleanup)
# If event loop was blocked, sleep(0.05) would take much longer
assert len(health_check_times) > 0, "No health checks completed"
for elapsed in health_check_times:
assert elapsed < 0.2, (
f"Health check took {elapsed:.3f}s - event loop was blocked! "
"Expected < 0.2s for a 0.05s sleep"
)

@patch("api.core.acapy.client.requests.get")
@patch("api.core.acapy.client.requests.delete")
@pytest.mark.asyncio
async def test_cleanup_runs_in_thread_pool(self, mock_delete, mock_get):
"""Verify cleanup functions execute in a thread pool, not the main thread."""

execution_thread_ids = set()
main_thread_id = threading.current_thread().ident

def tracking_get(*args, **kwargs):
execution_thread_ids.add(threading.current_thread().ident)
response = Mock()
response.status_code = 200
response.content = b'{"results": []}'
return response

def tracking_delete(*args, **kwargs):
execution_thread_ids.add(threading.current_thread().ident)
response = Mock()
response.status_code = 200
return response

mock_get.side_effect = tracking_get
mock_delete.side_effect = tracking_delete

# Run cleanup
await perform_cleanup()

# Verify HTTP calls happened in worker threads
assert len(execution_thread_ids) > 0, "No HTTP calls were recorded"
assert main_thread_id not in execution_thread_ids, (
"Cleanup ran in main thread - would block event loop! "
"asyncio.to_thread() should offload to thread pool."
)

@patch("api.core.acapy.client.requests.get")
@patch("api.core.acapy.client.requests.delete")
@pytest.mark.asyncio
async def test_multiple_concurrent_tasks_during_cleanup(
self, mock_delete, mock_get
):
"""Verify multiple async tasks can run concurrently during cleanup."""

def slow_get(*args, **kwargs):
time.sleep(0.2)
response = Mock()
response.status_code = 200
response.content = b'{"results": []}'
return response

mock_get.side_effect = slow_get
mock_delete.return_value = Mock(status_code=200)

task_execution_times = []

async def background_task(task_id):
"""A simple async task that should run during cleanup."""
start = asyncio.get_event_loop().time()
await asyncio.sleep(0.01)
elapsed = asyncio.get_event_loop().time() - start
task_execution_times.append((task_id, elapsed))

# Run cleanup with multiple concurrent background tasks
await asyncio.gather(
perform_cleanup(),
background_task(1),
background_task(2),
background_task(3),
)

# All background tasks should complete quickly
assert len(task_execution_times) == 3, "Not all background tasks completed"
for task_id, elapsed in task_execution_times:
assert (
elapsed < 0.1
), f"Task {task_id} took {elapsed:.3f}s - event loop was blocked!"