Compare commits

...

4 Commits

Author SHA1 Message Date
psychedelicious
28d3356710 chore: prep for v5.8.1 2025-03-18 09:06:47 +11:00
psychedelicious
81e70fb9d2 tidy(app): errant character 2025-03-18 08:00:51 +11:00
psychedelicious
971c425734 fix(app): incorrect values inserted when retrying queue item
In #7688 we optimized queuing preparation logic. This inadvertently broke retrying queue items.

Previously, a `NamedTuple` was used to store the values to insert in the DB when enqueuing. This handy class provides an API similar to a dataclass, where you can instantiate it with kwargs in any order. The resultant tuple re-orders the kwargs to match the order in the class definition.

For example, consider this `NamedTuple`:
```py
class SessionQueueValueToInsert(NamedTuple):
    foo: str
    bar: str
```

When instantiating it, no matter the order of the kwargs, if you make a normal tuple out of it, the tuple values are in the same order as in the class definition:

```
t1 = SessionQueueValueToInsert(foo="foo", bar="bar")
print(tuple(t1)) # -> ('foo', 'bar')

t2 = SessionQueueValueToInsert(bar="bar", foo="foo")
print(tuple(t2)) # -> ('foo', 'bar')
```

So, in the old code, when we used the `NamedTuple`, it implicitly normalized the order of the values we insert into the DB.

In the retry logic, the values of the tuple were not ordered correctly, but the use of `NamedTuple` had secretly fixed the order for us.

In the linked PR, `NamedTuple` was dropped for a normal tuple, after profiling showed `NamedTuple` to be meaningfully slower than a normal tuple.

The implicit order normalization behaviour wasn't understood, and the order wasn't fixed when changin the retry logic to use a normal tuple instead of `NamedTuple`. This results in a bug where we incorrectly create queue items in the DB. For example, we stored the `destination` in the `field_values` column.

When such an incorrectly-created queue item is dequeued, it fails pydantic validation and causes what appears to be an endless loop of errors.

The only user-facing solution is to add this line to `invokeai.yaml` and restart the app:
```yaml
clear_queue_on_startup: true
```

On next startup, the queue is forcibly cleared before the error loop is triggered. Then the user should remove this line so their queue is persisted across app launches per usual.

The solution is simple - fix the ordering of the tuple. I also added a type annotation and comment to the tuple type alias definition.

Note: The endless error loop, as a general problem, will take some thinking to fix. The queue service methods to cancel and fail a queue item still retrieve it and parse it. And the list queue items methods parse the queue items. Bit of a catch 22, maybe the solution is to simply delete totally borked queue items and log an error.
2025-03-18 08:00:51 +11:00
psychedelicious
b09008c530 feat(ui): add cancel and clear all as toggleable app feature 2025-03-18 06:48:10 +11:00
10 changed files with 252 additions and 99 deletions

View File

