Skip to main content

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:

StepOperationLatencyNotes
1Network round-trip~100msVaries by location
2Redis Lua script~2msAtomic delete
3PostgreSQL UPDATE~10msIndexed update
4Duration calculation~1msTimestamp math
5UsageEvent INSERT~10msEvent sourcing write
6Metrics update~1msPrometheus counter
7Audit log~5msCloud 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:

  1. Check if session exists (step 1)
  2. If not exists → return success immediately
  3. No error thrown - idempotent behavior

Benefits:

  • Client can retry safely
  • No duplicate error handling needed
  • Simplifies client code

  • 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