Skip to content

Commit 96d5701

Browse files
valentijnscholtenMaffoochclaude
authored
feat: allow import/reimport to wait for deduplication to complete (#15007)
* feat(importers): add async-wait import execution mode Introduce a third import/reimport post-processing execution mode and make it configurable per request and via the user profile. - async (default): dispatch post-processing to the background, respond immediately - async_wait (new): dispatch to the background but wait for deduplication to finish before responding, so scan_added notifications and the returned statistics reflect the deduplicated state - sync: run post-processing inline in the web process Replace the legacy UserContactInfo.block_execution boolean with an import_execution_mode CharField; a data migration maps block_execution=True to the 'sync' mode. wants_block_execution() now derives from the sync mode, preserving global foreground-execution semantics for all async tasks. Add a request field import_execution_mode (ImportScanSerializer/ ReImportScanSerializer) resolved against the profile (request override wins), and a deduplication_complete boolean in the import/reimport response. The post_process_findings_batch task now stores its result (ignore_result=False) so async_wait can join on it via AsyncResult.get(); the join is bounded by the new DD_IMPORT_ASYNC_WAIT_TIMEOUT setting. * feat(importers): make scan_added notifications dedup-aware notify_scan_added used the importer's in-memory finding lists, whose duplicate flag is stale because deduplication runs on separately-fetched instances. Once deduplication has completed (sync or async_wait, signalled by deduplication_complete), refresh the duplicate flag from the database and split each action list into "real" and duplicate findings: - findings_new -> net-new (excludes deduplicated) - findings_new_duplicate / findings_reactivated_duplicate / findings_untouched_duplicate - finding_count -> recomputed to exclude new/reactivated duplicates (so an all-duplicate import sends scan_added_empty) In plain async mode (dedup not awaited) the lists are left untouched, preserving historical behavior. Mail and webhook scan_added templates render the new duplicate sections. Note: import/reimport response statistics are already dedup-accurate once awaited — Test.statistics / Test_Import.statistics query Finding and annotate the duplicate count directly; only the notification used stale in-memory data. * refactor(importers): await dedup before all import notifications Move wait_for_post_processing() ahead of the test_added notification dispatch (not just notify_scan_added) so both notifications sent during a fresh import are emitted after deduplication has completed in async_wait mode. The reimporter already orders the await before its single notification. * refactor: rename import_execution_mode to deduplication_execution_mode The mode governs whether the request waits for deduplication, so name it for that. Rename the UserContactInfo field, the request/serializer field, the ImporterOptions attribute and validator, the resolver, and the IMPORT_EXECUTION_MODE_* constants to DEDUPLICATION_EXECUTION_MODE_*. Add a RenameField migration (0271) rather than mutating the add/remove migrations introduced earlier on this branch. * refactor: rename DD_IMPORT_ASYNC_WAIT_TIMEOUT to DD_DEDUPLICATION_ASYNC_WAIT_TIMEOUT, default 60s * refactor: keep block_execution as global switch, deduplication_execution_mode for import dedup block_execution gates every async task (notifications, jira, grading, deletes, reindex, ...) via we_want_async, so it must remain. Restore it as the global "run all async tasks in the foreground" flag and make deduplication_execution_mode an independent field that only controls how import/reimport deduplication post-processing is dispatched and awaited. - Restore the block_execution field; wants_block_execution() reads it again. - resolve_deduplication_execution_mode() falls back to block_execution -> sync. - Replace the destructive remove migration (0270) with a seed-only data migration that maps block_execution=True -> deduplication_execution_mode 'sync'; keep both. - Restore fixtures, the profile form field, and the user detail view (both fields). - Revert the test sweep: tests that need global foreground use block_execution=True again; the dedicated deduplication_execution_mode tests are updated for the split. * docs: 2.60 upgrade notes for deduplication_execution_mode * fix(importers): honor profile deduplication_execution_mode for UI import/reimport The API resolves deduplication_execution_mode in the serializer, but the UI import/reimport views built their context straight from the form and never resolved it, so UI imports silently defaulted to async regardless of the user's profile. Resolve it from the profile in both UI process_form paths. * test: revert set_block_execution to the block_execution checkbox Option B keeps block_execution as a checkbox in the profile form, so the integration helper toggles id_block_execution again instead of the deduplication_execution_mode select (which no longer existed under that id). * refactor(migrations): split into schema add + data seed (2 migrations) Collapse the add/rename pair into a single AddField that creates deduplication_execution_mode with its final name, and keep the seed as a separate data migration, per the convention of keeping schema and data migrations apart. Drops the interim rename migration. * test: use versioned_fixtures so dedup mode tests pass under V3_FEATURE_LOCATIONS The CI 'true' matrix variant enables V3_FEATURE_LOCATIONS, where loading dojo_testdata.json (containing Endpoints) raises NotImplementedError. Decorate the test classes with @versioned_fixtures so the locations fixture is used in that mode, matching the other import/reimport test suites. * test(perf): add async_wait deduplication performance test Covers the 'async_wait' execution mode: post-processing is dispatched to a background worker and the request joins on it before responding. The dedup queries run in the worker (separate connection), not the web request, so the only web-side delta over the plain async path is the post-dedup notification refresh SELECT (+1): 109->110 first import, 89->90 second. Does not use CELERY_TASK_ALWAYS_EAGER (that would run the task inline on the request connection and wrongly count worker queries). The dedup batch is dispatched async and the join (AsyncResult.get) is mocked to return instantly, simulating a finished worker. Adds an optional dedup_mode param to _deduplication_performance. * fix(migrations): renumber import-execution-mode migrations onto dev (0270/0271) dev added 0269_normalize_blank_finding_components, colliding with this branch's 0269. Renumber to 0270_usercontactinfo_deduplication_execution_mode and 0271_seed_deduplication_execution_mode and repoint the dependency chain onto dev's 0269. * fix(importers): drop ignore_result=False override on post_process_findings_batch The async_wait deduplication join works with the global CELERY_TASK_IGNORE_RESULT=True default (verified live by reviewer against a real broker/worker). Removing the per-task override avoids writing a celery_taskmeta result row for every batch of every import (incl. plain async), which were retained for CELERY_RESULT_EXPIRES. * test(e2e): selenium integration test for async_wait deduplication mode Adds async_wait coverage to the existing tests/dedupe_test.py DedupeTest suite: set the user profile to 'async_wait', import a Checkmarx report into two tests of a dedupe-on-engagement engagement, and assert the duplicates are visible immediately (check_nb_duplicates_no_wait, no sleep/retry) — proving the request blocked until the cross-process deduplication finished. This exercises the real worker->request join that the eager unit tests cannot cover. Adds a set_deduplication_execution_mode() helper to base_test_class. * fix(importers): make async_wait actually join via per-dispatch ignore_result The async_wait join via AsyncResult.get() was a silent no-op: the global CELERY_TASK_IGNORE_RESULT=True means post_process_findings_batch never stores a result, so .get() returns immediately instead of blocking. The import responded with deduplication_complete=true while dedup had not run. Pass ignore_result=False only on the async_wait dispatch, threaded through dojo_dispatch_task to apply_async. The task decorator stays plain @app.task, so async/sync imports do not write useless celery_taskmeta result rows. * test(dedupe): replace Selenium async_wait test with real-worker API guard The Selenium async_wait test could not fail when the cross-process join was broken: with only 2 findings and several page navigations between the import and the duplicate check, the background dedupe finished regardless of whether the import actually blocked. It also never read deduplication_complete. Remove it and add ImportAsyncWaitApiTest: a pure-API test that imports a 400-finding report twice into a dedup-on-engagement engagement against the real worker, then asserts (no sleep/retry) that async_wait reports deduplication_complete=true with all 400 marked duplicate at response time, while plain async returns incomplete with fewer marked. A no-op join makes async_wait behave like async and fails the test. * test(dedupe): make async_wait integration test deterministic via gated dedup delay The giant-report approach was non-deterministic in CI: deduplication is dispatched per batch during the import, so on a fast worker plain `async` finishes dedup before the response and marks all findings too. The count discriminator then couldn't tell async_wait from async (the control failed `400 != 400`), and a broken async_wait could false-pass. Add DD_DEDUPLICATION_BATCH_PROCESS_TEST_DELAY (default 0, no-op in production), set to 3s on the integration-test celeryworker only. Each dedup batch sleeps before doing work, so async_wait blocks past it (all duplicates marked) while async returns mid-delay (none marked) — independent of worker speed. Rewrite ImportAsyncWaitApiTest to a small report and assert async marks exactly 0. Verified locally: green normally; fails `0 != 25` when the join fix is reverted. * test(dedupe): scope async_wait dedup delay to the test's findings, widen margin The first delay attempt broke two ways in CI: - It delayed *every* dedup batch in the UI suite, breaking the timing-sensitive close_old_findings_dedupe_test. - 3s was too short: the async control's import + observation GET took longer than that on CI, so dedup finished first and the control marked all findings. Add DD_DEDUPLICATION_BATCH_PROCESS_TEST_DELAY_FILTER (a finding-title prefix, case-insensitive) so only the async_wait test's own findings are delayed -- other dedupe tests run at full speed. Bump the delay to 10s for margin over the import round-trip. The Generic importer title-cases findings, so the match uses istartswith. Verified locally against the real worker: test_wait now blocks ~10s and marks all 25; async marks 0 at response time; reverting the join fix fails 0 != 25. * test(dedupe): log WARN when the test-only dedup delay fires Diagnostic for CI: surfaces exactly which post_process_findings_batch invocations hit the integration-test delay (and the finding count + filter), to confirm the async_wait test's batches are being delayed as intended. * test(dedupe): assert only deduplication_complete for the async control The async control's marked-count assertion was non-deterministic: its post_process_findings_batch task races the import request's own DB commit, so on CI the findings were not visible when the delay filter ran (no sleep) yet were deduplicated moments later -> the control saw all 25 marked and failed. Drop the count assertion for async and keep only deduplication_complete is False, which is deterministic and mode-based. The duplicate-count guarantee stays on the async_wait test, where the worker-side delay (confirmed firing via the WARN log: 10s/25 findings per batch) makes it robust and a no-op join still fails 0 != NUM_FINDINGS. * test(dedupe): note async_wait eager test is not a real guard Document that test_import_async_wait_returns_statistics cannot fail when the cross-process join is broken (eager .get() always returns inline), pointing to the real-worker ImportAsyncWaitApiTest. Kept for endpoint/field plumbing coverage and future reference. * docs(dedupe): document import/reimport deduplication execution mode Extend the deduplication 'Background processing' section with the new deduplication_execution_mode (async / async_wait / sync), the per-profile and per-request override, the deduplication_complete response field, the DD_DEDUPLICATION_ASYNC_WAIT_TIMEOUT bound, and its relationship to the global block_execution flag. Previously this was only in the 2.60 upgrade note. * feat(importers): log async_wait deduplication wait duration Record how long the import request blocked on the deduplication join: - DEBUG line with elapsed seconds + task count + success on completion. - the timeout/error WARNING now includes the elapsed time before it gave up. Timing detail is DEBUG so it does not add noise per import in production; the timeout case stays at WARNING since it signals a degraded (respond-anyway) join. * chore: stop tracking local-only files committed by mistake CLAUDE.local.md, fix-jira-push-eligibility.md, phase10-session-prompt.md, scripts/run-nuclei-docker.sh and tagoulus-replcement-options.md were accidentally added in an earlier refactor commit; none belong in this feature. Remove them from version control (kept on disk locally) and gitignore CLAUDE.local.md / CLAUDE.md so they cannot be re-added. * fix(migrations): renumber dedup-execution-mode migrations onto current dev leaf dev advanced past the previous 0270/0271 numbering (it now ships 0270_finding_visibility_perf_indexes through 0272_reencrypt_tool_config_credentials_aes_gcm), so the PR's 0270/0271 migrations reintroduced a second leaf node off 0269_normalize_blank_finding_components -> 'Conflicting migrations detected; multiple leaf nodes in the migration graph'. Renumber onto the current leaf: - 0270_usercontactinfo_deduplication_execution_mode -> 0273_..., dep 0272_reencrypt_tool_config_credentials_aes_gcm - 0271_seed_deduplication_execution_mode -> 0274_..., dep 0273_usercontactinfo_deduplication_execution_mode Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(perf): bump async_wait dedup query baseline to match measured counts CI 'Check counts are up to date' measured 94/74 for test_deduplication_performance_pghistory_async_wait (first/second import) vs the committed 93/73. The web-side delta over plain async (92/72) is +2, not +1: the post-dedup notification refresh SELECT plus one result-backend write from the per-dispatch ignore_result=False that enables the AsyncResult.get() join. Update the baseline and the docstring accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(migrations): renumber dedup-execution-mode migrations onto current dev leaf Rebasing onto dev pulled in 0273_product_upper_name_index, 0274_finding_sla_expiration_index and 0275_usercontactinfo_user_state_details, colliding with this branch's 0273/0274 and producing two leaf nodes. Renumber onto the current leaf: - 0273_usercontactinfo_deduplication_execution_mode -> 0276_..., dep 0275_usercontactinfo_user_state_details - 0274_seed_deduplication_execution_mode -> 0277_..., dep 0276_usercontactinfo_deduplication_execution_mode Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: move deduplication_execution_mode upgrade note to 3.1 The feature now ships in 3.1.0, not 2.60. Revert 2.60.md to its "no special instructions" state and fold the deduplication execution mode section into 3.1.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6851210 commit 96d5701

28 files changed

Lines changed: 987 additions & 22 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,3 +153,5 @@ docs/.hugo_build.lock
153153
# claude etc
154154
MEMORY.md
155155
.claude/
156+
CLAUDE.md
157+
CLAUDE.local.md

docker-compose.override.integration_tests.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ services:
4646
environment:
4747
DD_DATABASE_URL: ${DD_TEST_DATABASE_URL:-postgresql://defectdojo:defectdojo@postgres:5432/test_defectdojo}
4848
DD_V3_FEATURE_LOCATIONS: ${DD_V3_FEATURE_LOCATIONS:-False}
49+
# Delay deduplication batches so the async_wait integration test can
50+
# deterministically distinguish a blocking join (async_wait) from a
51+
# non-blocking one (async). Scoped by _FILTER to that test's findings so
52+
# other dedupe tests are unaffected. Integration-test stack only; never prod.
53+
DD_DEDUPLICATION_BATCH_PROCESS_TEST_DELAY: 10
54+
DD_DEDUPLICATION_BATCH_PROCESS_TEST_DELAY_FILTER: "async_wait finding"
4955
initializer:
5056
environment:
5157
PYTHONWARNINGS: error # We are strict about Warnings during testing

docs/content/en/open_source/upgrading/3.1.md

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,50 @@
22
title: 'Upgrading to DefectDojo Version 3.1.x'
33
toc_hide: true
44
weight: -20260615
5-
description: New optional setting DD_OS_MESSAGE_ENABLED to control the open-source promo banner.
5+
description: New optional setting DD_OS_MESSAGE_ENABLED to control the open-source promo banner, and a new deduplication execution mode for import/reimport.
66
---
77
There are no breaking changes when upgrading to 3.1.x. Check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/3.1.0) for the contents of the release.
88

99
### New setting: `DD_OS_MESSAGE_ENABLED`
1010

1111
This release adds the `DD_OS_MESSAGE_ENABLED` setting (default `True`), which controls the open-source promotional ("Upgrade to Pro") banner. The default preserves the existing behavior. Set `DD_OS_MESSAGE_ENABLED=False` to hide the banner; when disabled, DefectDojo skips the outbound request that fetches the message.
12+
13+
## Deduplication execution mode for import/reimport
14+
15+
This release adds a new `deduplication_execution_mode` setting that controls how
16+
import/reimport deduplication post-processing is dispatched and whether the API
17+
response waits for it. It can be set per user (profile) and overridden per request
18+
on the import and reimport endpoints.
19+
20+
Modes:
21+
22+
- `async` (default): deduplication and the rest of post-processing are dispatched
23+
to the background and the response returns immediately. This is the historical
24+
behavior; nothing changes for existing users.
25+
- `async_wait`: post-processing is still dispatched to the background, but the
26+
request waits for deduplication to finish before responding. As a result the
27+
`scan_added` notification and the statistics in the import/reimport response
28+
reflect the deduplicated state (findings that turned out to be duplicates are
29+
no longer counted/listed as new). JIRA push, product grading and other
30+
non-deduplication tasks remain asynchronous and are not awaited.
31+
- `sync`: import deduplication runs inline in the web request.
32+
33+
The wait in `async_wait` is bounded by the new `DD_DEDUPLICATION_ASYNC_WAIT_TIMEOUT`
34+
environment variable (default `60` seconds). If no worker picks up the work within
35+
the timeout, the request responds anyway (degrading to the `async` outcome) rather
36+
than hanging.
37+
38+
The import/reimport response now also includes a `deduplication_complete` boolean
39+
indicating whether deduplication had finished by the time the response was produced.
40+
41+
### Relationship to `block_execution`
42+
43+
The existing `block_execution` profile flag is unchanged. It remains the global
44+
switch that forces **all** of a user's asynchronous tasks (notifications, JIRA
45+
push, product grading, deduplication, ...) to run in the foreground.
46+
`deduplication_execution_mode` is independent and narrower — it only affects
47+
import/reimport deduplication post-processing. A user who has `block_execution`
48+
enabled continues to get fully synchronous imports; the upgrade migration seeds
49+
their `deduplication_execution_mode` to `sync` so behavior is unchanged.
50+
51+
No action is required to upgrade.

