Skip to main content

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:

StepOperationLatencyNotes
1API request (network)~100msDepends on location
2PostgreSQL license lookup~5msIndexed query
3Validation checks~2msIn-memory
4Redis Lua script~2msAtomic operation
5PostgreSQL session insert~10msWrite operation
6Metrics logging~1msPrometheus counter
7Audit logging~5msCloud 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

  • 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