Sequence Diagram: Seat Acquisition Flow
Purpose: Atomic seat acquisition workflow for floating concurrent licensing with Redis-based seat counting.
Actors:
- CODITECT Client (local application)
- License API (Django on GKE)
- PostgreSQL (license metadata)
- Redis (atomic seat counter)
Flow: Atomic seat acquisition with fallback and denial handling
Mermaid Sequence Diagram
Step-by-Step Breakdown
1. Seat Acquisition Request (Step 1)
Client initiates seat acquisition:
# Client-side: Seat acquisition
import requests
import uuid
def acquire_seat(
jwt_token: str,
license_key: str,
user_email: str
) -> dict:
"""
Acquire floating license seat.
Args:
jwt_token: JWT access token
license_key: License key from validated license
user_email: User's email address
Returns:
Seat acquisition result with seat number and active count
Raises:
SeatDeniedError: No seats available
LicenseError: License invalid
"""
# Generate unique session ID
session_id = str(uuid.uuid4())
response = requests.post(
'https://api.coditect.ai/api/v1/license/seat/acquire',
headers={
'Authorization': f'Bearer {jwt_token}',
'Content-Type': 'application/json'
},
json={
'license_key': license_key,
'session_id': session_id,
'user_email': user_email
}
)
if response.status_code == 429:
# No seats available
data = response.json()
raise SeatDeniedError(
f"No seats available ({data['active_seats']}/{data['max_seats']}). "
f"Retry after {data['retry_after']} seconds."
)
if response.status_code != 200:
raise LicenseError(response.json()['error'])
return response.json()
2. Atomic Seat Acquisition (Step 4)
Server-side: Redis Lua script for atomic seat counting:
# Server-side: Atomic seat acquisition with Lua
import redis
from typing import Dict, Any
class SeatManager:
"""
Manages floating license seats using Redis.
Uses Lua scripting for atomicity - prevents race conditions.
"""
def __init__(self):
self.redis_client = redis.StrictRedis(
host='10.0.0.3', # Redis Memorystore private IP
port=6379,
db=0,
decode_responses=True
)
# Lua script for atomic seat acquisition
self.acquire_script = """
local license_key = KEYS[1]
local session_id = ARGV[1]
local max_seats = tonumber(ARGV[2])
local ttl = tonumber(ARGV[3]) -- 6 minutes (360 seconds)
-- Key for active sessions set
local sessions_key = "license:" .. license_key .. ":sessions"
-- Get current seat count
local active_seats = redis.call('SCARD', sessions_key)
-- Check if seats available
if active_seats < max_seats then
-- Add session to set with TTL
redis.call('SADD', sessions_key, session_id)
redis.call('EXPIRE', sessions_key, ttl)
-- Set individual session key with TTL (for heartbeat renewal)
local session_key = "session:" .. session_id
redis.call('SET', session_key, license_key)
redis.call('EXPIRE', session_key, ttl)
-- Return success with updated count
local new_count = active_seats + 1
return {1, new_count, new_count} -- {success, active_seats, seat_number}
else
-- No seats available
return {0, active_seats, max_seats} -- {failure, active_seats, max_seats}
end
"""
async def acquire_seat(
self,
license_key: str,
session_id: str,
max_seats: int
) -> Dict[str, Any]:
"""
Atomically acquire a floating license seat.
Args:
license_key: License key
session_id: Unique session identifier
max_seats: Maximum concurrent seats for license
Returns:
{
'success': bool,
'active_seats': int,
'seat_number': int (if success),
'max_seats': int (if failure)
}
"""
result = self.redis_client.eval(
self.acquire_script,
1, # Number of keys
license_key, # KEYS[1]
session_id, # ARGV[1]
max_seats, # ARGV[2]
360 # ARGV[3] - TTL in seconds (6 minutes)
)
success = result[0] == 1
active_seats = result[1]
seat_info = result[2]
if success:
return {
'success': True,
'active_seats': active_seats,
'seat_number': seat_info
}
else:
return {
'success': False,
'active_seats': active_seats,
'max_seats': seat_info
}
Why Lua Script?
The Lua script runs atomically on Redis server, preventing race conditions:
❌ WITHOUT LUA (Race Condition Possible):
Thread A: Check seats (49/50) ─┐
Thread B: Check seats (49/50) │ Race!
Thread A: Increment (50/50) │
Thread B: Increment (51/50) ←───┘ OVERBOOKED!
✅ WITH LUA (Atomic):
Thread A: Lua script (check + increment) ─── Success (50/50)
Thread B: Lua script (check + increment) ─── Denied (50/50)
3. Django REST Framework Endpoint (Steps 1-7)
Server-side: Seat acquisition endpoint:
# Server-side: Django REST Framework 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
# Request/Response Serializers
class SeatAcquisitionRequestSerializer(serializers.Serializer):
license_key = serializers.CharField(max_length=255)
session_id = serializers.CharField(max_length=255)
user_email = serializers.EmailField()
class SeatAcquisitionResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()
seat_number = serializers.IntegerField()
active_seats = serializers.IntegerField()
heartbeat_interval = serializers.IntegerField() # seconds
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.utils import timezone
from apps.licenses.models import License, Session
from apps.licenses.serializers import SeatAcquisitionRequestSerializer
from apps.licenses.services import SeatManager
from apps.core.audit import audit_logger
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def acquire_seat(request):
"""
Acquire floating license seat.
Process:
1. Validate license active/not expired
2. Atomic seat acquisition via Redis Lua
3. Record session in PostgreSQL
4. Return seat number and heartbeat interval
Returns:
200 OK: Seat acquired successfully
429 Too Many Requests: No seats available
403 Forbidden: License invalid/expired
"""
# Validate request data
serializer = SeatAcquisitionRequestSerializer(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']
user_email = serializer.validated_data['user_email']
# Step 1: Validate license
try:
license_obj = License.objects.get(license_key=license_key)
except License.DoesNotExist:
return Response(
{"detail": "License not found"},
status=status.HTTP_404_NOT_FOUND
)
if not license_obj.is_active or license_obj.is_expired:
return Response(
{"detail": "License inactive or expired"},
status=status.HTTP_403_FORBIDDEN
)
# Verify tenant ownership
if license_obj.tenant.owner_id != request.user.id:
return Response(
{"detail": "License not owned by user"},
status=status.HTTP_403_FORBIDDEN
)
# Step 2: Atomic seat acquisition
seat_manager = SeatManager()
result = seat_manager.acquire_seat(
license_key=license_key,
session_id=session_id,
max_seats=license_obj.max_seats
)
if not result['success']:
# No seats available
from prometheus_client import Counter
seat_denials = Counter(
'seat_denials_total',
'Total seat denials',
['license_key']
)
seat_denials.labels(license_key=license_key).inc()
return Response(
{
'error': 'No seats available',
'active_seats': result['active_seats'],
'max_seats': result['max_seats'],
'retry_after': 60
},
status=status.HTTP_429_TOO_MANY_REQUESTS
)
# Step 3: Record session in database
Session.objects.create(
license=license_obj,
session_id=session_id,
user_email=user_email,
seat_number=result['seat_number'],
acquired_at=timezone.now()
)
# Step 4: Log success metrics
from prometheus_client import Counter, Gauge
seat_acquisitions = Counter(
'seat_acquisitions_total',
'Total seat acquisitions',
['license_key', 'success']
)
seat_acquisitions.labels(
license_key=license_key,
success='true'
).inc()
active_sessions = Gauge(
'active_sessions',
'Active sessions',
['license_key']
)
active_sessions.labels(license_key=license_key).set(
result['active_seats']
)
# Step 5: Audit log
audit_logger.log_event(
event_type='license',
user_id=str(request.user.id),
resource_type='seat',
resource_id=session_id,
action='acquire',
status='success',
ip_address=request.META.get('HTTP_X_FORWARDED_FOR'),
user_agent=request.META.get('HTTP_USER_AGENT'),
metadata={
'license_key': license_key,
'seat_number': result['seat_number'],
'active_seats': result['active_seats']
}
)
# Step 6: Return success
return Response(
{
'success': True,
'seat_number': result['seat_number'],
'active_seats': result['active_seats'],
'heartbeat_interval': 300 # 5 minutes
},
status=status.HTTP_200_OK
)
4. Client Retry Logic (Step 10)
Client-side: Retry with exponential backoff:
# Client-side: Retry logic for seat denial
import time
from typing import Optional
def acquire_seat_with_retry(
jwt_token: str,
license_key: str,
user_email: str,
max_retries: int = 10,
show_dialog: callable = None
) -> Optional[dict]:
"""
Acquire seat with retry logic for denial scenarios.
Args:
jwt_token: JWT access token
license_key: License key
user_email: User email
max_retries: Maximum retry attempts (default: 10 = 10 minutes)
show_dialog: Callback to show wait dialog to user
Returns:
Seat acquisition result or None if user cancels
"""
retry_count = 0
while retry_count < max_retries:
try:
result = acquire_seat(jwt_token, license_key, user_email)
return result # Success!
except SeatDeniedError as e:
retry_count += 1
# Show wait dialog to user
if show_dialog:
user_cancelled = show_dialog(
title="All Seats In Use",
message=f"All {e.max_seats} seats are currently in use.\n"
f"Waiting for an available seat...\n\n"
f"Retry {retry_count}/{max_retries}",
countdown=60,
allow_cancel=True
)
if user_cancelled:
return None # User cancelled
# Wait before retry (60 seconds)
time.sleep(60)
except LicenseError as e:
# License error - don't retry
raise
# Max retries exceeded
raise SeatDeniedError("No seats available after 10 retries")
Example UI dialog:
# Client-side: PyQt6 wait dialog
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton
from PyQt6.QtCore import QTimer
class SeatWaitDialog(QDialog):
"""
Dialog shown when no seats available.
Features:
- Countdown timer (60 seconds)
- Cancel button
- Auto-retry when countdown expires
"""
def __init__(self, message: str, countdown: int):
super().__init__()
self.setWindowTitle("Waiting for Seat")
self.countdown = countdown
self.cancelled = False
layout = QVBoxLayout()
self.message_label = QLabel(message)
layout.addWidget(self.message_label)
self.countdown_label = QLabel(f"Retrying in {countdown} seconds...")
layout.addWidget(self.countdown_label)
cancel_button = QPushButton("Cancel")
cancel_button.clicked.connect(self.on_cancel)
layout.addWidget(cancel_button)
self.setLayout(layout)
# Start countdown timer
self.timer = QTimer()
self.timer.timeout.connect(self.update_countdown)
self.timer.start(1000) # 1 second interval
def update_countdown(self):
self.countdown -= 1
self.countdown_label.setText(f"Retrying in {self.countdown} seconds...")
if self.countdown <= 0:
self.timer.stop()
self.accept() # Close dialog and retry
def on_cancel(self):
self.timer.stop()
self.cancelled = True
self.reject() # Close dialog and cancel
Error Scenarios
Named User Limit (Team/Enterprise)
Session Already Active
Performance Characteristics
Latency Breakdown:
| Step | Operation | Latency | Notes |
|---|---|---|---|
| 1 | API request (network) | ~100ms | Depends on location |
| 2 | PostgreSQL license lookup | ~5ms | Indexed query |
| 3 | Validation checks | ~2ms | In-memory |
| 4 | Redis Lua script | ~2ms | Atomic operation |
| 5 | PostgreSQL session insert | ~10ms | Write operation |
| 6 | Metrics logging | ~1ms | Prometheus counter |
| 7 | Audit logging | ~5ms | Cloud Logging async |
Total: ~125ms (typical)
Throughput:
- Redis can handle 100,000+ operations/second
- Single license: 10,000+ acquisitions/second (theoretical)
- Practical: Limited by PostgreSQL writes (~1,000 acq/sec)
Optimization:
- Batch PostgreSQL writes (every 10 seconds)
- Use Redis for real-time, PostgreSQL for audit trail
Related Documentation
- ADR-003: Seat Management (floating concurrent licensing)
- ADR-004: Session Management (TTL and heartbeats)
- ADR-011: Zombie Session Cleanup
- 03-heartbeat-renewal-flow.md: Heartbeat mechanism
- 04-seat-release-flow.md: Explicit seat release
Last Updated: 2025-11-30 Diagram Type: Sequence (Mermaid) Scope: Core licensing flow - Seat acquisition