@@ -570,7 +570,10 @@ ValueToInsertTuple: TypeAlias = tuple[
str | None, # destination (optional)
int | None, # retried_from_item_id (optional, this is always None for new items)
]
"""A type alias for the tuple of values to insert into the session queue table."""
"""A type alias for the tuple of values to insert into the session queue table.
**If you change this, be sure to update the `enqueue_batch` and `retry_items_by_id` methods in the session queue service!**
"""
def prepare_values_to_insert(

View File

@@ -27,6 +27,7 @@ from invokeai.app.services.session_queue.session_queue_common import (
SessionQueueItemDTO,
SessionQueueItemNotFoundError,
SessionQueueStatus,
ValueToInsertTuple,
calc_session_count,
prepare_values_to_insert,
)
@@ -689,7 +690,7 @@ class SqliteSessionQueue(SessionQueueBase):
"""Retries the given queue items"""
try:
cursor = self._conn.cursor()
values_to_insert: list[tuple] = []
values_to_insert: list[ValueToInsertTuple] = []
retried_item_ids: list[int] = []
for item_id in item_ids:
@@ -715,16 +716,16 @@ class SqliteSessionQueue(SessionQueueBase):
else queue_item.item_id
)
value_to_insert = (
value_to_insert: ValueToInsertTuple = (
queue_item.queue_id,
queue_item.batch_id,
queue_item.destination,
field_values_json,
queue_item.origin,
queue_item.priority,
workflow_json,
cloned_session_json,
cloned_session.id,
queue_item.batch_id,
field_values_json,
queue_item.priority,
workflow_json,
queue_item.origin,
queue_item.destination,
retried_from_item_id,
)
values_to_insert.append(value_to_insert)

View File

@@ -27,7 +27,8 @@ export type AppFeature =
| 'bulkDownload'
| 'starterModels'
| 'hfToken'
| 'retryQueueItem';
| 'retryQueueItem'
| 'cancelAndClearAll';
/**
* A disable-able Stable Diffusion feature
*/

View File

@@ -0,0 +1,32 @@
import type { ButtonProps } from '@invoke-ai/ui-library';
import { Button } from '@invoke-ai/ui-library';
import { useCancelAllExceptCurrentQueueItemDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiXCircle } from 'react-icons/pi';
type Props = ButtonProps;
export const CancelAllExceptCurrentButton = memo((props: Props) => {
const { t } = useTranslation();
const cancelAllExceptCurrent = useCancelAllExceptCurrentQueueItemDialog();
return (
<>
<Button
onClick={cancelAllExceptCurrent.openDialog}
isLoading={cancelAllExceptCurrent.isLoading}
isDisabled={cancelAllExceptCurrent.isDisabled}
tooltip={t('queue.cancelAllExceptCurrentTooltip')}
leftIcon={<PiXCircle />}
colorScheme="error"
data-testid={t('queue.clear')}
{...props}
>
{t('queue.clear')}
</Button>
</>
);
});
CancelAllExceptCurrentButton.displayName = 'CancelAllExceptCurrentButton';

View File

@@ -1,33 +1,89 @@
import { IconButton, useShiftModifier } from '@invoke-ai/ui-library';
import { useCancelAllExceptCurrentQueueItemDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog';
import { useCancelCurrentQueueItem } from 'features/queue/hooks/useCancelCurrentQueueItem';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashSimpleBold, PiXBold } from 'react-icons/pi';
import { PiTrashSimpleBold, PiXBold, PiXCircle } from 'react-icons/pi';
import { useClearQueueDialog } from './ClearQueueConfirmationAlertDialog';
export const ClearQueueIconButton = memo((_) => {
const { t } = useTranslation();
const clearQueue = useClearQueueDialog();
const cancelCurrentQueueItem = useCancelCurrentQueueItem();
// Show the single item clear button when shift is pressed
// Otherwise show the clear queue button
export const ClearQueueIconButton = memo(() => {
const isCancelAndClearAllEnabled = useFeatureStatus('cancelAndClearAll');
const shift = useShiftModifier();
if (!shift) {
// Shift is not pressed - show cancel current
return <CancelCurrentIconButton />;
}
if (isCancelAndClearAllEnabled) {
// Shift is pressed and cancel and clear all is enabled - show cancel and clear all
return <CancelAndClearAllIconButton />;
}
// Shift is pressed and cancel and clear all is disabled - show cancel all except current
return <CancelAllExceptCurrentIconButton />;
});
ClearQueueIconButton.displayName = 'ClearQueueIconButton';
const CancelCurrentIconButton = memo(() => {
const { t } = useTranslation();
const cancelCurrentQueueItem = useCancelCurrentQueueItem();
return (
<IconButton
size="lg"
isDisabled={shift ? clearQueue.isDisabled : cancelCurrentQueueItem.isDisabled}
isLoading={shift ? clearQueue.isLoading : cancelCurrentQueueItem.isLoading}
aria-label={shift ? t('queue.clear') : t('queue.cancel')}
tooltip={shift ? t('queue.clearTooltip') : t('queue.cancelTooltip')}
icon={shift ? <PiTrashSimpleBold /> : <PiXBold />}
isDisabled={cancelCurrentQueueItem.isDisabled}
isLoading={cancelCurrentQueueItem.isLoading}
aria-label={t('queue.cancel')}
tooltip={t('queue.cancelTooltip')}
icon={<PiXBold />}
colorScheme="error"
onClick={shift ? clearQueue.openDialog : cancelCurrentQueueItem.cancelQueueItem}
data-testid={shift ? t('queue.clear') : t('queue.cancel')}
onClick={cancelCurrentQueueItem.cancelQueueItem}
/>
);
});
ClearQueueIconButton.displayName = 'ClearQueueIconButton';
CancelCurrentIconButton.displayName = 'CancelCurrentIconButton';
const CancelAndClearAllIconButton = memo(() => {
const { t } = useTranslation();
const clearQueue = useClearQueueDialog();
return (
<IconButton
size="lg"
isDisabled={clearQueue.isDisabled}
isLoading={clearQueue.isLoading}
aria-label={t('queue.clear')}
tooltip={t('queue.clearTooltip')}
icon={<PiTrashSimpleBold />}
colorScheme="error"
onClick={clearQueue.openDialog}
/>
);
});
CancelAndClearAllIconButton.displayName = 'CancelAndClearAllIconButton';
const CancelAllExceptCurrentIconButton = memo(() => {
const { t } = useTranslation();
const cancelAllExceptCurrent = useCancelAllExceptCurrentQueueItemDialog();
return (
<IconButton
size="lg"
isDisabled={cancelAllExceptCurrent.isDisabled}
isLoading={cancelAllExceptCurrent.isLoading}
aria-label={t('queue.clear')}
tooltip={t('queue.cancelAllExceptCurrentTooltip')}
icon={<PiXCircle />}
colorScheme="error"
onClick={cancelAllExceptCurrent.openDialog}
/>
);
});
CancelAllExceptCurrentIconButton.displayName = 'CancelAllExceptCurrentIconButton';

View File

@@ -27,6 +27,7 @@ export const QueueActionsMenuButton = memo(() => {
const { t } = useTranslation();
const isPauseEnabled = useFeatureStatus('pauseQueue');
const isResumeEnabled = useFeatureStatus('resumeQueue');
const isCancelAndClearAllEnabled = useFeatureStatus('cancelAndClearAll');
const cancelAllExceptCurrent = useCancelAllExceptCurrentQueueItemDialog();
const cancelCurrent = useCancelCurrentQueueItem();
const clearQueue = useClearQueueDialog();
@@ -71,15 +72,17 @@ export const QueueActionsMenuButton = memo(() => {
>
{t('queue.cancelAllExceptCurrentTooltip')}
</MenuItem>
<MenuItem
isDestructive
icon={<PiTrashSimpleBold />}
onClick={clearQueue.openDialog}
isLoading={clearQueue.isLoading}
isDisabled={clearQueue.isDisabled}
>
{t('queue.clearTooltip')}
</MenuItem>
{isCancelAndClearAllEnabled && (
<MenuItem
isDestructive
icon={<PiTrashSimpleBold />}
onClick={clearQueue.openDialog}
isLoading={clearQueue.isLoading}
isDisabled={clearQueue.isDisabled}
>
{t('queue.clearTooltip')}
</MenuItem>
)}
{isResumeEnabled && (
<MenuItem
icon={<PiPlayFill />}

View File

@@ -1,5 +1,6 @@
/* eslint-disable i18next/no-literal-string */
import { ButtonGroup, Flex } from '@invoke-ai/ui-library';
import { CancelAllExceptCurrentButton } from 'features/queue/components/CancelAllExceptCurrentButton';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { memo } from 'react';
@@ -12,6 +13,8 @@ import ResumeProcessorButton from './ResumeProcessorButton';
const QueueTabQueueControls = () => {
const isPauseEnabled = useFeatureStatus('pauseQueue');
const isResumeEnabled = useFeatureStatus('resumeQueue');
const isCancelAndClearAllEnabled = useFeatureStatus('cancelAndClearAll');
return (
<Flex flexDir="column" layerStyle="first" borderRadius="base" p={2} gap={2}>
<Flex gap={2}>
@@ -25,7 +28,8 @@ const QueueTabQueueControls = () => {
)}
<ButtonGroup w={28} orientation="vertical" size="sm">
<PruneQueueButton />
<ClearQueueButton />
{isCancelAndClearAllEnabled && <ClearQueueButton />}
{!isCancelAndClearAllEnabled && <CancelAllExceptCurrentButton />}
</ButtonGroup>
</Flex>
<ClearModelCacheButton />

View File

@@ -141,7 +141,7 @@ export const AppContent = memo(() => {
)}
<Panel id="main-panel" order={1} minSize={20} style={panelStyles}>
<MainPanelContent />
{withLeftPanel && <FloatingParametersPanelButtons panelApi={leftPanel} />}
{withLeftPanel && <FloatingParametersPanelButtons togglePanel={leftPanel.toggle} />}
{withRightPanel && <FloatingGalleryButton panelApi={rightPanel} />}
</Panel>
{withRightPanel && (

View File

@@ -3,11 +3,12 @@ import { useAppSelector } from 'app/store/storeHooks';
import { ToolChooser } from 'features/controlLayers/components/Tool/ToolChooser';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { useCancelAllExceptCurrentQueueItemDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog';
import { useClearQueueDialog } from 'features/queue/components/ClearQueueConfirmationAlertDialog';
import { InvokeButtonTooltip } from 'features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip';
import { useCancelCurrentQueueItem } from 'features/queue/hooks/useCancelCurrentQueueItem';
import { useInvoke } from 'features/queue/hooks/useInvoke';
import type { UsePanelReturn } from 'features/ui/hooks/usePanel';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -18,22 +19,53 @@ import {
PiSparkleFill,
PiTrashSimpleBold,
PiXBold,
PiXCircle,
} from 'react-icons/pi';
import { useGetQueueStatusQuery } from 'services/api/endpoints/queue';
type Props = {
panelApi: UsePanelReturn;
togglePanel: () => void;
};
const FloatingSidePanelButtons = (props: Props) => {
const FloatingSidePanelButtons = ({ togglePanel }: Props) => {
const { t } = useTranslation();
const tab = useAppSelector(selectActiveTab);
const imageViewer = useImageViewer();
const isCancelAndClearAllEnabled = useFeatureStatus('cancelAndClearAll');
return (
<Flex pos="absolute" transform="translate(0, -50%)" top="50%" insetInlineStart={2} direction="column" gap={2}>
{tab === 'canvas' && !imageViewer.isOpen && (
<CanvasManagerProviderGate>
<ToolChooser />
</CanvasManagerProviderGate>
)}
<ButtonGroup orientation="vertical" h={48}>
<Tooltip label={t('accessibility.toggleLeftPanel')} placement="end">
<IconButton
aria-label={t('accessibility.toggleLeftPanel')}
onClick={togglePanel}
icon={<PiSlidersHorizontalBold />}
flexGrow={1}
/>
</Tooltip>
<InvokeIconButton />
<CancelCurrentIconButton />
{/* Show the cancel all except current button instead of cancel and clear all when it is disabled */}
{isCancelAndClearAllEnabled && <CancelAndClearAllIconButton />}
{!isCancelAndClearAllEnabled && <CancelAllExceptCurrentIconButton />}
</ButtonGroup>
</Flex>
);
};
export default memo(FloatingSidePanelButtons);
const InvokeIconButton = memo(() => {
const { t } = useTranslation();
const queue = useInvoke();
const shift = useShiftModifier();
const tab = useAppSelector(selectActiveTab);
const imageViewer = useImageViewer();
const clearQueue = useClearQueueDialog();
const { data: queueStatus } = useGetQueueStatusQuery();
const cancelCurrent = useCancelCurrentQueueItem();
const queueButtonIcon = useMemo(() => {
const isProcessing = (queueStatus?.queue.in_progress ?? 0) > 0;
@@ -47,59 +79,80 @@ const FloatingSidePanelButtons = (props: Props) => {
}, [queue.isDisabled, queueStatus?.queue.in_progress, shift]);
return (
<Flex pos="absolute" transform="translate(0, -50%)" top="50%" insetInlineStart={2} direction="column" gap={2}>
{tab === 'canvas' && !imageViewer.isOpen && (
<CanvasManagerProviderGate>
<ToolChooser />
</CanvasManagerProviderGate>
)}
<ButtonGroup orientation="vertical" h={48}>
<Tooltip label={t('accessibility.toggleLeftPanel')} placement="end">
<IconButton
aria-label={t('accessibility.toggleLeftPanel')}
onClick={props.panelApi.toggle}
icon={<PiSlidersHorizontalBold />}
flexGrow={1}
/>
</Tooltip>
<InvokeButtonTooltip prepend={shift} placement="end">
<IconButton
aria-label={t('queue.queueBack')}
onClick={shift ? queue.queueFront : queue.queueBack}
isLoading={queue.isLoading}
isDisabled={queue.isDisabled}
icon={queueButtonIcon}
colorScheme="invokeYellow"
flexGrow={1}
/>
</InvokeButtonTooltip>
<Tooltip label={t('queue.cancelTooltip')} placement="end">
<IconButton
isDisabled={cancelCurrent.isDisabled}
isLoading={cancelCurrent.isLoading}
aria-label={t('queue.cancelTooltip')}
icon={<PiXBold />}
onClick={cancelCurrent.cancelQueueItem}
colorScheme="error"
flexGrow={1}
/>
</Tooltip>
<Tooltip label={t('queue.clearTooltip')} placement="end">
<IconButton
isDisabled={clearQueue.isDisabled}
isLoading={clearQueue.isLoading}
aria-label={t('queue.clearTooltip')}
icon={<PiTrashSimpleBold />}
colorScheme="error"
onClick={clearQueue.openDialog}
data-testid={t('queue.clear')}
flexGrow={1}
/>
</Tooltip>
</ButtonGroup>
</Flex>
<InvokeButtonTooltip prepend={shift} placement="end">
<IconButton
aria-label={t('queue.queueBack')}
onClick={shift ? queue.queueFront : queue.queueBack}
isLoading={queue.isLoading}
isDisabled={queue.isDisabled}
icon={queueButtonIcon}
colorScheme="invokeYellow"
flexGrow={1}
/>
</InvokeButtonTooltip>
);
};
});
InvokeIconButton.displayName = 'InvokeIconButton';
export default memo(FloatingSidePanelButtons);
const CancelCurrentIconButton = memo(() => {
const { t } = useTranslation();
const cancelCurrentQueueItem = useCancelCurrentQueueItem();
return (
<Tooltip label={t('queue.cancelTooltip')} placement="end">
<IconButton
isDisabled={cancelCurrentQueueItem.isDisabled}
isLoading={cancelCurrentQueueItem.isLoading}
aria-label={t('queue.cancelTooltip')}
icon={<PiXBold />}
onClick={cancelCurrentQueueItem.cancelQueueItem}
colorScheme="error"
flexGrow={1}
/>
</Tooltip>
);
});
CancelCurrentIconButton.displayName = 'CancelCurrentIconButton';
const CancelAndClearAllIconButton = memo(() => {
const { t } = useTranslation();
const clearQueue = useClearQueueDialog();
return (
<Tooltip label={t('queue.clearTooltip')} placement="end">
<IconButton
isDisabled={clearQueue.isDisabled}
isLoading={clearQueue.isLoading}
aria-label={t('queue.clearTooltip')}
icon={<PiTrashSimpleBold />}
colorScheme="error"
onClick={clearQueue.openDialog}
flexGrow={1}
/>
</Tooltip>
);
});
CancelAndClearAllIconButton.displayName = 'CancelAndClearAllIconButton';
const CancelAllExceptCurrentIconButton = memo(() => {
const { t } = useTranslation();
const cancelAllExceptCurrent = useCancelAllExceptCurrentQueueItemDialog();
return (
<Tooltip label={t('queue.cancelAllExceptCurrentTooltip')} placement="end">
<IconButton
isDisabled={cancelAllExceptCurrent.isDisabled}
isLoading={cancelAllExceptCurrent.isLoading}
aria-label={t('queue.clear')}
icon={<PiXCircle />}
colorScheme="error"
onClick={cancelAllExceptCurrent.openDialog}
flexGrow={1}
/>
</Tooltip>
);
});
CancelAllExceptCurrentIconButton.displayName = 'CancelAllExceptCurrentIconButton';

View File

@@ -1 +1 @@
__version__ = "5.8.0"
__version__ = "5.8.1"