chore: import upstream snapshot with attribution
docs / deploy (push) Has been cancelled
docs / changes (push) Has been cancelled
docs / check-and-build (push) Has been cancelled
build container image / cpu (push) Has been cancelled
build container image / cuda (push) Has been cancelled
build container image / rocm (push) Has been cancelled
frontend checks / frontend-checks (push) Has been cancelled
frontend tests / frontend-tests (push) Has been cancelled
lfs checks / lfs-check (push) Has been cancelled
python checks / python-checks (push) Has been cancelled
python tests / py3.12: macos-default (push) Has been cancelled
python tests / py3.11: windows-cpu (push) Has been cancelled
python tests / py3.12: windows-cpu (push) Has been cancelled
python tests / py3.11: linux-cpu (push) Has been cancelled
typegen checks / typegen-checks (push) Has been cancelled
uv lock checks / uv-lock-checks (push) Has been cancelled
openapi checks / openapi-checks (push) Has been cancelled
python tests / py3.11: macos-default (push) Has been cancelled
python tests / py3.12: linux-cpu (push) Has been cancelled
docs / deploy (push) Has been cancelled
docs / changes (push) Has been cancelled
docs / check-and-build (push) Has been cancelled
build container image / cpu (push) Has been cancelled
build container image / cuda (push) Has been cancelled
build container image / rocm (push) Has been cancelled
frontend checks / frontend-checks (push) Has been cancelled
frontend tests / frontend-tests (push) Has been cancelled
lfs checks / lfs-check (push) Has been cancelled
python checks / python-checks (push) Has been cancelled
python tests / py3.12: macos-default (push) Has been cancelled
python tests / py3.11: windows-cpu (push) Has been cancelled
python tests / py3.12: windows-cpu (push) Has been cancelled
python tests / py3.11: linux-cpu (push) Has been cancelled
typegen checks / typegen-checks (push) Has been cancelled
uv lock checks / uv-lock-checks (push) Has been cancelled
openapi checks / openapi-checks (push) Has been cancelled
python tests / py3.11: macos-default (push) Has been cancelled
python tests / py3.12: linux-cpu (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
"""Tests for session queue clear() user_id scoping."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_queue(mock_invoker: Invoker) -> SqliteSessionQueue:
|
||||
"""Create a SqliteSessionQueue backed by the mock invoker's in-memory database."""
|
||||
db = mock_invoker.services.board_records._db
|
||||
queue = SqliteSessionQueue(db=db)
|
||||
queue.start(mock_invoker)
|
||||
return queue
|
||||
|
||||
|
||||
def _insert_queue_item(session_queue: SqliteSessionQueue, queue_id: str, user_id: str) -> None:
|
||||
"""Directly insert a minimal queue item for the given user."""
|
||||
session_id = str(uuid.uuid4())
|
||||
batch_id = str(uuid.uuid4())
|
||||
with session_queue._db.transaction() as cursor:
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination, retried_from_item_id, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(queue_id, "{}", session_id, batch_id, None, 0, None, None, None, None, user_id),
|
||||
)
|
||||
|
||||
|
||||
def _count_items(session_queue: SqliteSessionQueue, queue_id: str, user_id: str | None = None) -> int:
|
||||
"""Count items in the queue, optionally filtered by user_id."""
|
||||
with session_queue._db.transaction() as cursor:
|
||||
if user_id is not None:
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) FROM session_queue WHERE queue_id = ? AND user_id = ?",
|
||||
(queue_id, user_id),
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) FROM session_queue WHERE queue_id = ?",
|
||||
(queue_id,),
|
||||
)
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
|
||||
def test_clear_with_user_id_only_deletes_own_items(session_queue: SqliteSessionQueue) -> None:
|
||||
"""Non-admin clear (user_id provided) should only remove that user's items."""
|
||||
queue_id = "default"
|
||||
user_a = "user_a"
|
||||
user_b = "user_b"
|
||||
|
||||
_insert_queue_item(session_queue, queue_id, user_a)
|
||||
_insert_queue_item(session_queue, queue_id, user_a)
|
||||
_insert_queue_item(session_queue, queue_id, user_b)
|
||||
|
||||
result = session_queue.clear(queue_id, user_id=user_a)
|
||||
|
||||
assert result.deleted == 2
|
||||
assert _count_items(session_queue, queue_id, user_a) == 0
|
||||
assert _count_items(session_queue, queue_id, user_b) == 1
|
||||
|
||||
|
||||
def test_clear_without_user_id_deletes_all_items(session_queue: SqliteSessionQueue) -> None:
|
||||
"""Admin clear (no user_id) should remove all items in the queue."""
|
||||
queue_id = "default"
|
||||
|
||||
_insert_queue_item(session_queue, queue_id, "user_a")
|
||||
_insert_queue_item(session_queue, queue_id, "user_b")
|
||||
_insert_queue_item(session_queue, queue_id, "user_c")
|
||||
|
||||
result = session_queue.clear(queue_id)
|
||||
|
||||
assert result.deleted == 3
|
||||
assert _count_items(session_queue, queue_id) == 0
|
||||
|
||||
|
||||
def test_clear_with_user_id_does_not_affect_other_queues(session_queue: SqliteSessionQueue) -> None:
|
||||
"""Clearing one queue should not affect items in another queue."""
|
||||
queue_a = "queue_a"
|
||||
queue_b = "queue_b"
|
||||
user_id = "user_x"
|
||||
|
||||
_insert_queue_item(session_queue, queue_a, user_id)
|
||||
_insert_queue_item(session_queue, queue_b, user_id)
|
||||
|
||||
result = session_queue.clear(queue_a, user_id=user_id)
|
||||
|
||||
assert result.deleted == 1
|
||||
assert _count_items(session_queue, queue_a) == 0
|
||||
assert _count_items(session_queue, queue_b) == 1
|
||||
|
||||
|
||||
def test_clear_returns_zero_when_no_matching_items(session_queue: SqliteSessionQueue) -> None:
|
||||
"""Clear should return 0 deleted when there are no items for the given user."""
|
||||
queue_id = "default"
|
||||
|
||||
_insert_queue_item(session_queue, queue_id, "user_b")
|
||||
|
||||
result = session_queue.clear(queue_id, user_id="user_a")
|
||||
|
||||
assert result.deleted == 0
|
||||
assert _count_items(session_queue, queue_id) == 1
|
||||
@@ -0,0 +1,293 @@
|
||||
"""Tests for session queue dequeue() ordering: FIFO and round-robin modes."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
from pydantic_core import to_jsonable_python
|
||||
|
||||
from invokeai.app.services.config.config_default import InvokeAIAppConfig
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.session_queue.session_queue_sqlite import (
|
||||
ROUND_ROBIN_DEQUEUE_QUERY,
|
||||
SqliteSessionQueue,
|
||||
)
|
||||
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
|
||||
|
||||
_EMPTY_SESSION_JSON = json.dumps(to_jsonable_python(GraphExecutionState(graph=Graph()).model_dump()))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_queue_fifo(mock_invoker: Invoker) -> SqliteSessionQueue:
|
||||
"""Queue backed by a single-user (FIFO) invoker."""
|
||||
# Default config has multiuser=False, so FIFO is always used.
|
||||
db = mock_invoker.services.board_records._db
|
||||
queue = SqliteSessionQueue(db=db)
|
||||
queue.start(mock_invoker)
|
||||
return queue
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_queue_round_robin(mock_invoker: Invoker) -> SqliteSessionQueue:
|
||||
"""Queue backed by a multiuser invoker with round_robin mode."""
|
||||
mock_invoker.services.configuration = InvokeAIAppConfig(
|
||||
use_memory_db=True,
|
||||
node_cache_size=0,
|
||||
multiuser=True,
|
||||
session_queue_mode="round_robin",
|
||||
)
|
||||
db = mock_invoker.services.board_records._db
|
||||
queue = SqliteSessionQueue(db=db)
|
||||
queue.start(mock_invoker)
|
||||
return queue
|
||||
|
||||
|
||||
def _insert_queue_item(
|
||||
session_queue: SqliteSessionQueue,
|
||||
queue_id: str,
|
||||
user_id: str,
|
||||
priority: int = 0,
|
||||
) -> int:
|
||||
"""Directly insert a minimal queue item and return its item_id."""
|
||||
session_id = str(uuid.uuid4())
|
||||
batch_id = str(uuid.uuid4())
|
||||
with session_queue._db.transaction() as cursor:
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination, retried_from_item_id, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(queue_id, _EMPTY_SESSION_JSON, session_id, batch_id, None, priority, None, None, None, None, user_id),
|
||||
)
|
||||
return cursor.lastrowid # type: ignore[return-value]
|
||||
|
||||
|
||||
def _dequeue_user_ids(session_queue: SqliteSessionQueue, count: int) -> list[Optional[str]]:
|
||||
"""Dequeue `count` items and return the list of user_ids in dequeue order."""
|
||||
result = []
|
||||
for _ in range(count):
|
||||
item = session_queue.dequeue()
|
||||
result.append(item.user_id if item is not None else None)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FIFO tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fifo_single_user_order(session_queue_fifo: SqliteSessionQueue) -> None:
|
||||
"""FIFO: items from a single user are dequeued in insertion order."""
|
||||
queue_id = "default"
|
||||
_insert_queue_item(session_queue_fifo, queue_id, "user_a")
|
||||
_insert_queue_item(session_queue_fifo, queue_id, "user_a")
|
||||
_insert_queue_item(session_queue_fifo, queue_id, "user_a")
|
||||
|
||||
user_ids = _dequeue_user_ids(session_queue_fifo, 3)
|
||||
assert user_ids == ["user_a", "user_a", "user_a"]
|
||||
|
||||
|
||||
def test_fifo_multi_user_preserves_insertion_order(session_queue_fifo: SqliteSessionQueue) -> None:
|
||||
"""FIFO: jobs from multiple users are dequeued in strict insertion order, not interleaved."""
|
||||
queue_id = "default"
|
||||
# Insert A1, A2, B1, C1, C2, A3 – FIFO should preserve this exact order.
|
||||
_insert_queue_item(session_queue_fifo, queue_id, "user_a")
|
||||
_insert_queue_item(session_queue_fifo, queue_id, "user_a")
|
||||
_insert_queue_item(session_queue_fifo, queue_id, "user_b")
|
||||
_insert_queue_item(session_queue_fifo, queue_id, "user_c")
|
||||
_insert_queue_item(session_queue_fifo, queue_id, "user_c")
|
||||
_insert_queue_item(session_queue_fifo, queue_id, "user_a")
|
||||
|
||||
user_ids = _dequeue_user_ids(session_queue_fifo, 6)
|
||||
assert user_ids == ["user_a", "user_a", "user_b", "user_c", "user_c", "user_a"]
|
||||
|
||||
|
||||
def test_fifo_priority_respected(session_queue_fifo: SqliteSessionQueue) -> None:
|
||||
"""FIFO: higher-priority items are dequeued before lower-priority ones."""
|
||||
queue_id = "default"
|
||||
_insert_queue_item(session_queue_fifo, queue_id, "user_a", priority=0)
|
||||
_insert_queue_item(session_queue_fifo, queue_id, "user_a", priority=10)
|
||||
|
||||
user_ids = _dequeue_user_ids(session_queue_fifo, 2)
|
||||
# Both are user_a; second inserted item has higher priority and should come first.
|
||||
assert user_ids == ["user_a", "user_a"]
|
||||
|
||||
|
||||
def test_fifo_returns_none_when_empty(session_queue_fifo: SqliteSessionQueue) -> None:
|
||||
"""FIFO: dequeue returns None when the queue is empty."""
|
||||
assert session_queue_fifo.dequeue() is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Round-robin tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_round_robin_interleaves_users(session_queue_round_robin: SqliteSessionQueue) -> None:
|
||||
"""Round-robin: jobs from multiple users are interleaved one per user per round.
|
||||
|
||||
Queue insertion order (matching the issue example):
|
||||
A job 1, A job 2, B job 1, C job 1, C job 2, A job 3
|
||||
|
||||
Expected dequeue order:
|
||||
A job 1, B job 1, C job 1, A job 2, C job 2, A job 3
|
||||
"""
|
||||
queue_id = "default"
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_a")
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_a")
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_b")
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_c")
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_c")
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_a")
|
||||
|
||||
user_ids = _dequeue_user_ids(session_queue_round_robin, 6)
|
||||
assert user_ids == ["user_a", "user_b", "user_c", "user_a", "user_c", "user_a"]
|
||||
|
||||
|
||||
def test_round_robin_single_user_behaves_like_fifo(session_queue_round_robin: SqliteSessionQueue) -> None:
|
||||
"""Round-robin with only one user produces the same order as FIFO."""
|
||||
queue_id = "default"
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_a")
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_a")
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_a")
|
||||
|
||||
user_ids = _dequeue_user_ids(session_queue_round_robin, 3)
|
||||
assert user_ids == ["user_a", "user_a", "user_a"]
|
||||
|
||||
|
||||
def test_round_robin_handles_user_joining_mid_queue(session_queue_round_robin: SqliteSessionQueue) -> None:
|
||||
"""Round-robin: a user who joins later is correctly interleaved."""
|
||||
queue_id = "default"
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_a")
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_a")
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_b")
|
||||
|
||||
user_ids = _dequeue_user_ids(session_queue_round_robin, 3)
|
||||
# Round 1: A (oldest rank-1 item), B (rank-1 item)
|
||||
# Round 2: A (rank-2 item)
|
||||
assert user_ids == ["user_a", "user_b", "user_a"]
|
||||
|
||||
|
||||
def test_round_robin_returns_none_when_empty(session_queue_round_robin: SqliteSessionQueue) -> None:
|
||||
"""Round-robin: dequeue returns None when the queue is empty."""
|
||||
assert session_queue_round_robin.dequeue() is None
|
||||
|
||||
|
||||
def test_round_robin_priority_within_user_respected(session_queue_round_robin: SqliteSessionQueue) -> None:
|
||||
"""Round-robin: within a single user's items, higher priority is dequeued first."""
|
||||
queue_id = "default"
|
||||
# Insert low-priority item first, then high-priority for same user.
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_a", priority=0)
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_a", priority=10)
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_b", priority=0)
|
||||
|
||||
# Round 1: user_a's best item (priority 10), user_b's only item.
|
||||
# Round 2: user_a's remaining item (priority 0).
|
||||
items = []
|
||||
for _ in range(3):
|
||||
item = session_queue_round_robin.dequeue()
|
||||
assert item is not None
|
||||
items.append((item.user_id, item.priority))
|
||||
|
||||
assert items[0] == ("user_a", 10)
|
||||
assert items[1] == ("user_b", 0)
|
||||
assert items[2] == ("user_a", 0)
|
||||
|
||||
|
||||
def _seed_completed_history(
|
||||
session_queue: SqliteSessionQueue,
|
||||
queue_id: str,
|
||||
user_id: str,
|
||||
count: int,
|
||||
) -> None:
|
||||
"""Insert `count` completed items (with started_at set) for a user, simulating retained history."""
|
||||
with session_queue._db.transaction() as cursor:
|
||||
for i in range(count):
|
||||
session_id = str(uuid.uuid4())
|
||||
batch_id = str(uuid.uuid4())
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
INSERT INTO session_queue
|
||||
(queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination, retried_from_item_id, user_id, status, started_at, completed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'completed', ?, ?)
|
||||
""",
|
||||
(
|
||||
queue_id,
|
||||
_EMPTY_SESSION_JSON,
|
||||
session_id,
|
||||
batch_id,
|
||||
None,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
user_id,
|
||||
# Monotonically increasing timestamps so MAX(started_at) is well-defined per user.
|
||||
f"2026-01-01 {i // 3600 % 24:02d}:{i // 60 % 60:02d}:{i % 60:02d}",
|
||||
f"2026-01-01 {i // 3600 % 24:02d}:{i // 60 % 60:02d}:{i % 60:02d}",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_round_robin_dequeue_does_not_scan_full_history(session_queue_round_robin: SqliteSessionQueue) -> None:
|
||||
"""Round-robin dequeue cost must scale with active users, not retained queue history.
|
||||
|
||||
Regression guard for the scaling concern: the per-user "last served" lookup must be an
|
||||
indexed seek (MAX(started_at) WHERE user_id = ?) rather than a GROUP BY / scan over every
|
||||
historical started row. `max_queue_history` is unbounded by default, so a plan that scans
|
||||
the full history makes each dequeue O(total history) instead of O(active users).
|
||||
|
||||
We seed a large completed history across several users plus a few pending items, then assert
|
||||
the dequeue query plan never scans the `session_queue` base table and resolves the
|
||||
last-served lookup via a seek on `idx_session_queue_user_started_at`.
|
||||
"""
|
||||
queue_id = "default"
|
||||
for u in ("user_a", "user_b", "user_c"):
|
||||
_seed_completed_history(session_queue_round_robin, queue_id, u, count=500)
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, u)
|
||||
|
||||
with session_queue_round_robin._db.transaction() as cursor:
|
||||
plan_rows = cursor.execute("EXPLAIN QUERY PLAN " + ROUND_ROBIN_DEQUEUE_QUERY).fetchall()
|
||||
details = [row["detail"] for row in plan_rows]
|
||||
|
||||
# No step may scan the session_queue base table — that is the full-history scan we are
|
||||
# eliminating. (CTE result scans like "SCAN uni" / "SCAN (subquery-N)" are fine; those are
|
||||
# one row per pending user.)
|
||||
offending = [d for d in details if d.startswith("SCAN session_queue")]
|
||||
assert not offending, f"dequeue plan scans full queue history: {offending}\nfull plan: {details}"
|
||||
|
||||
# The last-served lookup must use the started_at index as a per-user seek.
|
||||
assert any("idx_session_queue_user_started_at" in d and "user_id=?" in d for d in details), (
|
||||
f"last-served lookup is not an indexed seek; plan: {details}"
|
||||
)
|
||||
|
||||
# And the dequeue must still return the least-recently-served user (correctness under history).
|
||||
# user_a's history ends earliest only if seeded first; all three were seeded equal counts with
|
||||
# identical timestamps, so item_id ASC tie-breaks to the first-inserted pending item (user_a).
|
||||
item = session_queue_round_robin.dequeue()
|
||||
assert item is not None
|
||||
assert item.user_id == "user_a"
|
||||
|
||||
|
||||
def test_round_robin_ignored_in_single_user_mode(mock_invoker: Invoker) -> None:
|
||||
"""When multiuser=False, round_robin config is ignored and FIFO is used."""
|
||||
mock_invoker.services.configuration = InvokeAIAppConfig(
|
||||
use_memory_db=True,
|
||||
node_cache_size=0,
|
||||
multiuser=False,
|
||||
session_queue_mode="round_robin",
|
||||
)
|
||||
db = mock_invoker.services.board_records._db
|
||||
queue = SqliteSessionQueue(db=db)
|
||||
queue.start(mock_invoker)
|
||||
|
||||
queue_id = "default"
|
||||
_insert_queue_item(queue, queue_id, "user_a")
|
||||
_insert_queue_item(queue, queue_id, "user_a")
|
||||
_insert_queue_item(queue, queue_id, "user_b")
|
||||
|
||||
# FIFO order: user_a, user_a, user_b
|
||||
user_ids = _dequeue_user_ids(queue, 3)
|
||||
assert user_ids == ["user_a", "user_a", "user_b"]
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Regression tests for the cross-user identifier leak in QueueItemStatusChangedEvent.
|
||||
|
||||
When user A's queue item changes status while user B's item is currently in_progress,
|
||||
the embedded SessionQueueStatus inside the event must NOT expose B's item_id,
|
||||
session_id, or batch_id. The full event ships to user:{A.user_id} and admin rooms,
|
||||
so unredacted fields would let owner A learn user B's identifiers.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
|
||||
from invokeai.app.services.events.events_common import QueueItemStatusChangedEvent
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.session_queue.session_queue_common import SessionQueueItem
|
||||
from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue
|
||||
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
|
||||
from tests.test_nodes import PromptTestInvocation, TestEventService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_queue(mock_invoker: Invoker) -> SqliteSessionQueue:
|
||||
db = mock_invoker.services.board_records._db
|
||||
queue = SqliteSessionQueue(db=db)
|
||||
queue.start(mock_invoker)
|
||||
return queue
|
||||
|
||||
|
||||
def _insert_queue_item(session_queue: SqliteSessionQueue, user_id: str) -> int:
|
||||
graph = Graph()
|
||||
graph.add_node(PromptTestInvocation(id="prompt", prompt="test"))
|
||||
session = GraphExecutionState(graph=graph)
|
||||
session_json = session.model_dump_json(warnings=False, exclude_none=True)
|
||||
batch_id = str(uuid.uuid4())
|
||||
with session_queue._db.transaction() as cursor:
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
INSERT INTO session_queue (
|
||||
queue_id, session, session_id, batch_id, field_values,
|
||||
priority, workflow, origin, destination, retried_from_item_id, user_id
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
("default", session_json, session.id, batch_id, None, 0, None, None, None, None, user_id),
|
||||
)
|
||||
return cursor.lastrowid # type: ignore[return-value]
|
||||
|
||||
|
||||
def _insert_waiting_workflow_call_parent(
|
||||
session_queue: SqliteSessionQueue, user_id: str
|
||||
) -> tuple[int, GraphExecutionState]:
|
||||
parent_graph = Graph()
|
||||
parent_graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a"))
|
||||
parent_session = GraphExecutionState(graph=parent_graph)
|
||||
invocation = parent_session.next()
|
||||
assert isinstance(invocation, CallSavedWorkflowInvocation)
|
||||
|
||||
frame = parent_session.build_workflow_call_frame(invocation.id, invocation.workflow_id)
|
||||
child_session = parent_session.create_child_workflow_execution_state(Graph(), frame)
|
||||
parent_session.begin_waiting_on_workflow_call(frame)
|
||||
parent_session.attach_waiting_workflow_call_child_session(child_session)
|
||||
|
||||
batch_id = str(uuid.uuid4())
|
||||
with session_queue._db.transaction() as cursor:
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
INSERT INTO session_queue (
|
||||
queue_id, session, session_id, batch_id, field_values,
|
||||
priority, workflow, origin, destination, retried_from_item_id, user_id, status
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"default",
|
||||
parent_session.model_dump_json(warnings=False, exclude_none=True),
|
||||
parent_session.id,
|
||||
batch_id,
|
||||
None,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
user_id,
|
||||
"waiting",
|
||||
),
|
||||
)
|
||||
return cursor.lastrowid, child_session # type: ignore[return-value]
|
||||
|
||||
|
||||
def _last_status_event_for_item(event_bus: TestEventService, item_id: int) -> QueueItemStatusChangedEvent:
|
||||
matches = [e for e in event_bus.events if isinstance(e, QueueItemStatusChangedEvent) and e.item_id == item_id]
|
||||
assert matches, f"No QueueItemStatusChangedEvent found for item {item_id}"
|
||||
return matches[-1]
|
||||
|
||||
|
||||
def test_event_redacts_other_users_current_item_identifiers(
|
||||
session_queue: SqliteSessionQueue, mock_invoker: Invoker
|
||||
) -> None:
|
||||
"""When user A's pending item is canceled while user B's item is in_progress, the
|
||||
embedded queue_status in A's status-changed event must not expose B's identifiers."""
|
||||
user_a = "user-a"
|
||||
user_b = "user-b"
|
||||
|
||||
a_item_id = _insert_queue_item(session_queue, user_id=user_a)
|
||||
b_item_id = _insert_queue_item(session_queue, user_id=user_b)
|
||||
|
||||
# Make user B's item the in-progress one. We must dequeue B first; FIFO would dequeue A
|
||||
# because it was inserted first, so reverse the insertion: cancel A's, re-insert as new.
|
||||
# Simpler: dequeue twice. First dequeue picks A (older); promote B by inserting in
|
||||
# right order means we need B to be the in_progress item when A's event fires.
|
||||
# Cancel A first to make it ineligible, then dequeue B.
|
||||
# Actually we need A to be pending when its status changes — so we must dequeue B first.
|
||||
# Re-do: insert B BEFORE A by temporarily inserting A second. Recreate cleanly:
|
||||
session_queue.delete_queue_item(a_item_id)
|
||||
session_queue.delete_queue_item(b_item_id)
|
||||
b_item_id = _insert_queue_item(session_queue, user_id=user_b)
|
||||
a_item_id = _insert_queue_item(session_queue, user_id=user_a)
|
||||
|
||||
in_progress = session_queue.dequeue()
|
||||
assert in_progress is not None and in_progress.item_id == b_item_id
|
||||
assert in_progress.user_id == user_b
|
||||
|
||||
event_bus: TestEventService = mock_invoker.services.events
|
||||
event_bus.events.clear()
|
||||
|
||||
# Now cancel user A's pending item. The emitted event for A must not leak B's
|
||||
# current-item identifiers via the embedded queue_status.
|
||||
canceled = session_queue.cancel_queue_item(a_item_id)
|
||||
assert canceled.user_id == user_a
|
||||
|
||||
a_event = _last_status_event_for_item(event_bus, a_item_id)
|
||||
assert a_event.user_id == user_a
|
||||
assert a_event.queue_status.item_id is None, "must not leak other user's current item_id"
|
||||
assert a_event.queue_status.session_id is None, "must not leak other user's current session_id"
|
||||
assert a_event.queue_status.batch_id is None, "must not leak other user's current batch_id"
|
||||
# Aggregate counts in the embedded status are global and OK to share.
|
||||
assert a_event.queue_status.in_progress == 1
|
||||
assert a_event.queue_status.canceled == 1
|
||||
|
||||
|
||||
def test_event_preserves_owner_current_item_identifiers(
|
||||
session_queue: SqliteSessionQueue, mock_invoker: Invoker
|
||||
) -> None:
|
||||
"""When the current in-progress item belongs to the same user as the changed item, the
|
||||
embedded queue_status must continue to expose the identifiers (no over-redaction)."""
|
||||
user_a = "user-a"
|
||||
|
||||
a_item_id = _insert_queue_item(session_queue, user_id=user_a)
|
||||
|
||||
in_progress = session_queue.dequeue()
|
||||
assert in_progress is not None and in_progress.item_id == a_item_id
|
||||
|
||||
event_bus: TestEventService = mock_invoker.services.events
|
||||
event_bus.events.clear()
|
||||
|
||||
completed = session_queue.complete_queue_item(a_item_id)
|
||||
assert completed.user_id == user_a
|
||||
|
||||
# The event for A's transition fires AFTER the row is marked completed, so by the time
|
||||
# _set_queue_item_status reads get_current it returns None — there is no in-progress
|
||||
# item to leak. queue_status fields should therefore be None.
|
||||
a_event = _last_status_event_for_item(event_bus, a_item_id)
|
||||
assert a_event.user_id == user_a
|
||||
assert a_event.queue_status.item_id is None # no in-progress item at all
|
||||
assert a_event.queue_status.completed == 1
|
||||
|
||||
|
||||
def test_event_redacts_when_current_item_disappears_between_reads(
|
||||
session_queue: SqliteSessionQueue,
|
||||
mock_invoker: Invoker,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Regression: _set_queue_item_status reads get_current twice — once via
|
||||
get_queue_status (which embeds the in-progress item's identifiers into the
|
||||
SessionQueueStatus) and once again to decide whether to redact those
|
||||
identifiers. The two reads are not atomic. If user B is the in-progress
|
||||
item when the first read happens, and B then completes/cancels before the
|
||||
second read, the redaction guard sees current_item is None and skips
|
||||
scrubbing — leaving B's item_id, session_id, and batch_id in the event
|
||||
sent to user A. The fix must make the redaction decision derive from the
|
||||
same snapshot that supplied the embedded identifiers."""
|
||||
user_a = "user-a"
|
||||
user_b = "user-b"
|
||||
|
||||
b_item_id = _insert_queue_item(session_queue, user_id=user_b)
|
||||
a_item_id = _insert_queue_item(session_queue, user_id=user_a)
|
||||
|
||||
in_progress = session_queue.dequeue()
|
||||
assert in_progress is not None and in_progress.item_id == b_item_id
|
||||
assert in_progress.user_id == user_b
|
||||
|
||||
real_get_current = session_queue.get_current
|
||||
b_snapshot = real_get_current(queue_id="default")
|
||||
assert b_snapshot is not None and b_snapshot.user_id == user_b
|
||||
|
||||
# Simulate the race: the read inside get_queue_status sees B's in-progress
|
||||
# item; the redaction read returns None as if B finished in between.
|
||||
call_count = {"n": 0}
|
||||
|
||||
def racey_get_current(queue_id: str) -> Optional[SessionQueueItem]:
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
return b_snapshot
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(session_queue, "get_current", racey_get_current)
|
||||
|
||||
event_bus: TestEventService = mock_invoker.services.events
|
||||
event_bus.events.clear()
|
||||
|
||||
canceled = session_queue.cancel_queue_item(a_item_id)
|
||||
assert canceled.user_id == user_a
|
||||
# The patched read must have been consulted at least once. The pre-fix
|
||||
# implementation made two reads (embedding + redaction) and leaked when
|
||||
# the second returned None; the fixed implementation makes a single read
|
||||
# whose snapshot drives both embedding and redaction. Either way, the
|
||||
# invariant below is the one that matters.
|
||||
assert call_count["n"] >= 1
|
||||
|
||||
a_event = _last_status_event_for_item(event_bus, a_item_id)
|
||||
assert a_event.user_id == user_a
|
||||
assert a_event.queue_status.item_id is None, (
|
||||
"race-window leak: other user's item_id survived because the second "
|
||||
"get_current() returned None and the redaction guard was skipped"
|
||||
)
|
||||
assert a_event.queue_status.session_id is None, "race-window leak of session_id"
|
||||
assert a_event.queue_status.batch_id is None, "race-window leak of batch_id"
|
||||
|
||||
|
||||
def test_event_preserves_identifiers_when_current_item_is_the_changed_item(
|
||||
session_queue: SqliteSessionQueue, mock_invoker: Invoker
|
||||
) -> None:
|
||||
"""The dequeue() transition makes the changed item itself the in-progress current item.
|
||||
queue_status must expose its identifiers since they belong to the event's owner."""
|
||||
user_a = "user-a"
|
||||
a_item_id = _insert_queue_item(session_queue, user_id=user_a)
|
||||
|
||||
event_bus: TestEventService = mock_invoker.services.events
|
||||
event_bus.events.clear()
|
||||
|
||||
in_progress = session_queue.dequeue()
|
||||
assert in_progress is not None and in_progress.item_id == a_item_id
|
||||
|
||||
a_event = _last_status_event_for_item(event_bus, a_item_id)
|
||||
assert a_event.status == "in_progress"
|
||||
assert a_event.user_id == user_a
|
||||
# Current item == changed item == owned by user_a → no redaction
|
||||
assert a_event.queue_status.item_id == a_item_id
|
||||
assert a_event.queue_status.session_id == in_progress.session_id
|
||||
assert a_event.queue_status.batch_id == in_progress.batch_id
|
||||
|
||||
|
||||
def test_workflow_call_child_enqueue_event_redacts_other_users_current_item_identifiers(
|
||||
session_queue: SqliteSessionQueue, mock_invoker: Invoker
|
||||
) -> None:
|
||||
"""The child enqueue path emits QueueItemStatusChangedEvent without going through
|
||||
_set_queue_item_status, so it must apply the same per-owner current-item redaction."""
|
||||
user_a = "user-a"
|
||||
user_b = "user-b"
|
||||
|
||||
b_item_id = _insert_queue_item(session_queue, user_id=user_b)
|
||||
parent_item_id, child_session = _insert_waiting_workflow_call_parent(session_queue, user_id=user_a)
|
||||
|
||||
in_progress = session_queue.dequeue()
|
||||
assert in_progress is not None and in_progress.item_id == b_item_id
|
||||
assert in_progress.user_id == user_b
|
||||
|
||||
event_bus: TestEventService = mock_invoker.services.events
|
||||
event_bus.events.clear()
|
||||
|
||||
parent_queue_item = session_queue.get_queue_item(parent_item_id)
|
||||
child_queue_item = session_queue.enqueue_workflow_call_child(parent_queue_item, child_session)
|
||||
|
||||
child_event = _last_status_event_for_item(event_bus, child_queue_item.item_id)
|
||||
assert child_event.user_id == user_a
|
||||
assert child_event.queue_status.item_id is None, "must not leak other user's current item_id"
|
||||
assert child_event.queue_status.session_id is None, "must not leak other user's current session_id"
|
||||
assert child_event.queue_status.batch_id is None, "must not leak other user's current batch_id"
|
||||
assert child_event.queue_status.in_progress == 1
|
||||
assert child_event.queue_status.waiting == 1
|
||||
@@ -0,0 +1,103 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from invokeai.app.services.events.events_common import QueueItemStatusChangedEvent
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue
|
||||
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
|
||||
from tests.test_nodes import PromptTestInvocation, TestEventService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_queue(mock_invoker: Invoker) -> SqliteSessionQueue:
|
||||
db = mock_invoker.services.board_records._db
|
||||
queue = SqliteSessionQueue(db=db)
|
||||
queue.start(mock_invoker)
|
||||
return queue
|
||||
|
||||
|
||||
def _insert_queue_item(
|
||||
session_queue: SqliteSessionQueue,
|
||||
queue_id: str = "default",
|
||||
destination: str | None = None,
|
||||
) -> int:
|
||||
graph = Graph()
|
||||
graph.add_node(PromptTestInvocation(id="prompt", prompt="test"))
|
||||
session = GraphExecutionState(graph=graph)
|
||||
session_json = session.model_dump_json(warnings=False, exclude_none=True)
|
||||
batch_id = str(uuid.uuid4())
|
||||
with session_queue._db.transaction() as cursor:
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
INSERT INTO session_queue (
|
||||
queue_id,
|
||||
session,
|
||||
session_id,
|
||||
batch_id,
|
||||
field_values,
|
||||
priority,
|
||||
workflow,
|
||||
origin,
|
||||
destination,
|
||||
retried_from_item_id,
|
||||
user_id
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(queue_id, session_json, session.id, batch_id, None, 0, None, None, destination, None, "system"),
|
||||
)
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
def test_status_sequence_increments_for_queue_item_lifecycle(
|
||||
session_queue: SqliteSessionQueue, mock_invoker: Invoker
|
||||
) -> None:
|
||||
item_id = _insert_queue_item(session_queue)
|
||||
|
||||
pending_item = session_queue.get_queue_item(item_id)
|
||||
assert pending_item.status == "pending"
|
||||
assert pending_item.status_sequence == 0
|
||||
|
||||
in_progress_item = session_queue.dequeue()
|
||||
assert in_progress_item is not None
|
||||
assert in_progress_item.item_id == item_id
|
||||
assert in_progress_item.status == "in_progress"
|
||||
assert in_progress_item.status_sequence == 1
|
||||
|
||||
completed_item = session_queue.complete_queue_item(item_id)
|
||||
assert completed_item.status == "completed"
|
||||
assert completed_item.status_sequence == 2
|
||||
|
||||
event_bus: TestEventService = mock_invoker.services.events
|
||||
status_events = [event for event in event_bus.events if isinstance(event, QueueItemStatusChangedEvent)]
|
||||
|
||||
assert len(status_events) == 2
|
||||
assert [event.status for event in status_events] == ["in_progress", "completed"]
|
||||
assert [event.status_sequence for event in status_events] == [1, 2]
|
||||
|
||||
|
||||
def test_status_sequence_increments_for_bulk_cancel_paths(session_queue: SqliteSessionQueue) -> None:
|
||||
first_item_id = _insert_queue_item(session_queue)
|
||||
second_item_id = _insert_queue_item(session_queue)
|
||||
|
||||
result = session_queue.cancel_all_except_current("default")
|
||||
|
||||
assert result.canceled == 2
|
||||
assert session_queue.get_queue_item(first_item_id).status == "canceled"
|
||||
assert session_queue.get_queue_item(first_item_id).status_sequence == 1
|
||||
assert session_queue.get_queue_item(second_item_id).status == "canceled"
|
||||
assert session_queue.get_queue_item(second_item_id).status_sequence == 1
|
||||
|
||||
|
||||
def test_status_sequence_continues_after_dequeue_then_cancel(session_queue: SqliteSessionQueue) -> None:
|
||||
item_id = _insert_queue_item(session_queue)
|
||||
|
||||
in_progress_item = session_queue.dequeue()
|
||||
assert in_progress_item is not None
|
||||
assert in_progress_item.item_id == item_id
|
||||
assert in_progress_item.status_sequence == 1
|
||||
|
||||
canceled_item = session_queue.cancel_queue_item(item_id)
|
||||
assert canceled_item.status == "canceled"
|
||||
assert canceled_item.status_sequence == 2
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Regression tests for multiuser queue status / list scoping.
|
||||
|
||||
The queue badge in multiuser mode shows "X/Y" where X is the requesting user's own
|
||||
pending+in_progress jobs and Y is the global total across all users. For this to work,
|
||||
get_queue_status must report GLOBAL aggregate counts and ADDITIONALLY return the requesting
|
||||
user's own counts in user_pending/user_in_progress.
|
||||
|
||||
Separately, the virtualized queue list fetches ids via get_queue_item_ids and hydrates them
|
||||
via get_queue_items_by_item_ids (which redacts other users' items). So get_queue_item_ids must
|
||||
return every user's ids — otherwise a non-admin never sees the (redacted) entries belonging to
|
||||
other users.
|
||||
|
||||
A regression (#9018) scoped both of these to the calling user, which (a) collapsed the badge to
|
||||
a single own-count and (b) hid other users' redacted entries entirely. These tests guard the
|
||||
restored behavior.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue
|
||||
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
|
||||
from tests.test_nodes import PromptTestInvocation
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_queue(mock_invoker: Invoker) -> SqliteSessionQueue:
|
||||
db = mock_invoker.services.board_records._db
|
||||
queue = SqliteSessionQueue(db=db)
|
||||
queue.start(mock_invoker)
|
||||
return queue
|
||||
|
||||
|
||||
def _insert_queue_item(session_queue: SqliteSessionQueue, user_id: str) -> int:
|
||||
graph = Graph()
|
||||
graph.add_node(PromptTestInvocation(id="prompt", prompt="test"))
|
||||
session = GraphExecutionState(graph=graph)
|
||||
session_json = session.model_dump_json(warnings=False, exclude_none=True)
|
||||
batch_id = str(uuid.uuid4())
|
||||
with session_queue._db.transaction() as cursor:
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
INSERT INTO session_queue (
|
||||
queue_id, session, session_id, batch_id, field_values,
|
||||
priority, workflow, origin, destination, retried_from_item_id, user_id
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
("default", session_json, session.id, batch_id, None, 0, None, None, None, None, user_id),
|
||||
)
|
||||
return cursor.lastrowid # type: ignore[return-value]
|
||||
|
||||
|
||||
def test_status_aggregate_counts_are_global_with_user_subcounts(session_queue: SqliteSessionQueue) -> None:
|
||||
"""A non-admin caller (user_id set) sees global aggregate counts plus their own subcounts."""
|
||||
user_a = "user-a"
|
||||
user_b = "user-b"
|
||||
_insert_queue_item(session_queue, user_id=user_a)
|
||||
_insert_queue_item(session_queue, user_id=user_a)
|
||||
_insert_queue_item(session_queue, user_id=user_b)
|
||||
|
||||
status = session_queue.get_queue_status(queue_id="default", user_id=user_a)
|
||||
|
||||
# Global counts span every user's pending items.
|
||||
assert status.pending == 3
|
||||
assert status.total == 3
|
||||
# Per-user subcounts reflect only user A's items → badge renders "2/3".
|
||||
assert status.user_pending == 2
|
||||
assert status.user_in_progress == 0
|
||||
|
||||
|
||||
def test_status_admin_global_call_omits_user_subcounts(session_queue: SqliteSessionQueue) -> None:
|
||||
"""An admin/global caller (user_id=None) gets global counts and no per-user subcounts."""
|
||||
_insert_queue_item(session_queue, user_id="user-a")
|
||||
_insert_queue_item(session_queue, user_id="user-b")
|
||||
|
||||
status = session_queue.get_queue_status(queue_id="default")
|
||||
|
||||
assert status.pending == 2
|
||||
assert status.total == 2
|
||||
assert status.user_pending is None
|
||||
assert status.user_in_progress is None
|
||||
|
||||
|
||||
def test_status_current_item_redacted_for_non_owner_but_counts_global(session_queue: SqliteSessionQueue) -> None:
|
||||
"""When the in-progress item belongs to another user, its identifiers are hidden from a
|
||||
non-owner, but the aggregate counts (and the user's own subcounts) remain populated."""
|
||||
user_a = "user-a"
|
||||
user_b = "user-b"
|
||||
b_item_id = _insert_queue_item(session_queue, user_id=user_b)
|
||||
_insert_queue_item(session_queue, user_id=user_a)
|
||||
|
||||
in_progress = session_queue.dequeue()
|
||||
assert in_progress is not None and in_progress.item_id == b_item_id
|
||||
|
||||
status = session_queue.get_queue_status(queue_id="default", user_id=user_a)
|
||||
|
||||
# B's in-progress item identifiers are hidden from A.
|
||||
assert status.item_id is None
|
||||
assert status.session_id is None
|
||||
assert status.batch_id is None
|
||||
# But counts are still global, and A's own subcounts are present.
|
||||
assert status.in_progress == 1 # B's item, counted globally
|
||||
assert status.user_in_progress == 0 # A owns none in progress
|
||||
assert status.user_pending == 1 # A's single pending item
|
||||
|
||||
|
||||
def test_get_queue_item_ids_returns_all_users_ids(session_queue: SqliteSessionQueue) -> None:
|
||||
"""get_queue_item_ids returns ids for every user so the virtualized list can show the
|
||||
(redacted) entries belonging to other users. Redaction happens at hydration time."""
|
||||
a_item_id = _insert_queue_item(session_queue, user_id="user-a")
|
||||
b_item_id = _insert_queue_item(session_queue, user_id="user-b")
|
||||
|
||||
result = session_queue.get_queue_item_ids(queue_id="default")
|
||||
|
||||
assert set(result.item_ids) == {a_item_id, b_item_id}
|
||||
assert result.total_count == 2
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
||||
"""Tests for workflow-call retry semantics in the session queue."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from invokeai.app.services.events.events_common import QueueItemsRetriedEvent
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.session_queue.session_queue_common import SessionQueueItem
|
||||
from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue
|
||||
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
|
||||
from tests.test_nodes import TestEventService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_queue(mock_invoker: Invoker) -> SqliteSessionQueue:
|
||||
db = mock_invoker.services.board_records._db
|
||||
queue = SqliteSessionQueue(db=db)
|
||||
queue.start(mock_invoker)
|
||||
return queue
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def event_bus(mock_invoker: Invoker) -> TestEventService:
|
||||
assert isinstance(mock_invoker.services.events, TestEventService)
|
||||
return mock_invoker.services.events
|
||||
|
||||
|
||||
def _build_queue_item(
|
||||
*,
|
||||
item_id: int,
|
||||
session: GraphExecutionState,
|
||||
user_id: str,
|
||||
status: str,
|
||||
root_item_id: int | None = None,
|
||||
retried_from_item_id: int | None = None,
|
||||
) -> SessionQueueItem:
|
||||
now = datetime.now()
|
||||
return SessionQueueItem(
|
||||
item_id=item_id,
|
||||
status=status,
|
||||
priority=0,
|
||||
batch_id=f"batch-{item_id}",
|
||||
origin=None,
|
||||
destination=None,
|
||||
session_id=session.id,
|
||||
error_type=None,
|
||||
error_message=None,
|
||||
error_traceback=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
started_at=None,
|
||||
completed_at=None,
|
||||
queue_id="default",
|
||||
user_id=user_id,
|
||||
user_display_name=None,
|
||||
user_email=None,
|
||||
field_values=None,
|
||||
retried_from_item_id=retried_from_item_id,
|
||||
workflow_call_id=None,
|
||||
parent_item_id=None,
|
||||
parent_session_id=None,
|
||||
root_item_id=root_item_id,
|
||||
workflow_call_depth=None,
|
||||
session=session,
|
||||
workflow=None,
|
||||
)
|
||||
|
||||
|
||||
def test_retry_items_by_id_retries_root_once_for_child_chain_item(
|
||||
session_queue: SqliteSessionQueue, event_bus: TestEventService, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
root_session = GraphExecutionState(graph=Graph())
|
||||
child_session = GraphExecutionState(graph=Graph())
|
||||
|
||||
root_item = _build_queue_item(item_id=10, session=root_session, user_id="user-1", status="failed")
|
||||
child_item = _build_queue_item(
|
||||
item_id=11,
|
||||
session=child_session,
|
||||
user_id="user-1",
|
||||
status="failed",
|
||||
root_item_id=root_item.item_id,
|
||||
)
|
||||
|
||||
items = {root_item.item_id: root_item, child_item.item_id: child_item}
|
||||
monkeypatch.setattr(session_queue, "get_queue_item", lambda item_id: items[item_id])
|
||||
|
||||
retry_result = session_queue.retry_items_by_id("default", [child_item.item_id, root_item.item_id])
|
||||
|
||||
assert retry_result.retried_item_ids == [root_item.item_id]
|
||||
|
||||
all_items = session_queue.list_all_queue_items("default")
|
||||
retried_items = [item for item in all_items if item.retried_from_item_id == root_item.item_id]
|
||||
assert len(retried_items) == 1
|
||||
assert retried_items[0].status == "pending"
|
||||
assert retried_items[0].workflow_call_id is None
|
||||
assert retried_items[0].parent_item_id is None
|
||||
assert retried_items[0].root_item_id is None
|
||||
|
||||
retry_events = [event for event in event_bus.events if isinstance(event, QueueItemsRetriedEvent)]
|
||||
assert len(retry_events) == 1
|
||||
assert retry_events[0].retried_item_ids == [root_item.item_id]
|
||||
assert retry_events[0].user_ids == ["user-1"]
|
||||
assert retry_events[0].retried_item_ids_by_user == {"user-1": [root_item.item_id]}
|
||||
|
||||
|
||||
def test_retry_items_by_id_emits_unique_owner_ids_for_multiple_roots(
|
||||
session_queue: SqliteSessionQueue, event_bus: TestEventService, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
first_root_item = _build_queue_item(
|
||||
item_id=20, session=GraphExecutionState(graph=Graph()), user_id="user-1", status="failed"
|
||||
)
|
||||
second_root_item = _build_queue_item(
|
||||
item_id=21, session=GraphExecutionState(graph=Graph()), user_id="user-2", status="canceled"
|
||||
)
|
||||
|
||||
items = {
|
||||
first_root_item.item_id: first_root_item,
|
||||
second_root_item.item_id: second_root_item,
|
||||
}
|
||||
monkeypatch.setattr(session_queue, "get_queue_item", lambda item_id: items[item_id])
|
||||
|
||||
retry_result = session_queue.retry_items_by_id("default", [first_root_item.item_id, second_root_item.item_id])
|
||||
|
||||
assert retry_result.retried_item_ids == [first_root_item.item_id, second_root_item.item_id]
|
||||
|
||||
retry_events = [event for event in event_bus.events if isinstance(event, QueueItemsRetriedEvent)]
|
||||
assert len(retry_events) == 1
|
||||
assert retry_events[0].user_ids == ["user-1", "user-2"]
|
||||
assert retry_events[0].retried_item_ids_by_user == {
|
||||
"user-1": [first_root_item.item_id],
|
||||
"user-2": [second_root_item.item_id],
|
||||
}
|
||||
Reference in New Issue
Block a user