Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
06b971c
Initial plan
Copilot Mar 10, 2026
ae281e9
feat: add round-robin session queue scheduling across users
Copilot Mar 10, 2026
ec59f0a
Merge branch 'main' into copilot/enhancement-round-robin-job-scheduling
lstein Apr 25, 2026
28c63b1
fix(multiuser): restore X/Y queue badge and cross-user queue list
lstein Apr 25, 2026
8179b9d
docs: regenerate settings.json for session_queue_mode
lstein Apr 25, 2026
9c27d09
Merge branch 'main' into copilot/enhancement-round-robin-job-scheduling
lstein May 9, 2026
aaa379b
fix(session_queue): restore user_pending/user_in_progress computation…
lstein May 9, 2026
0e936a8
Merge branch 'main' into copilot/enhancement-round-robin-job-scheduling
JPPhoto May 14, 2026
8a28a91
Merge branch 'main' into copilot/enhancement-round-robin-job-scheduling
JPPhoto May 14, 2026
ffe9f49
Merge branch 'main' into copilot/enhancement-round-robin-job-scheduling
lstein Jun 1, 2026
dc6d9ae
fix(multiuser): fix queue-status route, add regression test and round…
lstein Jun 1, 2026
866d692
chore(frontend): regenerate openapi and schema
lstein Jun 1, 2026
8533673
Merge branch 'main' into copilot/enhancement-round-robin-job-scheduling
lstein Jun 5, 2026
841fad8
Merge remote-tracking branch 'origin/main' into copilot/enhancement-r…
lstein Jun 25, 2026
c2907b5
Merge branch 'main' into copilot/enhancement-round-robin-job-scheduling
JPPhoto Jun 25, 2026
41e9df7
perf(session_queue): make round-robin dequeue scale with active users…
lstein Jun 26, 2026
58b06c6
style: apply ruff format to dequeue test assertion
lstein Jun 26, 2026
d50b58c
chore(doc): fix inline comment to match actual code
lstein Jun 27, 2026
165f4fb
Merge branch 'main' into copilot/enhancement-round-robin-job-scheduling
JPPhoto Jun 29, 2026
192d4ed
Merge branch 'main' into copilot/enhancement-round-robin-job-scheduling
lstein Jun 30, 2026
1704069
Merge branch 'main' into copilot/enhancement-round-robin-job-scheduling
lstein Jun 30, 2026
878db3a
fix(docs): authenticate GitHub changelog loader to avoid CI rate limits
lstein Jun 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/src/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export const collections = {
owner: 'invoke-ai',
repo: 'InvokeAI',
pagefind: false,
// Authenticate GitHub API requests so the release changelog loader uses
// the 5000 req/hr authenticated rate limit instead of the 60 req/hr
// unauthenticated limit (shared per CI runner IP), which causes
// intermittent "403 - rate limit exceeded" build failures. The token is
// optional, so local builds without it fall back to unauthenticated.
token: process.env.GITHUB_TOKEN,
}
]),
})
Expand Down
30 changes: 29 additions & 1 deletion docs/src/content/docs/features/Multi-User Mode/admin-guide.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,34 @@ Then restart the InvokeAI server backend from the command line or using the laun
If at any time you wish to revert to single-user mode, simply comment out the `multiuser` line, or change "true" to "false". Then restart the server. Because of the way that browsers cache pages, users with open InvokeAI sessions may need to force-refresh their browsers.
:::

### Queue Scheduling

The image generation queue is processed by a single worker, so jobs run one at a time. The `session_queue_mode` option controls the order in which pending jobs are selected:

| Mode | Behavior |
|------|----------|
| `round_robin` (default) | Interleaves jobs across users — each user is served one job before any user is served a second. A single user enqueuing a large batch can no longer monopolize the queue. |
| `FIFO` | Strict first-come, first-serve. Jobs run in the order they were enqueued (respecting priority), so a large batch drains completely before the next user's jobs start. |

Set it in `invokeai.yaml`:

```yaml
multiuser: true
session_queue_mode: round_robin # or FIFO
```

Or via the `INVOKEAI_SESSION_QUEUE_MODE` environment variable:

```bash
INVOKEAI_SESSION_QUEUE_MODE=FIFO
```

:::note
`session_queue_mode` only applies in multiuser mode. In single-user mode the queue is always FIFO regardless of this setting, since all jobs belong to the same account.
:::

