feat: Use postgres as a queue

We've been keen to try this for a while as it means we can remove redis as a
dependency, which makes Immich easier to setup and run.

This replaces bullmq with a bespoke postgres queue. Jobs in the queue are
processed either immediately via triggers and notifications, or eventually if a
notification is missed.
This commit is contained in:
Thomas Way
2025-04-30 20:43:51 +01:00
parent b845184c80
commit 8c0c8a8d0e
46 changed files with 731 additions and 915 deletions

View File

@@ -47,20 +47,20 @@
onCommand,
}: Props = $props();
let waitingCount = $derived(jobCounts.waiting + jobCounts.paused + jobCounts.delayed);
let isIdle = $derived(!queueStatus.isActive && !queueStatus.isPaused);
let waitingCount = $derived(jobCounts.waiting + jobCounts.delayed);
let idle = $derived(jobCounts.active + jobCounts.waiting + jobCounts.delayed === 0);
let multipleButtons = $derived(allText || refreshText);
const commonClasses = 'flex place-items-center justify-between w-full py-2 sm:py-4 pe-4 ps-6';
const commonClasses = 'flex place-items-center justify-between w-full py-2 sm:py-4 pr-4 pl-6';
</script>
<div
class="flex flex-col overflow-hidden rounded-2xl bg-gray-100 dark:bg-immich-dark-gray sm:flex-row sm:rounded-[35px]"
>
<div class="flex w-full flex-col">
{#if queueStatus.isPaused}
{#if queueStatus.paused}
<JobTileStatus color="warning">{$t('paused')}</JobTileStatus>
{:else if queueStatus.isActive}
{:else if !idle}
<JobTileStatus color="success">{$t('active')}</JobTileStatus>
{/if}
<div class="flex flex-col gap-2 p-5 sm:p-7 md:p-9">
@@ -119,12 +119,12 @@
</div>
<div
class="{commonClasses} flex-row-reverse rounded-b-lg bg-gray-200 text-immich-dark-bg dark:bg-gray-700 dark:text-immich-gray sm:rounded-s-none sm:rounded-e-lg"
class="{commonClasses} rounded-b-lg bg-gray-200 text-immich-dark-bg dark:bg-gray-700 dark:text-immich-gray sm:rounded-s-none sm:rounded-e-lg"
>
<p>{$t('waiting')}</p>
<p class="text-2xl">
{waitingCount.toLocaleString($locale)}
</p>
<p>{$t('waiting')}</p>
</div>
</div>
</div>
@@ -139,54 +139,52 @@
<Icon path={mdiAlertCircle} size="36" />
{$t('disabled').toUpperCase()}
</JobTileButton>
{/if}
{#if !disabled && !isIdle}
{#if waitingCount > 0}
<JobTileButton color="gray" onClick={() => onCommand({ command: JobCommand.Empty, force: false })}>
{:else}
{#if !idle}
<JobTileButton color="gray" onClick={() => onCommand({ command: JobCommand.Clear, force: false })}>
<Icon path={mdiClose} size="24" />
{$t('clear').toUpperCase()}
</JobTileButton>
{/if}
{#if queueStatus.isPaused}
{@const size = waitingCount > 0 ? '24' : '48'}
<JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Resume, force: false })}>
{#if multipleButtons && idle}
{#if allText}
<JobTileButton color="dark-gray" onClick={() => onCommand({ command: JobCommand.Start, force: true })}>
<Icon path={mdiAllInclusive} size="24" />
{allText}
</JobTileButton>
{/if}
{#if refreshText}
<JobTileButton color="gray" onClick={() => onCommand({ command: JobCommand.Start, force: undefined })}>
<Icon path={mdiImageRefreshOutline} size="24" />
{refreshText}
</JobTileButton>
{/if}
<JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Start, force: false })}>
<Icon path={mdiSelectionSearch} size="24" />
{missingText}
</JobTileButton>
{/if}
{#if !multipleButtons && idle}
<JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Start, force: false })}>
<Icon path={mdiPlay} size="24" />
{missingText}
</JobTileButton>
{/if}
{#if queueStatus.paused}
<JobTileButton color="gray" onClick={() => onCommand({ command: JobCommand.Resume, force: false })}>
<!-- size property is not reactive, so have to use width and height -->
<Icon path={mdiFastForward} {size} />
<Icon path={mdiFastForward} size="24" />
{$t('resume').toUpperCase()}
</JobTileButton>
{:else}
<JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Pause, force: false })}>
<JobTileButton color="gray" onClick={() => onCommand({ command: JobCommand.Pause, force: false })}>
<Icon path={mdiPause} size="24" />
{$t('pause').toUpperCase()}
</JobTileButton>
{/if}
{/if}
{#if !disabled && multipleButtons && isIdle}
{#if allText}
<JobTileButton color="dark-gray" onClick={() => onCommand({ command: JobCommand.Start, force: true })}>
<Icon path={mdiAllInclusive} size="24" />
{allText}
</JobTileButton>
{/if}
{#if refreshText}
<JobTileButton color="gray" onClick={() => onCommand({ command: JobCommand.Start, force: undefined })}>
<Icon path={mdiImageRefreshOutline} size="24" />
{refreshText}
</JobTileButton>
{/if}
<JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Start, force: false })}>
<Icon path={mdiSelectionSearch} size="24" />
{missingText}
</JobTileButton>
{/if}
{#if !disabled && !multipleButtons && isIdle}
<JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Start, force: false })}>
<Icon path={mdiPlay} size="48" />
{missingText}
</JobTileButton>
{/if}
</div>
</div>

View File

@@ -154,7 +154,7 @@
jobs[jobId] = await sendJobCommand({ id: jobId, jobCommandDto: jobCommand });
switch (jobCommand.command) {
case JobCommand.Empty: {
case JobCommand.Clear: {
notificationController.show({
message: $t('admin.cleared_jobs', { values: { job: title } }),
type: NotificationType.Info,