docs/content/triage_findings/finding_deduplication/about_deduplication.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,18 @@ The endpoints also have to match for the findings to be considered duplicates, s
108108

109109
- Dedupe is triggered on import/reimport and during certain updates run via Celery in the background.
110110

111+
### Import/reimport deduplication execution mode
112+
113+
For import and reimport you can control how deduplication post-processing is dispatched and whether the API response waits for it. Set it per user on the profile page (**Deduplication execution mode**), or override it per request with the `deduplication_execution_mode` field on the import/reimport endpoints (the request value takes precedence over the profile).
114+
115+
- `async` (default): deduplication and the rest of post-processing run in the background and the response returns immediately. Historical behavior; the response is produced before findings are deduplicated.
116+
- `async_wait`: post-processing is still dispatched to the background, but the request waits for deduplication to finish before responding. The `scan_added` notification and the statistics in the response then reflect the deduplicated state (findings that turned out to be duplicates are no longer counted/listed as new). JIRA push, product grading and other non-deduplication tasks remain asynchronous and are not awaited. The wait is bounded by `DD_DEDUPLICATION_ASYNC_WAIT_TIMEOUT` (default `60` seconds); if no worker picks up the work in time, the request responds anyway rather than hanging.
117+
- `sync`: import deduplication runs inline in the web request.
118+
119+
The import/reimport response includes a `deduplication_complete` boolean indicating whether deduplication had finished by the time the response was produced (`true` for `sync` and for a completed `async_wait`, `false` for `async`).
120+
121+
This is independent of the global `block_execution` profile flag, which forces **all** of a user's asynchronous tasks (notifications, JIRA push, product grading, deduplication, ...) to the foreground. When no execution mode is set, `block_execution=True` falls back to `sync`.
122+
111123
## Service field and its impact
112124

