fix: close race conditions in script builder session and slug creation

- script_builder endpoint: pg_advisory_xact_lock on user_id before
  session count check, preventing concurrent creates from both passing
  the MAX_SESSIONS_PER_USER guard
- script_builder_service send_message: pg_advisory_xact_lock on session_id
  before message count check, preventing concurrent sends from both
  passing the MAX_MESSAGES_PER_SESSION guard
- script_builder_service save_to_library: replace check-then-insert slug
  logic with IntegrityError retry loop (3 attempts with fresh UUID suffix);
  add unique constraint on script_templates.slug (migration 070)
- ScriptBuilderPage: add creatingSessionRef to serialize concurrent
  handleSend calls that would otherwise both call createSession() while
  session is still null

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-04-01 05:09:42 +00:00
parent 41b0f0a627
commit cb33787c08
5 changed files with 105 additions and 34 deletions

View File

@@ -0,0 +1,29 @@
"""add unique constraint to script_templates.slug
Revision ID: 070
Revises: 069
Create Date: 2026-04-01
"""
from alembic import op
revision = "070"
down_revision = "069"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_unique_constraint(
"uq_script_templates_slug",
"script_templates",
["slug"],
)
def downgrade() -> None:
op.drop_constraint(
"uq_script_templates_slug",
"script_templates",
type_="unique",
)

View File

@@ -3,6 +3,7 @@ from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
@@ -67,6 +68,12 @@ async def create_session(
current_user: Annotated[User, Depends(get_current_active_user)],
) -> ScriptBuilderSessionDetail:
"""Start a new Script Builder session."""
# Acquire per-user advisory lock so concurrent create requests are serialized.
# Without this, two simultaneous requests both read count < limit and both
# insert, exceeding MAX_SESSIONS_PER_USER.
user_lock_key = hash(str(current_user.id)) % (2**62)
await db.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": user_lock_key})
# Enforce max concurrent sessions
count = await script_builder_service.count_user_sessions(db, current_user.id)
if count >= MAX_SESSIONS_PER_USER:

View File

@@ -48,7 +48,7 @@ class ScriptTemplate(Base):
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
slug: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
slug: Mapped[str] = mapped_column(String(200), nullable=False, unique=True, index=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
use_case: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
script_body: Mapped[str] = mapped_column(Text, nullable=False)

View File

@@ -5,7 +5,8 @@ from datetime import datetime, timezone
from typing import Optional
from uuid import UUID
from sqlalchemy import select, func
from sqlalchemy import select, func, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -169,6 +170,12 @@ async def send_message(
user_content: str,
) -> ScriptBuilderMessageResponse:
"""Send a user message and get AI response with generated script."""
# Acquire per-session advisory lock to prevent concurrent message count races.
# Two simultaneous sends to the same session would otherwise both read the same
# count, both pass the limit check, and both insert — exceeding the cap.
session_lock_key = hash(str(session.id)) % (2**62)
await db.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": session_lock_key})
# Count existing messages for the session
msg_count_result = await db.execute(
select(func.count(ScriptBuilderMessage.id)).where(
@@ -344,36 +351,48 @@ async def save_to_library(
raise ValueError("Default 'AI Generated' category not found. Run migrations.")
resolved_category_id = default_cat
# Generate unique slug
# Generate slug. Use a UUID suffix on first attempt to prevent concurrent
# saves with the same name from hitting the unique constraint on slug.
base_slug = name.lower().replace(" ", "-").replace("_", "-")[:80]
base_slug = re.sub(r"[^a-z0-9\-]", "", base_slug)
slug = base_slug
# Check uniqueness
existing = await db.execute(
select(ScriptTemplate.id).where(ScriptTemplate.slug == slug)
)
if existing.scalar_one_or_none():
slug = f"{base_slug}-{uuid_mod.uuid4().hex[:6]}"
template = ScriptTemplate(
id=uuid_mod.uuid4(),
category_id=resolved_category_id,
created_by=user_id,
team_id=team_id if share_with_team else None,
name=name,
slug=slug,
description=description,
script_body=script_body or session.latest_script,
parameters_schema=parameters_schema or {"parameters": []},
default_values={},
validation_rules={},
tags=[session.language, "ai-generated"],
complexity="intermediate",
is_verified=False,
is_active=True,
version=1,
usage_count=0,
# Check if the base slug is already taken; if not, use it clean (no suffix).
# If taken, or if the insert races with a concurrent request, retry with a
# fresh UUID suffix. The unique constraint on script_templates.slug is the
# authoritative guard — the application check just avoids unnecessary retries.
existing = await db.execute(
select(ScriptTemplate.id).where(ScriptTemplate.slug == base_slug)
)
db.add(template)
await db.flush()
return template
slug = base_slug if not existing.scalar_one_or_none() else f"{base_slug}-{uuid_mod.uuid4().hex[:6]}"
for attempt in range(3):
template = ScriptTemplate(
id=uuid_mod.uuid4(),
category_id=resolved_category_id,
created_by=user_id,
team_id=team_id if share_with_team else None,
name=name,
slug=slug,
description=description,
script_body=script_body or session.latest_script,
parameters_schema=parameters_schema or {"parameters": []},
default_values={},
validation_rules={},
tags=[session.language, "ai-generated"],
complexity="intermediate",
is_verified=False,
is_active=True,
version=1,
usage_count=0,
)
db.add(template)
try:
await db.flush()
return template
except IntegrityError as exc:
if "uq_script_templates_slug" not in str(exc.orig) or attempt == 2:
raise
await db.rollback()
slug = f"{base_slug}-{uuid_mod.uuid4().hex[:8]}"
raise RuntimeError("Failed to generate a unique slug after 3 attempts")

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Terminal } from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -21,6 +21,11 @@ export default function ScriptBuilderPage() {
const [messages, setMessages] = useState<ScriptBuilderMessage[]>([])
const [language, setLanguage] = useState('powershell')
const [isLoading, setIsLoading] = useState(false)
// Ref-based lock: prevents two concurrent handleSend calls (e.g. FlowPilot
// handoff useEffect + user keystroke) from each calling createSession() and
// creating two orphaned sessions. React state updates are async so isLoading
// alone can't guard across two calls in the same render cycle.
const creatingSessionRef = useRef(false)
const [previewScript, setPreviewScript] = useState<{ script: string; filename: string | null } | null>(null)
const [showSaveDialog, setShowSaveDialog] = useState(false)
const [handoffProcessed, setHandoffProcessed] = useState(false)
@@ -75,8 +80,19 @@ export default function ScriptBuilderPage() {
// Create session if needed
let currentSession = session
if (!currentSession) {
currentSession = await scriptBuilderApi.createSession(effectiveLanguage)
setSession(currentSession)
if (creatingSessionRef.current) {
// Another concurrent call is already creating the session; drop this send.
setIsLoading(false)
setMessages((prev) => prev.slice(0, -1))
return
}
creatingSessionRef.current = true
try {
currentSession = await scriptBuilderApi.createSession(effectiveLanguage)
setSession(currentSession)
} finally {
creatingSessionRef.current = false
}
}
// Send message