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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:22:06 +08:00
commit cddb07a176
3370 changed files with 685519 additions and 0 deletions
@@ -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],
}