Compare commits

...
Author SHA1 Message Date
shamoon a3089875a4 Fix: move re-index after workflow runs on doc edit 2026-10-26 17:06:22 -07:00
Jan Kleineandshamoon 7d8147c21d Performance: reduce tasks badge load times (#14552)
---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-10-26 14:23:33 +00:00
7 changed files with 125 additions and 139 deletions

No files matched your search

@@ -19,7 +19,6 @@ import {
PaperlessTaskTriggerSource, PaperlessTaskTriggerSource,
PaperlessTaskType, PaperlessTaskType,
} from 'src/app/data/paperless-task' } from 'src/app/data/paperless-task'
import { Results } from 'src/app/data/results'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
@@ -153,11 +152,6 @@ const tasks: PaperlessTask[] = [
}, },
] ]
const paginatedTasks: Results<PaperlessTask> = {
count: tasks.length,
results: tasks,
}
const sectionCountResponse = { const sectionCountResponse = {
all: 7, all: 7,
needs_attention: 2, needs_attention: 2,
@@ -204,7 +198,7 @@ describe('TasksComponent', () => {
}).compileComponents() }).compileComponents()
tasksService = TestBed.inject(TasksService) tasksService = TestBed.inject(TasksService)
reloadSpy = jest.spyOn(tasksService, 'reload') reloadSpy = jest.spyOn(tasksService, 'reload').mockImplementation(() => {})
httpTestingController = TestBed.inject(HttpTestingController) httpTestingController = TestBed.inject(HttpTestingController)
modalService = TestBed.inject(NgbModal) modalService = TestBed.inject(NgbModal)
router = TestBed.inject(Router) router = TestBed.inject(Router)
@@ -214,15 +208,6 @@ describe('TasksComponent', () => {
jest.useFakeTimers() jest.useFakeTimers()
fixture.detectChanges() fixture.detectChanges()
httpTestingController
.expectOne(
(req) =>
req.url === `${environment.apiBaseUrl}tasks/` &&
req.params.get('acknowledged') === 'false' &&
req.params.get('page_size') === '1000'
)
.flush(paginatedTasks)
httpTestingController httpTestingController
.expectOne( .expectOne(
(req) => (req) =>
@@ -344,11 +344,11 @@
ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="list-task"></i-bs><span class="nav-link-label"><ng-container i18n>Tasks</ng-container></span> <i-bs class="me-2" name="list-task"></i-bs><span class="nav-link-label"><ng-container i18n>Tasks</ng-container></span>
@if (tasksService.needsAttentionTasks.length > 0 && !slimSidebarEnabled) { @if (tasksService.needsAttentionTasksCount > 0 && !slimSidebarEnabled) {
<span class="badge bg-danger ms-2 d-inline flex-shrink-0">{{tasksService.needsAttentionTasks.length}}</span> <span class="badge bg-danger ms-2 d-inline flex-shrink-0">{{tasksService.needsAttentionTasksCount}}</span>
} }
@if (tasksService.needsAttentionTasks.length > 0 && slimSidebarEnabled) { @if (tasksService.needsAttentionTasksCount > 0 && slimSidebarEnabled) {
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.needsAttentionTasks.length}}</span> <span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.needsAttentionTasksCount}}</span>
} }
</a> </a>
</li> </li>
@@ -588,8 +588,8 @@ describe('AppFrameComponent', () => {
it('should show tasks badge for needs-attention tasks', () => { it('should show tasks badge for needs-attention tasks', () => {
jest jest
.spyOn(tasksService, 'needsAttentionTasks', 'get') .spyOn(tasksService, 'needsAttentionTasksCount', 'get')
.mockReturnValue([{} as any, {} as any]) .mockReturnValue(2)
fixture.detectChanges() fixture.detectChanges()
+22 -82
View File
@@ -9,11 +9,7 @@ import {
} from '@angular/common/http/testing' } from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing' import { TestBed } from '@angular/core/testing'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { import { PaperlessTaskType } from '../data/paperless-task'
PaperlessTaskStatus,
PaperlessTaskTriggerSource,
PaperlessTaskType,
} from '../data/paperless-task'
import { TasksService } from './tasks.service' import { TasksService } from './tasks.service'
describe('TasksService', () => { describe('TasksService', () => {
@@ -38,23 +34,23 @@ describe('TasksService', () => {
httpTestingController.verify() httpTestingController.verify()
}) })
it('calls tasks api endpoint on reload', () => { it('calls tasks status counts api endpoint on reload', () => {
tasksService.reload() tasksService.reload()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
(req: HttpRequest<unknown>) => (req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` && req.url === `${environment.apiBaseUrl}tasks/status_counts/` &&
req.params.get('acknowledged') === 'false' && req.params.get('acknowledged') === 'false'
req.params.get('page_size') === '1000'
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush({ count: 0, results: [] }) req.flush({ all: 5, needs_attention: 3, in_progress: 1, completed: 1 })
expect(tasksService.needsAttentionTasksCount).toEqual(3)
}) })
it('cancels an in-progress reload when reloading again', () => { it('cancels an in-progress reload when reloading again', () => {
tasksService.reload() tasksService.reload()
const staleReload = httpTestingController.expectOne( const staleReload = httpTestingController.expectOne(
(req: HttpRequest<unknown>) => (req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` req.url === `${environment.apiBaseUrl}tasks/status_counts/`
) )
tasksService.reload() tasksService.reload()
@@ -62,9 +58,9 @@ describe('TasksService', () => {
httpTestingController httpTestingController
.expectOne( .expectOne(
(req: HttpRequest<unknown>) => (req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` req.url === `${environment.apiBaseUrl}tasks/status_counts/`
) )
.flush({ count: 0, results: [] }) .flush({ all: 0, needs_attention: 0, in_progress: 0, completed: 0 })
}) })
it('continues reloading after a reload request fails', () => { it('continues reloading after a reload request fails', () => {
@@ -72,7 +68,7 @@ describe('TasksService', () => {
httpTestingController httpTestingController
.expectOne( .expectOne(
(req: HttpRequest<unknown>) => (req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` req.url === `${environment.apiBaseUrl}tasks/status_counts/`
) )
.flush('error', { status: 500, statusText: 'error' }) .flush('error', { status: 500, statusText: 'error' })
@@ -82,16 +78,16 @@ describe('TasksService', () => {
httpTestingController httpTestingController
.expectOne( .expectOne(
(req: HttpRequest<unknown>) => (req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` req.url === `${environment.apiBaseUrl}tasks/status_counts/`
) )
.flush({ count: 0, results: [] }) .flush({ all: 0, needs_attention: 0, in_progress: 0, completed: 0 })
}) })
it('reloads after dismissing a task while a reload is already in progress', () => { it('reloads after dismissing a task while a reload is already in progress', () => {
tasksService.reload() tasksService.reload()
const staleReload = httpTestingController.expectOne( const staleReload = httpTestingController.expectOne(
(req: HttpRequest<unknown>) => (req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` && req.url === `${environment.apiBaseUrl}tasks/status_counts/` &&
req.params.get('acknowledged') === 'false' req.params.get('acknowledged') === 'false'
) )
@@ -104,12 +100,12 @@ describe('TasksService', () => {
httpTestingController httpTestingController
.expectOne( .expectOne(
(req: HttpRequest<unknown>) => (req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` && req.url === `${environment.apiBaseUrl}tasks/status_counts/` &&
req.params.get('acknowledged') === 'false' req.params.get('acknowledged') === 'false'
) )
.flush({ count: 0, results: [] }) .flush({ all: 50, needs_attention: 42, in_progress: 3, completed: 5 })
expect(tasksService.needsAttentionTasks).toHaveLength(0) expect(tasksService.needsAttentionTasksCount).toEqual(42)
}) })
it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => { it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => {
@@ -126,11 +122,10 @@ describe('TasksService', () => {
httpTestingController httpTestingController
.expectOne( .expectOne(
(req: HttpRequest<unknown>) => (req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` && req.url === `${environment.apiBaseUrl}tasks/status_counts/` &&
req.params.get('acknowledged') === 'false' && req.params.get('acknowledged') === 'false'
req.params.get('page_size') === '1000'
) )
.flush({ count: 0, results: [] }) .flush({ all: 0, needs_attention: 0, in_progress: 0, completed: 0 })
}) })
it('calls acknowledge_tasks api endpoint on dismiss all and reloads', () => { it('calls acknowledge_tasks api endpoint on dismiss all and reloads', () => {
@@ -147,65 +142,10 @@ describe('TasksService', () => {
httpTestingController httpTestingController
.expectOne( .expectOne(
(req: HttpRequest<unknown>) => (req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` && req.url === `${environment.apiBaseUrl}tasks/status_counts/` &&
req.params.get('acknowledged') === 'false' && req.params.get('acknowledged') === 'false'
req.params.get('page_size') === '1000'
) )
.flush({ count: 0, results: [] }) .flush({ all: 0, needs_attention: 0, in_progress: 0, completed: 0 })
})
it('includes revoked tasks in needs attention', () => {
const mockTasks = [
{
task_type: PaperlessTaskType.SanityCheck,
trigger_source: PaperlessTaskTriggerSource.System,
status: PaperlessTaskStatus.Failure,
acknowledged: false,
task_id: '1235',
input_data: {},
date_created: new Date(),
related_document_ids: [],
},
{
task_type: PaperlessTaskType.MailFetch,
trigger_source: PaperlessTaskTriggerSource.Scheduled,
status: PaperlessTaskStatus.Revoked,
acknowledged: false,
task_id: '1236',
input_data: {},
date_created: new Date(),
related_document_ids: [],
},
{
task_type: PaperlessTaskType.EmptyTrash,
trigger_source: PaperlessTaskTriggerSource.Manual,
status: PaperlessTaskStatus.Success,
acknowledged: false,
task_id: '1238',
input_data: {},
date_created: new Date(),
related_document_ids: [],
},
]
tasksService.reload()
const req = httpTestingController.expectOne(
(req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` &&
req.params.get('acknowledged') === 'false' &&
req.params.get('page_size') === '1000'
)
req.flush({ count: mockTasks.length, results: mockTasks })
expect(tasksService.needsAttentionTasks).toHaveLength(2)
expect(tasksService.needsAttentionTasks.map((task) => task.status)).toEqual(
expect.arrayContaining([
PaperlessTaskStatus.Failure,
PaperlessTaskStatus.Revoked,
])
)
}) })
it('supports running tasks', () => { it('supports running tasks', () => {
+23 -33
View File
@@ -5,14 +5,12 @@ import {
catchError, catchError,
finalize, finalize,
first, first,
map,
switchMap, switchMap,
takeUntil, takeUntil,
tap, tap,
} from 'rxjs/operators' } from 'rxjs/operators'
import { import {
PaperlessTask, PaperlessTask,
PaperlessTaskStatus,
PaperlessTaskStatusCounts, PaperlessTaskStatusCounts,
PaperlessTaskType, PaperlessTaskType,
} from 'src/app/data/paperless-task' } from 'src/app/data/paperless-task'
@@ -27,50 +25,42 @@ export class TasksService {
private baseUrl: string = environment.apiBaseUrl private baseUrl: string = environment.apiBaseUrl
private endpoint: string = 'tasks' private endpoint: string = 'tasks'
private readonly defaultReloadPageSize = 1000
public loading: boolean = false public loading: boolean = false
private readonly tasks = signal<PaperlessTask[]>([]) private readonly tasksStatusCounts = signal<PaperlessTaskStatusCounts>({
all: 0,
needs_attention: 0,
in_progress: 0,
completed: 0,
})
private readonly reloadNotifier = new Subject<void>() private readonly reloadNotifier = new Subject<void>()
private unsubscribeNotifer: Subject<any> = new Subject() private unsubscribeNotifer: Subject<any> = new Subject()
constructor() { constructor() {
this.reloadNotifier this.reloadNotifier
.pipe( .pipe(switchMap(() => this.fetchUnacknowledgedTasksCounts()))
switchMap(() => { .subscribe((taskStatusCounts) => {
this.loading = true this.tasksStatusCounts.set(taskStatusCounts)
return this.http
.get<Results<PaperlessTask>>(`${this.baseUrl}${this.endpoint}/`, {
params: {
acknowledged: 'false',
page_size: this.defaultReloadPageSize,
},
})
.pipe(
map((response) => response.results),
takeUntil(this.unsubscribeNotifer),
catchError(() => EMPTY),
finalize(() => {
this.loading = false
})
)
})
)
.subscribe((tasks) => {
this.tasks.set(tasks)
}) })
} }
public get needsAttentionTasks(): PaperlessTask[] { private fetchUnacknowledgedTasksCounts(): Observable<PaperlessTaskStatusCounts> {
return this.tasks().filter((t) => this.loading = true
[PaperlessTaskStatus.Failure, PaperlessTaskStatus.Revoked].includes( return this.statusCounts({ acknowledged: false }).pipe(
t.status takeUntil(this.unsubscribeNotifer),
) catchError(() => EMPTY),
finalize(() => {
this.loading = false
})
) )
} }
public get needsAttentionTasksCount(): number {
return this.tasksStatusCounts().needs_attention
}
public reload() { public reload() {
this.reloadNotifier.next() this.reloadNotifier.next()
} }
@@ -105,7 +95,7 @@ export class TasksService {
public dismissTasks(task_ids: Set<number>): Observable<any> { public dismissTasks(task_ids: Set<number>): Observable<any> {
return this.http return this.http
.post(`${this.baseUrl}tasks/acknowledge/`, { .post(`${this.baseUrl}${this.endpoint}/acknowledge/`, {
tasks: [...task_ids], tasks: [...task_ids],
}) })
.pipe( .pipe(
@@ -119,7 +109,7 @@ export class TasksService {
public dismissAllTasks(): Observable<any> { public dismissAllTasks(): Observable<any> {
return this.http return this.http
.post(`${this.baseUrl}tasks/acknowledge/`, { .post(`${this.baseUrl}${this.endpoint}/acknowledge/`, {
all: true, all: true,
}) })
.pipe( .pipe(
+65
View File
@@ -46,6 +46,7 @@ from documents.models import UiSettings
from documents.models import Workflow from documents.models import Workflow
from documents.models import WorkflowAction from documents.models import WorkflowAction
from documents.models import WorkflowTrigger from documents.models import WorkflowTrigger
from documents.search import get_backend
from documents.signals.handlers import run_workflows from documents.signals.handlers import run_workflows
from documents.tests.utils import ConsumeTaskMixin from documents.tests.utils import ConsumeTaskMixin
from paperless_testing.dirs import DirectoriesMixin from paperless_testing.dirs import DirectoriesMixin
@@ -66,6 +67,70 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
self.client.force_authenticate(user=self.user) self.client.force_authenticate(user=self.user)
cache.clear() cache.clear()
def test_detail_save_indexes_final_workflow_state(self) -> None:
viewer = UserFactory()
grant_global(viewer, "view_document")
inbox = TagFactory(is_inbox_tag=True)
doc = DocumentFactory(owner=self.user, content="distinctiveword")
doc.tags.add(inbox)
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
action = WorkflowAction.objects.create(assign_title="Workflow title")
action.assign_view_users.add(viewer)
action.remove_view_users.add(viewer)
workflow = Workflow.objects.create(name="Update document")
workflow.triggers.add(trigger)
workflow.actions.add(action)
backend = get_backend()
backend.add_or_update(doc)
for action_type in (
WorkflowAction.WorkflowActionType.ASSIGNMENT,
WorkflowAction.WorkflowActionType.REMOVAL,
WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
):
with self.subTest(action_type=action_type):
grant = action_type == WorkflowAction.WorkflowActionType.ASSIGNMENT
action.type = action_type
action.save()
trigger.filter_has_not_tags.set([inbox] if grant else [])
trigger.filter_has_tags.set([] if grant else [inbox])
self.client.force_authenticate(self.user)
response = self.client.patch(
f"/api/documents/{doc.pk}/",
{"tags": [] if grant else [inbox.pk]},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.client.force_authenticate(viewer)
self.assertEqual(
self.client.get(f"/api/documents/{doc.pk}/").status_code,
status.HTTP_200_OK if grant else status.HTTP_404_NOT_FOUND,
)
# Check the index directly: the API's DB permission filtering
# would otherwise hide stale grants and trashed index entries.
self.assertEqual(
list(backend.search_ids("distinctiveword", user=viewer)),
[doc.pk] if grant else [],
)
if grant:
response = self.client.get(
"/api/documents/",
{"query": "distinctiveword AND title:workflow"},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
[result["id"] for result in response.data["results"]],
[doc.pk],
)
elif action_type == WorkflowAction.WorkflowActionType.MOVE_TO_TRASH:
self.assertFalse(Document.objects.filter(pk=doc.pk).exists())
self.assertEqual(
list(backend.search_ids("distinctiveword", user=None)),
[],
)
def testDocuments(self) -> None: def testDocuments(self) -> None:
response = self.client.get("/api/documents/").data response = self.client.get("/api/documents/").data
+8 -2
View File
@@ -1304,13 +1304,19 @@ class DocumentViewSet(
from documents.search import get_backend from documents.search import get_backend
get_backend().add_or_update(refreshed_doc)
document_updated.send( document_updated.send(
sender=self.__class__, sender=self.__class__,
document=refreshed_doc, document=refreshed_doc,
) )
# Index the final workflow state, including deletion.
backend = get_backend()
final_doc = Document.objects.filter(pk=root_doc.pk).first()
if final_doc is None:
backend.remove(root_doc.pk)
else:
backend.add_or_update(final_doc)
return response return response
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):