Sequence Diagram: Seat Release Flow
Purpose: Explicit seat release when CODITECT shuts down gracefully, freeing seats immediately for other users.
Actors:
- CODITECT Client (shutdown handler)
- License API (Django on GKE)
- Redis (atomic seat removal)
- PostgreSQL (session termination logging)
Flow: Graceful shutdown with immediate seat release and usage tracking
Mermaid Sequence Diagram
Step-by-Step Breakdown
1. Client Shutdown Handler (Steps 1-3)
Client-side: Graceful shutdown with seat release:
# Client-side: Shutdown handler
import atexit
import signal
import sys
import logging
logger = logging.getLogger(__name__)
class SeatReleaseManager:
"""
Manages graceful seat release on CODITECT shutdown.
Features:
- Automatic release via atexit handler
- Signal handling (SIGINT, SIGTERM)
- Exception handling (try/finally)
- Idempotent (safe to call multiple times)
"""
def __init__(
self,
jwt_token: str,
license_key: str,
session_id: str,
heartbeat_manager: 'HeartbeatManager'
):
self.jwt_token = jwt_token
self.license_key = license_key
self.session_id = session_id
self.heartbeat_manager = heartbeat_manager
self.released = False # Prevent double release
# Register shutdown handlers
self._register_shutdown_handlers()
def _register_shutdown_handlers(self):
"""
Register handlers for various shutdown scenarios.
Handlers:
- atexit: Normal exit
- SIGINT: Ctrl+C
- SIGTERM: kill command
- SIGHUP: Terminal closed
"""
# Normal exit
atexit.register(self.release_seat)
# Signal handlers
signal.signal(signal.SIGINT, self._signal_handler)
signal.signal(signal.SIGTERM, self._signal_handler)
# SIGHUP (terminal closed) - Unix only
if sys.platform != 'win32':
signal.signal(signal.SIGHUP, self._signal_handler)
logger.info("Shutdown handlers registered")
def _signal_handler(self, signum, frame):
"""
Handle termination signals.
Performs graceful shutdown before exiting.
"""
logger.info(f"Received signal {signum} - shutting down gracefully")
self.release_seat()
# Exit with appropriate code
sys.exit(0 if signum == signal.SIGINT else 128 + signum)
def release_seat(self):
"""
Release seat gracefully.
Called automatically on shutdown.
Idempotent - safe to call multiple times.
"""
if self.released:
logger.debug("Seat already released")
return
logger.info("Releasing seat...")
try:
# Step 1: Stop heartbeat thread
if self.heartbeat_manager:
self.heartbeat_manager.stop()
# Step 2: Send release request to API
import requests
response = requests.post(
'https://api.coditect.ai/api/v1/license/seat/release',
headers={
'Authorization': f'Bearer {self.jwt_token}',
'Content-Type': 'application/json'
},
json={
'license_key': self.license_key,
'session_id': self.session_id
},
timeout=10 # Short timeout (we're exiting anyway)
)
if response.status_code == 200:
data = response.json()
logger.info(
f"Seat released successfully "
f"(active: {data.get('active_seats', 'unknown')})"
)
else:
logger.warning(
f"Seat release failed: {response.status_code} "
f"{response.text}"
)
except requests.exceptions.Timeout:
logger.warning("Seat release timeout (network slow)")
except requests.exceptions.ConnectionError:
logger.warning("Seat release failed (network unavailable)")
logger.info("Seat will auto-expire via TTL (6 minutes)")
except Exception as e:
logger.exception(f"Seat release error: {e}")
finally:
# Step 3: Mark as released (prevent double release)
self.released = True
# Step 4: Cleanup local cache
self._cleanup_local_cache()
def _cleanup_local_cache(self):
"""
Delete local license cache.
Forces re-validation on next startup.
"""
import os
license_cache_path = os.path.expanduser('~/.coditect/license.json')
try:
if os.path.exists(license_cache_path):
os.remove(license_cache_path)
logger.info(f"Deleted license cache: {license_cache_path}")
except Exception as e:
logger.warning(f"Failed to delete license cache: {e}")
Usage in main application:
# Client-side: Main application with seat release
import sys
from .license_client import validate_license, acquire_seat
from .heartbeat_manager import HeartbeatManager
from .seat_release_manager import SeatReleaseManager
def main():
"""
Main CODITECT entry point with seat release.
"""
try:
# Authenticate and validate license
jwt_token = authenticate(email, password)
license_token = validate_license(jwt_token, license_key, hardware_id, version)
# Acquire seat
seat_result = acquire_seat(jwt_token, license_key, user_email)
session_id = seat_result['session_id']
# Start heartbeat
heartbeat_manager = HeartbeatManager(
jwt_token=jwt_token,
license_key=license_key,
session_id=session_id
)
heartbeat_manager.start()
# Register seat release (automatic on exit)
seat_release_manager = SeatReleaseManager(
jwt_token=jwt_token,
license_key=license_key,
session_id=session_id,
heartbeat_manager=heartbeat_manager
)
# Run CODITECT
run_coditect_main_loop()
# Normal exit - seat automatically released via atexit
except LicenseError as e:
print(f"License error: {e}")
sys.exit(1)
except KeyboardInterrupt:
print("\nInterrupted by user")
# Seat automatically released via signal handler
sys.exit(0)
except Exception as e:
print(f"Error: {e}")
# Seat automatically released via atexit
sys.exit(1)
finally:
# This also triggers atexit handlers
pass
2. Server-Side Release Endpoint (Steps 4-11)
Django REST Framework release endpoint:
# Server-side: Seat release endpoint
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework import serializers
import redis
from django.utils import timezone
from datetime import datetime
# Request/Response Serializers
class SeatReleaseRequestSerializer(serializers.Serializer):
license_key = serializers.CharField(max_length=255)
session_id = serializers.CharField(max_length=255)
class SeatReleaseResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()
active_seats = serializers.IntegerField()
session_duration = serializers.IntegerField() # seconds
note = serializers.CharField(required=False, allow_null=True)
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from django.conf import settings
from django.utils import timezone
import logging
from apps.licenses.models import License, Session, UsageEvent
from apps.core.audit import audit_logger
logger = logging.getLogger(__name__)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def release_seat(request):
"""
Release floating license seat.
Process:
1. Verify session exists in Redis
2. Remove session atomically (Lua script)
3. Update session record (released_at timestamp)
4. Calculate session duration
5. Record usage event (event sourcing)
6. Update metrics and audit log
7. Return active seat count
Returns:
200 OK: Seat released successfully (or already released)
"""
# Validate request data
serializer = SeatReleaseRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"detail": "Invalid request data", "errors": serializer.errors},
status=status.HTTP_400_BAD_REQUEST
)
license_key = serializer.validated_data['license_key']
session_id = serializer.validated_data['session_id']
redis_client = redis.StrictRedis(
host=settings.REDIS_HOST,
port=6379,
db=0,
decode_responses=True
)
# Step 1: Check if session exists
session_key = f"session:{session_id}"
session_exists = redis_client.exists(session_key)
if not session_exists:
# Session already released or expired - idempotent response
logger.info(f"Session already released: {session_id}")
return Response(
{
'success': True,
'active_seats': 0, # Unknown
'session_duration': 0,
'note': "Session already released or expired"
},
status=status.HTTP_200_OK
)
# Step 2: Atomic session removal (Lua script)
lua_script = """
local session_key = KEYS[1]
local sessions_set_key = KEYS[2]
local session_id = ARGV[1]
-- Remove session from set
redis.call('SREM', sessions_set_key, session_id)
-- Delete session key
redis.call('DEL', session_key)
-- Get remaining active count
local active_count = redis.call('SCARD', sessions_set_key)
return {1, active_count} -- {success, active_count}
"""
sessions_set_key = f"license:{license_key}:sessions"
result = redis_client.eval(
lua_script,
2, # Number of keys
session_key,
sessions_set_key,
session_id
)
success = result[0] == 1
active_count = result[1]
# Step 3: Update session record in PostgreSQL
try:
session_obj = Session.objects.get(session_id=session_id)
except Session.DoesNotExist:
logger.warning(f"Session not found in database: {session_id}")
# Redis cleanup successful, PostgreSQL record missing
# (Should not happen normally)
return Response(
{
'success': True,
'active_seats': active_count,
'session_duration': 0,
'note': "Seat released (session record not found)"
},
status=status.HTTP_200_OK
)
# Update released timestamp
released_at = timezone.now()
session_obj.released_at = released_at
session_obj.status = 'released'
session_obj.save()
# Step 4: Calculate session duration
session_duration = (released_at - session_obj.acquired_at).total_seconds()
# Step 5: Record usage event (event sourcing)
UsageEvent.objects.create(
license=session_obj.license,
tenant=session_obj.license.tenant,
event_type=UsageEvent.EventType.SESSION_DURATION,
timestamp=released_at,
quantity=session_duration / 3600, # Convert to hours
metadata={
'session_id': session_id,
'user_email': session_obj.user_email,
'acquired_at': session_obj.acquired_at.isoformat(),
'released_at': released_at.isoformat(),
'duration_seconds': session_duration
}
)
# Step 6: Update Prometheus metrics
from prometheus_client import Counter, Gauge, Histogram
seat_releases = Counter(
'seat_releases_total',
'Total seat releases',
['license_key']
)
seat_releases.labels(license_key=license_key).inc()
active_sessions = Gauge(
'active_sessions',
'Active sessions',
['license_key']
)
active_sessions.labels(license_key=license_key).set(active_count)
session_duration_histogram = Histogram(
'session_duration_seconds',
'Session duration in seconds',
['license_key'],
buckets=[60, 300, 600, 1800, 3600, 7200, 14400, 28800, 86400]
)
session_duration_histogram.labels(license_key=license_key).observe(
session_duration
)
# Step 7: Audit log
audit_logger.log_event(
event_type='license',
user_id=str(request.user.id),
resource_type='seat',
resource_id=session_id,
action='release',
status='success',
ip_address=request.META.get('HTTP_X_FORWARDED_FOR'),
user_agent=request.META.get('HTTP_USER_AGENT'),
metadata={
'license_key': license_key,
'session_duration': session_duration,
'active_seats_after': active_count
}
)
# Step 8: Return success
logger.info(
f"Seat released: {session_id} "
f"(duration: {session_duration:.0f}s, active: {active_count})"
)
return Response(
{
'success': True,
'active_seats': active_count,
'session_duration': int(session_duration)
},
status=status.HTTP_200_OK
)
Error Scenarios
Network Failure During Release
Why it's OK:
- Session will auto-expire via TTL (6 minutes max)
- No zombie sessions - automatic cleanup
- Client can exit immediately (don't block user)
Crash Without Release
Zombie Session Protection:
- TTL-based automatic cleanup
- No permanent seat waste
- Max 6 minutes until seat freed
- See: 05-zombie-session-cleanup-flow.md
Performance Characteristics
Release Latency:
| Step | Operation | Latency | Notes |
|---|---|---|---|
| 1 | Network round-trip | ~100ms | Varies by location |
| 2 | Redis Lua script | ~2ms | Atomic delete |
| 3 | PostgreSQL UPDATE | ~10ms | Indexed update |
| 4 | Duration calculation | ~1ms | Timestamp math |
| 5 | UsageEvent INSERT | ~10ms | Event sourcing write |
| 6 | Metrics update | ~1ms | Prometheus counter |
| 7 | Audit log | ~5ms | Cloud Logging async |
Total: ~129ms (typical)
But client doesn't wait:
- Release request is fire-and-forget
- Client exits immediately after sending
- API processing happens asynchronously
- User experience: instant shutdown
Usage Tracking
Session duration tracking for billing/analytics:
# Server-side: Usage event for session duration
from .models import UsageEvent
async def record_session_usage(
license: License,
session_id: str,
duration_seconds: float,
user_email: str
):
"""
Record session usage for billing and analytics.
Used for:
- Usage-based metering (Enterprise tier)
- Analytics dashboards
- Customer usage reports
- Billing invoices (via Stripe)
"""
await UsageEvent.objects.acreate(
license=license,
tenant=license.tenant,
event_type=UsageEvent.EventType.SESSION_DURATION,
timestamp=timezone.now(),
quantity=duration_seconds / 3600, # Convert to hours
metadata={
'session_id': session_id,
'user_email': user_email,
'duration_seconds': duration_seconds,
'duration_minutes': duration_seconds / 60,
'duration_hours': duration_seconds / 3600
}
)
# For Enterprise tier - report to Stripe
if license.tier == Tier.ENTERPRISE:
await report_usage_to_stripe(
subscription_id=license.stripe_subscription_id,
quantity=duration_seconds / 3600, # Hours
timestamp=timezone.now()
)
Analytics queries:
-- Average session duration by license
SELECT
l.license_key,
l.tier,
COUNT(*) as session_count,
AVG(EXTRACT(EPOCH FROM (s.released_at - s.acquired_at))) as avg_duration_seconds,
MAX(EXTRACT(EPOCH FROM (s.released_at - s.acquired_at))) as max_duration_seconds
FROM sessions s
JOIN licenses l ON s.license_id = l.id
WHERE s.released_at IS NOT NULL
GROUP BY l.license_key, l.tier
ORDER BY avg_duration_seconds DESC;
-- Daily active users (DAU)
SELECT
DATE(acquired_at) as date,
COUNT(DISTINCT user_email) as dau
FROM sessions
WHERE acquired_at >= NOW() - INTERVAL '30 days'
GROUP BY DATE(acquired_at)
ORDER BY date;
-- Seat utilization over time
SELECT
DATE_TRUNC('hour', acquired_at) as hour,
l.license_key,
COUNT(*) as concurrent_sessions
FROM sessions s
JOIN licenses l ON s.license_id = l.id
WHERE acquired_at >= NOW() - INTERVAL '7 days'
GROUP BY DATE_TRUNC('hour', acquired_at), l.license_key
ORDER BY hour, license_key;
Idempotency
Why idempotency matters:
Multiple release calls (due to retries or bugs) should be safe:
# Multiple release calls - all succeed
release_seat(session_id='abc123') # 200 OK (released)
release_seat(session_id='abc123') # 200 OK (already released)
release_seat(session_id='abc123') # 200 OK (already released)
Implementation:
- Check if session exists (step 1)
- If not exists → return success immediately
- No error thrown - idempotent behavior
Benefits:
- Client can retry safely
- No duplicate error handling needed
- Simplifies client code
Related Documentation
- ADR-003: Seat Management (floating concurrent licensing)
- ADR-004: Session Management (TTL strategy)
- ADR-011: Zombie Session Cleanup
- 02-seat-acquisition-flow.md: Initial seat acquisition
- 03-heartbeat-renewal-flow.md: Heartbeat mechanism
- 05-zombie-session-cleanup-flow.md: Automatic cleanup
Last Updated: 2025-11-30 Diagram Type: Sequence (Mermaid) Scope: Core licensing flow - Seat release