113125
- By default, `HASH_CODE_FIELDS_ALWAYS = ["service"]`, meaning the `service` associated with a finding is appended to the hash for all scanners.

dojo/api_v2/serializers.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,14 @@
2424
from dojo.importers.default_reimporter import DefaultReImporter
2525
from dojo.location.models import Location
2626
from dojo.models import (
27+
DEDUPLICATION_EXECUTION_MODE_CHOICES,
2728
IMPORT_ACTIONS,
2829
SEVERITIES,
2930
SEVERITY_CHOICES,
3031
STATS_FIELDS,
3132
App_Analysis,
3233
Development_Environment,
34+
Dojo_User,
3335
DojoMeta,
3436
Endpoint,
3537
Engagement,
@@ -431,6 +433,16 @@ class CommonImportScanSerializer(serializers.Serializer):
431433
allow_null=True, default=None, queryset=User.objects.all(),
432434
)
433435
push_to_jira = serializers.BooleanField(default=False)
436+
deduplication_execution_mode = serializers.ChoiceField(
437+
required=False,
438+
allow_null=True,
439+
choices=DEDUPLICATION_EXECUTION_MODE_CHOICES,
440+
help_text="Override how import post-processing (deduplication, jira push, grading, ...) is executed for "
441+
"this request. 'async' dispatches post-processing to the background and responds immediately (default). "
442+
"'async_wait' dispatches to the background but waits for deduplication to finish before responding, so "
443+
"notifications and the returned statistics reflect the deduplicated state. 'sync' runs everything inline. "
444+
"If omitted, falls back to the user's profile setting (deduplication_execution_mode).",
445+
)
434446
environment = serializers.CharField(required=False)
435447
build_id = serializers.CharField(
436448
required=False, help_text="ID of the build that was scanned.",
@@ -476,6 +488,14 @@ class CommonImportScanSerializer(serializers.Serializer):
476488
help_text=_("Also referred to as 'Organization' ID."),
477489
)
478490
statistics = ImportStatisticsSerializer(read_only=True, required=False)
491+
deduplication_complete = serializers.BooleanField(
492+
read_only=True,
493+
required=False,
494+
help_text="Whether deduplication had finished by the time this response was produced. "
495+
"True for 'sync' and for 'async_wait' when deduplication completed within the timeout; "
496+
"False for 'async' (deduplication is still running in the background) or when an "
497+
"'async_wait' import timed out waiting for it.",
498+
)
479499
pro = serializers.ListField(read_only=True, required=False)
480500
apply_tags_to_findings = serializers.BooleanField(
481501
help_text="If set to True, the tags will be applied to the findings",
@@ -534,6 +554,7 @@ def process_scan(
534554
data["product_id"] = test.engagement.product.id
535555
data["product_type_id"] = test.engagement.product.prod_type.id
536556
data["statistics"] = {"after": test.statistics}
557+
data["deduplication_complete"] = importer.deduplication_complete
537558
duration = time.perf_counter() - start_time
538559
LargeScanSizeProductAnnouncement(response_data=data, duration=duration)
539560
ScanTypeProductAnnouncement(response_data=data, scan_type=context.get("scan_type"))
@@ -632,6 +653,14 @@ def setup_common_context(self, data: dict) -> dict:
632653
if eng_end_date:
633654
context["target_end"] = context.get("engagement_end_date")
634655

656+
# Resolve the effective import execution mode: request override (if any)
657+
# takes precedence over the user's profile setting, otherwise default async.
658+
request = self.context.get("request")
659+
user = getattr(request, "user", None)
660+
context["deduplication_execution_mode"] = Dojo_User.resolve_deduplication_execution_mode(
661+
user, data.get("deduplication_execution_mode"),
662+
)
663+
635664
return context
636665

637666

@@ -805,11 +834,11 @@ def process_scan(
805834
try:
806835
logger.debug(f"process_scan called with context: {context}")
807836
start_time = time.perf_counter()
837+
processor = None
808838
if test := context.get("test"):
809839
statistics_before = test.statistics
810-
context["test"], _, _, _, _, _, test_import = self.get_reimporter(
811-
**context,
812-
).process_scan(
840+
processor = self.get_reimporter(**context)
841+
context["test"], _, _, _, _, _, test_import = processor.process_scan(
813842
context.pop("scan", None),
814843
)
815844
if test_import:
@@ -821,9 +850,10 @@ def process_scan(
821850
# Do not close old findings when creating a brand new test: there are no
822851
# existing findings to compare against, and close_old_findings would
823852
# incorrectly close findings from other tests in the same scope.
824-
context["test"], _, _, _, _, _, _ = self.get_importer(
853+
processor = self.get_importer(
825854
**{**context, "close_old_findings": False},
826-
).process_scan(
855+
)
856+
context["test"], _, _, _, _, _, _ = processor.process_scan(
827857
context.pop("scan", None),
828858
)
829859
else:
@@ -842,6 +872,8 @@ def process_scan(
842872
if statistics_delta:
843873
data["statistics"]["delta"] = statistics_delta
844874
data["statistics"]["after"] = test.statistics
875+
if processor is not None:
876+
data["deduplication_complete"] = processor.deduplication_complete
845877
duration = time.perf_counter() - start_time
846878
LargeScanSizeProductAnnouncement(response_data=data, duration=duration)
847879
ScanTypeProductAnnouncement(response_data=data, scan_type=context.get("scan_type"))

dojo/celery_dispatch.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,11 @@ def dojo_dispatch_task(task_or_sig: _SupportsSi | _SupportsApplyAsync | Signatur
6161
6262
- Inject `async_user_id` if missing.
6363
- Capture and inject pghistory context if available.
64-
- Respect `force_sync=True` (foreground execution) and user `block_execution`.
64+
- Respect `force_sync=True` (foreground execution) and the user's
65+
block_execution flag.
6566
- Respect `force_async=True` (background execution even when the caller
66-
would otherwise run synchronously, e.g. user has `block_execution`).
67-
`force_async` wins over `force_sync` and `block_execution`.
67+
would otherwise run synchronously, e.g. user has block_execution).
68+
`force_async` wins over `force_sync` and block_execution.
6869
- Support `countdown=<seconds>` for async dispatch.
6970
7071
Returns:
@@ -75,6 +76,11 @@ def dojo_dispatch_task(task_or_sig: _SupportsSi | _SupportsApplyAsync | Signatur
7576
from dojo.decorators import dojo_async_task_counter, we_want_async # noqa: PLC0415 circular import
7677

7778
countdown = cast("int", kwargs.pop("countdown", 0))
79+
# Per-dispatch result storage. The task default is `ignore_result` (global
80+
# CELERY_TASK_IGNORE_RESULT=True), so AsyncResult.get() is a no-op. Callers
81+
# that need to join on the result later (e.g. import 'async_wait' mode) pass
82+
# ignore_result=False to force this one dispatch to store its result.
83+
ignore_result = kwargs.pop("ignore_result", None)
7884
injected = _inject_async_user(kwargs)
7985
injected = _inject_pghistory_context(injected)
8086

@@ -83,7 +89,10 @@ def dojo_dispatch_task(task_or_sig: _SupportsSi | _SupportsApplyAsync | Signatur
8389

8490
if we_want_async(*sig.args, func=getattr(sig, "type", None), **sig_kwargs):
8591
# DojoAsyncTask.apply_async tracks async dispatch. Avoid double-counting here.
86-
return sig.apply_async(countdown=countdown)
92+
apply_kwargs = {"countdown": countdown}
93+
if ignore_result is not None:
94+
apply_kwargs["ignore_result"] = ignore_result
95+
return sig.apply_async(**apply_kwargs)
8796

8897
# Track foreground execution as a "created task" as well (matches historical dojo_async_task behavior)
8998
dojo_async_task_counter.incr(str(sig.task), args=sig.args, kwargs=sig_kwargs)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from django.db import migrations, models
2+
3+
4+
class Migration(migrations.Migration):
5+
6+
dependencies = [
7+
('dojo', '0275_usercontactinfo_user_state_details'),
8+
]
9+
10+
operations = [
11+
migrations.AddField(
12+
model_name='usercontactinfo',
13+
name='deduplication_execution_mode',
14+
field=models.CharField(blank=True, choices=[('async', 'Async (do not wait)'), ('async_wait', 'Async, wait for deduplication'), ('sync', 'Synchronous (block)')], help_text="Controls how import/reimport deduplication post-processing is executed. 'Async' dispatches it to the background and returns immediately (default). 'Async, wait for deduplication' dispatches to the background but waits for deduplication to finish before responding, so notifications and statistics reflect the deduplicated state. 'Synchronous' runs the import deduplication inline. Can be overridden per request. Independent of block_execution, which forces all async tasks (notifications, jira, ...) to the foreground.", max_length=20, null=True),
15+
),
16+
]
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from django.db import migrations
2+
3+
4+
def seed_deduplication_execution_mode(apps, schema_editor):
5+
"""
6+
Seed the new import deduplication execution mode from the legacy block_execution flag.
7+
8+
block_execution remains the global "run all async tasks in the foreground" switch;
9+
users who had it enabled get the synchronous deduplication mode so import behavior is
10+
unchanged for them.
11+
"""
12+
UserContactInfo = apps.get_model("dojo", "UserContactInfo")
13+
UserContactInfo.objects.filter(block_execution=True).update(deduplication_execution_mode="sync")
14+
15+
16+
def unseed_deduplication_execution_mode(apps, schema_editor):
17+
"""Reverse: clear the seeded synchronous mode."""
18+
UserContactInfo = apps.get_model("dojo", "UserContactInfo")
19+
UserContactInfo.objects.filter(deduplication_execution_mode="sync").update(deduplication_execution_mode=None)
20+
21+
22+
class Migration(migrations.Migration):
23+
24+
dependencies = [
25+
('dojo', '0276_usercontactinfo_deduplication_execution_mode'),
26+
]
27+
28+
operations = [
29+
migrations.RunPython(seed_deduplication_execution_mode, unseed_deduplication_execution_mode),
30+
]

dojo/engagement/ui/views.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -948,6 +948,10 @@ def process_form(
948948
"create_finding_groups_for_all_findings": form.cleaned_data.get("create_finding_groups_for_all_findings", None),
949949
"environment": self.get_development_environment(environment_name=form.cleaned_data.get("environment")),
950950
})
951+
# Honor the user's profile deduplication_execution_mode for UI imports. The API resolves
952+
# this in the serializer; the UI has no per-import selector, so fall back to the profile
953+
# (or block_execution) instead of silently defaulting to async.
954+
context["deduplication_execution_mode"] = Dojo_User.resolve_deduplication_execution_mode(request.user)
951955
# Create the engagement if necessary
952956
self.create_engagement(context)
953957
# close_old_findings_product_scope is a modifier of close_old_findings.

dojo/finding/helper.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from contextlib import suppress
33
from datetime import datetime
44
from itertools import batched
5-
from time import strftime
5+
from time import sleep, strftime
66

77
from django.conf import settings
88
from django.db import transaction
@@ -470,6 +470,20 @@ def post_process_findings_batch(
470470
force_sync=False,
471471
**kwargs,
472472
):
473+
# Test-only hook: when DEDUPLICATION_BATCH_PROCESS_TEST_DELAY > 0 (set only in
474+
# the integration-test stack) block this batch so the async_wait integration
475+
# test can deterministically distinguish 'async_wait' (which joins on this
476+
# task) from 'async' (which does not). Default 0 -> no effect in production.
477+
# DEDUPLICATION_BATCH_PROCESS_TEST_DELAY_FILTER (a finding-title prefix) scopes
478+
# the delay to that one test's findings so unrelated dedupe tests are not slowed.
479+
if (test_delay := settings.DEDUPLICATION_BATCH_PROCESS_TEST_DELAY) > 0:
480+
delay_filter = settings.DEDUPLICATION_BATCH_PROCESS_TEST_DELAY_FILTER
481+
if not delay_filter or Finding.objects.filter(id__in=finding_ids, title__istartswith=delay_filter).exists():
482+
logger.warning(
483+
"post_process_findings_batch: TEST-ONLY delay of %ss for %d finding(s) (filter=%r)",
484+
test_delay, len(finding_ids) if finding_ids else 0, delay_filter,
485+
)
486+
sleep(test_delay)
473487

474488
logger.debug(
475489
f"post_process_findings_batch called: finding_ids_count={len(finding_ids) if finding_ids else 0}, "

0 commit comments

Comments
 (0)