Round-robin fairness is determined from when each user's jobs were last started. Retained terminal queue history (see [`max_queue_history`](#configuration-reference)) does not slow dequeue scheduling — each dequeue's cost scales with the number of users who currently have pending jobs, not with the size of the history.

### First Administrator Account

When InvokeAI starts for the first time in multi-user mode, you'll see the **Administrator Setup** dialog.
Expand Down Expand Up @@ -594,7 +622,7 @@ hashing_algorithm: blake3_multi

### How many users can InvokeAI support?

The backend will support dozens of concurrent users. However, because the image generation queue is single-threaded, image generation tasks are processed on a first-come, first-serve basis. This means that a user may have to wait for all the other users' image generation jobs to complete before their generation job starts to execute.
The backend will support dozens of concurrent users. However, because the image generation queue is single-threaded, only one job runs at a time. By default jobs are scheduled **round-robin** across users, so each user is served one job per turn and no single user can monopolize the queue with a large batch. You can switch to strict first-come, first-serve ordering with `session_queue_mode: FIFO` — see [Queue Scheduling](#queue-scheduling).

A future version of InvokeAI may support concurrent execution on systems with multiple GPUs/graphics cards.

Expand Down
4 changes: 2 additions & 2 deletions docs/src/content/docs/features/Multi-User Mode/user-guide.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,9 @@ You cannot:
- Cancel other users' generation jobs

:::tip[The generation queue]
When two or more users are accessing InvokeAI at the same time, their image generation jobs will be placed on the session queue on a first-come, first-serve basis. This means that you will have to wait for other users' image rendering jobs to complete before yours will start.
When two or more users are accessing InvokeAI at the same time, their image generation jobs share a single session queue. By default the server schedules jobs **round-robin** across users: each user gets one turn before anyone gets a second turn. This means a user who enqueues a large batch can no longer monopolize the queue — your jobs are interleaved with theirs rather than waiting for their entire batch to drain first. (An administrator can switch the server back to strict first-come, first-serve ordering; see the admin guide.)

While other users' jobs are running you will see the shared image generation progress bar, and the queue badge will show a single number — the count of your own jobs that are pending or in progress. It does not show other users' counts.
While other users' jobs are running you will see the shared image generation progress bar, and the queue badge shows **`your jobs / all jobs`** — for example `2/5` means 2 of the 5 pending-or-in-progress jobs are yours. (In single-user mode the badge shows just a single total.)

Open the Queue tab to see where your job sits in relation to the other queued tasks.
:::
Expand Down
14 changes: 14 additions & 0 deletions docs/src/generated/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,20 @@
"type": "<class 'int'>",
"validation": {}
},
{
"category": "GENERATION",
"default": "round_robin",
"description": "Session queue mode. Use 'FIFO' for traditional first-in-first-out, or 'round_robin' to serve each user's jobs in turn. In single-user mode, FIFO is always used regardless of this setting.",
"env_var": "INVOKEAI_SESSION_QUEUE_MODE",
"literal_values": [
"FIFO",
"round_robin"
],
"name": "session_queue_mode",
"required": false,
"type": "typing.Literal['FIFO', 'round_robin']",
"validation": {}
},
{
"category": "GENERATION",
"default": false,
Expand Down
4 changes: 3 additions & 1 deletion invokeai/app/api/routers/session_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,9 @@ async def get_queue_status(
current_user: CurrentUserOrDefault,
queue_id: str = Path(description="The queue id to perform this operation on"),
) -> SessionQueueAndProcessorStatus:
"""Gets the status of the session queue. Non-admin users see only their own counts and cannot see current item details unless they own it."""
"""Gets the status of the session queue. Returns global counts; non-admin users additionally
get their own pending/in_progress counts (so the UI can show an X/Y badge) and cannot see the
current item's identifiers unless they own it."""
try:
user_id = None if current_user.is_admin else current_user.user_id
queue = ApiDependencies.invoker.services.session_queue.get_queue_status(queue_id, user_id=user_id)
Expand Down
103 changes: 86 additions & 17 deletions invokeai/app/api/sockets.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,20 +260,37 @@ async def _handle_sub_bulk_download(self, sid: str, data: Any) -> None:
async def _handle_unsub_bulk_download(self, sid: str, data: Any) -> None:
await self._sio.leave_room(sid, BulkDownloadSubscriptionEvent(**data).bulk_download_id)

def _owner_and_admin_sids(self, owner_user_id: str) -> list[str]:
"""Sids belonging to the event's owner or to any admin.

Used as `skip_sid` when broadcasting a sanitized companion event to the queue room,
so the owner and admins (who already received the full event) don't get a second
copy that would clobber their cache with redacted values.
"""
return [
sid
for sid, info in self._socket_users.items()
if info.get("user_id") == owner_user_id or info.get("is_admin")
]

async def _handle_queue_event(self, event: FastAPIEvent[QueueEventBase]):
"""Handle queue events with user isolation.

All queue item events (invocation events AND QueueItemStatusChangedEvent) are
private to the owning user and admins. They carry unsanitized user_id, batch_id,
session_id, origin, destination and error metadata, and must never be broadcast
to the whole queue room — otherwise any other authenticated subscriber could
observe cross-user queue activity.
Queue events split into two routing paths:

RecallParametersUpdatedEvent is also private to the owner + admins.
1. The owner and admins receive the full unsanitized event in their `user:{id}` /
`admin` rooms. The full payload may include batch_id, session_id, origin,
destination, error metadata, etc.

BatchEnqueuedEvent carries the enqueuing user's batch_id/origin/counts and
is also routed privately. QueueClearedEvent is the only queue event that
is still broadcast to the whole queue room.
2. For events that other authenticated users need to know about so their queue list
and badge counts stay in sync (QueueItemStatusChangedEvent and BatchEnqueuedEvent),
a sanitized companion event is also emitted to the full queue room with the
owner's and admins' sids in `skip_sid`. The companion uses `user_id="redacted"`
as a sentinel so the frontend handler knows to do tag invalidation only and skip
per-session side effects.

InvocationEventBase events stay private (owner + admins only). RecallParametersUpdatedEvent
is also private. QueueClearedEvent has no user identity and is broadcast to the queue room.

IMPORTANT: Check InvocationEventBase BEFORE QueueItemEventBase since InvocationEventBase
inherits from QueueItemEventBase. The order of isinstance checks matters!
Expand Down Expand Up @@ -302,10 +319,51 @@ async def _handle_queue_event(self, event: FastAPIEvent[QueueEventBase]):

logger.debug(f"Emitted private invocation event {event_name} to user room {user_room} and admin room")

# Other queue item events (QueueItemStatusChangedEvent) carry unsanitized
# user_id, batch_id, session_id, origin, destination and error metadata.
# They are private to the owning user + admins — never broadcast to the
# full queue room.
# QueueItemStatusChangedEvent: full to owner+admin, sanitized to everyone else in
# the queue room so their queue list, badge, and item caches refresh.
elif isinstance(event_data, QueueItemStatusChangedEvent):
user_room = f"user:{event_data.user_id}"
await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room=user_room)
await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room="admin")

sanitized = event_data.model_copy(
update={
"user_id": "redacted",
"batch_id": "redacted",
"session_id": "redacted",
"origin": None,
"destination": None,
"error_type": None,
"error_message": None,
"error_traceback": None,
}
)
# Strip identifying fields out of the embedded batch_status / queue_status too.
sanitized.batch_status = sanitized.batch_status.model_copy(
update={"batch_id": "redacted", "origin": None, "destination": None}
)
sanitized.queue_status = sanitized.queue_status.model_copy(
update={
"item_id": None,
"session_id": None,
"batch_id": None,
"user_pending": None,
"user_in_progress": None,
}
)
await self._sio.emit(
event=event_name,
data=sanitized.model_dump(mode="json"),
room=event_data.queue_id,
skip_sid=self._owner_and_admin_sids(event_data.user_id),
)

logger.debug(
f"Emitted queue_item_status_changed: full to {user_room}+admin, sanitized to queue {event_data.queue_id}"
)

# Other queue item events (currently none beyond QueueItemStatusChangedEvent that
# carry user_id) stay private to owner + admins.
elif isinstance(event_data, QueueItemEventBase) and hasattr(event_data, "user_id"):
user_room = f"user:{event_data.user_id}"
await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room=user_room)
Expand All @@ -331,14 +389,25 @@ async def _handle_queue_event(self, event: FastAPIEvent[QueueEventBase]):
)
logger.debug(f"Emitted private recall_parameters_updated event to user room {user_room} and admin room")

# BatchEnqueuedEvent carries the enqueuing user's batch_id, origin, and
# enqueued counts. Route it privately to the owner + admins so other
# users do not observe cross-user batch activity.
# BatchEnqueuedEvent: full to owner+admin, sanitized to everyone else in the queue
# room so their badge total and queue list pick up the new items.
elif isinstance(event_data, BatchEnqueuedEvent):
user_room = f"user:{event_data.user_id}"
await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room=user_room)
await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room="admin")
logger.debug(f"Emitted private batch_enqueued event to user room {user_room} and admin room")

sanitized = event_data.model_copy(
update={"user_id": "redacted", "batch_id": "redacted", "origin": None}
)
await self._sio.emit(
event=event_name,
data=sanitized.model_dump(mode="json"),
room=event_data.queue_id,
skip_sid=self._owner_and_admin_sids(event_data.user_id),
)
logger.debug(
f"Emitted batch_enqueued: full to {user_room}+admin, sanitized to queue {event_data.queue_id}"
)

else:
# For remaining queue events (e.g. QueueClearedEvent) that do not
Expand Down
3 changes: 3 additions & 0 deletions invokeai/app/services/config/config_default.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
ATTENTION_SLICE_SIZE = Literal["auto", "balanced", "max", 1, 2, 3, 4, 5, 6, 7, 8]
LOG_FORMAT = Literal["plain", "color", "syslog", "legacy"]
LOG_LEVEL = Literal["debug", "info", "warning", "error", "critical"]
SESSION_QUEUE_MODE = Literal["FIFO", "round_robin"]
IMAGE_SUBFOLDER_STRATEGY = Literal["flat", "date", "type", "hash"]
CONFIG_SCHEMA_VERSION = "4.0.3"
EXTERNAL_PROVIDER_CONFIG_FIELDS = (
Expand Down Expand Up @@ -114,6 +115,7 @@ class InvokeAIAppConfig(BaseSettings):
force_tiled_decode: Whether to enable tiled VAE decode (reduces memory consumption with some performance penalty).
pil_compress_level: The compress_level setting of PIL.Image.save(), used for PNG encoding. All settings are lossless. 0 = no compression, 1 = fastest with slightly larger filesize, 9 = slowest with smallest filesize. 1 is typically the best setting.
max_queue_size: Maximum number of items in the session queue.
session_queue_mode: Session queue mode. Use 'FIFO' for traditional first-in-first-out, or 'round_robin' to serve each user's jobs in turn. In single-user mode, FIFO is always used regardless of this setting.<br>Valid values: `FIFO`, `round_robin`
clear_queue_on_startup: Empties session queue on startup. If true, disables `max_queue_history`.
max_queue_history: Keep the last N completed, failed, and canceled queue items. Older items are deleted on startup. Set to 0 to prune all terminal items. Ignored if `clear_queue_on_startup` is true.
allow_nodes: List of nodes to allow. Omit to allow all.
Expand Down Expand Up @@ -214,6 +216,7 @@ class InvokeAIAppConfig(BaseSettings):
force_tiled_decode: bool = Field(default=False, description="Whether to enable tiled VAE decode (reduces memory consumption with some performance penalty).")
pil_compress_level: int = Field(default=1, description="The compress_level setting of PIL.Image.save(), used for PNG encoding. All settings are lossless. 0 = no compression, 1 = fastest with slightly larger filesize, 9 = slowest with smallest filesize. 1 is typically the best setting.")
max_queue_size: int = Field(default=10000, gt=0, description="Maximum number of items in the session queue.")
session_queue_mode: SESSION_QUEUE_MODE = Field(default="round_robin", description="Session queue mode. Use 'FIFO' for traditional first-in-first-out, or 'round_robin' to serve each user's jobs in turn. In single-user mode, FIFO is always used regardless of this setting.")
clear_queue_on_startup: bool = Field(default=False, description="Empties session queue on startup. If true, disables `max_queue_history`.")
max_queue_history: Optional[int] = Field(default=None, ge=0, description="Keep the last N completed, failed, and canceled queue items. Older items are deleted on startup. Set to 0 to prune all terminal items. Ignored if `clear_queue_on_startup` is true.")

Expand Down
Loading
Loading