The controller_dir property hardcoded /tmp/awx_{id} as the expected
volume mount prefix. On containerized installs the job execution
directory is at /home/ansible/aap/controller/data/job_execution/awx_{id},
causing a RuntimeError when the prefix didn't match.
Changed to exact path component matching on the awx_{id} segment,
which works for both traditional and containerized path layouts
while preventing false matches (e.g. awx_1 matching awx_10).
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
After creating a user on the gateway API, the code immediately queries
the controller API to find the synced user. On containerized installs
(2.6+), the gateway-to-controller sync may not have completed yet,
causing an IndexError when .results.pop() is called on an empty list.
This affected ~32 tests across the tower-qa legacy suite on every
containerized pipeline run (2.6, 2.7, 2.8).
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
AAP-90120: add _mesh_all_ready_nodes_visible() to block peer-judgment
while KnownConnectionCosts is empty after a controller restart. Gate
fails open when mesh_status is unavailable. Eliminates double receptor
status call per heartbeat.
AAP-90121: wrap each instance's reap+mark_offline block in
advisory_lock('task_manager_lock', wait=False) so the task manager
cannot dispatch to a node being concurrently marked offline. Extracts
_reap_and_mark_lost_instance() helper.
Tests: rewrite new test classes to use real Django DB (functional/)
instead of MagicMock objects.
---------
Signed-off-by: Hui Song <hsong@redhat.com>
Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* Add PostgreSQL utility functions for metrics-utility
Creates two plpgsql functions used by metrics-utility when querying the
AWX controller database via a read-only connection:
- metrics_utility_parse_yaml_field: extracts a named field from YAML-encoded text
- metrics_utility_is_valid_json: validates whether a text value is valid JSON
GRANT EXECUTE TO PUBLIC so any database user (including ms_awx_readonly)
can call the functions without requiring an explicit per-user grant.
The migration is a no-op on SQLite so unit and functional tests are unaffected.
Assisted-by: Claude Code (claude-sonnet-4-6)
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Ciaran Shiels <cas.ireland@Gmail.com>
* Fix regex injection and broad exception in metrics utility migration
- Escape `field` parameter before interpolating into regex pattern using
regexp_replace with the whole-match `&` reference rather than bare
concatenation; prevents regex metacharacters in the field name from
altering match semantics
- Narrow `EXCEPTION WHEN others` to `WHEN invalid_text_representation`
(SQLSTATE 22P02) in metrics_utility_is_valid_json so only the expected
JSON-cast failure is swallowed and unexpected runtime errors propagate
Assisted-by: Claude Code (claude-sonnet-4-6)
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Ciaran Shiels <cshiels@redhat.com>
* Renumber migration 0206 to 0210 to avoid conflict
Re-parent create_metrics_utility_functions migration from 0205 to 0209
after other migrations (0206–0209) merged to devel since branch creation.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Renumber migration 0210 to 0211 to avoid conflict
0210_remove_host_last_job_fields arrived on devel after the last rebase,
re-parent create_metrics_utility_functions to depend on it instead.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Signed-off-by: Ciaran Shiels <cas.ireland@Gmail.com>
Signed-off-by: Ciaran Shiels <cshiels@redhat.com>
Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
* [AAP-89071] Add control plane EE unit/functional tests
Add tests for EE resolution, constructed inventory model,
and ansible-sign signature validation tag injection.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* [AAP-89071] Fix control plane EE tests
- Test resolve_execution_environment on ProjectUpdate/InventoryUpdate
(job objects) instead of Project/InventorySource (templates), matching
the actual code path in BaseTask.run()
- Consolidate inventory EE resolution into single parametrized test
- Remove trivial kind-check test
- Fix OrderedManyToManyField.add() to add one inventory at a time
- Add asymmetric host counts for better aggregation validation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* [AAP-89071] Fix InventoryUpdate missing inventory relation
Add inventory=inv to InventoryUpdate.objects.create() so
resolve_execution_environment() can access self.inventory.kind.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
In particular, this brings two important changes:
- rather than opening a new PR every time it runs, it uses a stable
branch name updating its content instead; this means that at most
there is one open PR per target branch
- a new secret "OPENAPI_SPEC_PR_REVIEW_TEAM" is introduced to specify
the name of the github team to notify when creating new PRs; this
helps to make sure that PRs are reviewed
* Remove dead Host.last_job and Host.last_job_host_summary FK fields
PR #16332 replaced these denormalized ForeignKey columns with dynamic
derivation via Host.latest_summary, but left the columns in place.
Nothing writes to or reads from them anymore — drop the dead weight.
- Remove last_job and last_job_host_summary ForeignKey fields from Host
- Convert HostSerializer to use SerializerMethodField instead of FK-backed fields
- Remove redundant JobHostSummary.latest_for_host/latest_job_for_host classmethods
- Add migration 0206 to drop the columns
- Update tests to use Host.latest_summary instead of removed fields/methods
* Remove dead last_job_host_summary_id UPDATE from cleanup_jobs
The Host.last_job_host_summary FK was removed in the prior commit,
so the raw SQL UPDATE that NULLed it before deleting JHS rows now
references a non-existent column and crashes.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The GitHub Actions runner image update reduced available disk space,
causing the awx-operator molecule test to hang and timeout waiting for
pods that can't start due to disk pressure. Prune BuildKit cache and
dangling images after the AWX image build to reclaim space.
Same fix as ansible/tower PRs #7905 (stable-2.6), #7926 (stable-2.7),
and #7927 (release_4.6).
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Added null guard and restructured transactions
* Flag names that are None and add logging warnings for all modules
* Add test for job-deleted-during-transaction race condition
Covers the Job.DoesNotExist handler inside the transaction.atomic()
block to satisfy SonarQube's 90% new code coverage gate.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* AAP-87599 — Re-parent 0206 and update 0207 as merge point for 2.6 backport
Re-parent 0206_jobhostsummary_host_id_idx to depend on 0203 instead
of 0205, and add 0205 as a second dependency on 0207 to converge the
migration graph.
This follows the gateway backport migration pattern (AAP-80663) so that
the same 0206 migration file can be backported to stable-2.6 (where
0204/0205 do not exist) without breaking upgrade paths.
* AAP-87599 — Move merge point to 0209, restore 0207 to original
Reverts the 0205 dependency addition on 0207_alter_skip_tags and
instead adds 0209_merge_0205_0206 at the end of the chain. This
keeps 0207 pristine so it can be backported to stable-2.7 in the
future without a naming conflict.
Migration graph after this change:
0203 → 0204 → 0205 ──────────────────────────────────┐
\ ↓
→ 0206 → 0207_skip_tags → 0208_sys_auditor → 0209_merge
* AAP-87599 — Move merge point to 0207 for devel/2.7 parity
Restructure the migration graph so devel matches stable-2.7 through
the merge point:
- 0207_merge_0205_0206 is now identical on both branches (depends on
0205 + 0206, empty operations)
- Renumber 0207_alter_skip_tags → 0208, 0208_fix_system_auditor → 0209
- Both depend linearly from the merge point forward
Graph:
0205 ──────────────┐
\ ↓
0206 → 0207_merge → 0208_skip_tags → 0209_sys_auditor
* Fix test references after migration rename 0208→0209
Update test_migrations.py to reference 0209_fix_system_auditor_migration
after the merge point restructure moved it from 0208 to 0209.
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Peter Braun <pbraun@redhat.com>
Replace ResourceMixin/accessible_objects/accessible_pk_qs with DAB RBAC access_qs/access_ids_qs
Remove the ResourceMixin class and migrate all callers to the new
DAB RBAC queryset API: access_qs replaces accessible_objects and
access_ids_qs replaces accessible_pk_qs, using permission strings
(view, change, use, etc.) instead of role field names.
Also updates prefetch_page_capabilities to use permission-based
capability definitions instead of role field references.
Restore UNION pattern in TeamAccess.filtered_queryset
The access_qs migration accidentally collapsed the UNION into an OR,
which can be 48x slower at scale (AAP-83319).
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Unify activity stream format for role assignments across old and new APIs
The old API (/api/v2/users/<pk>/roles/) and new API
(/api/v2/role_user_assignments/) were producing different activity stream
entries for the same logical action. The old API created entries via the
Role.members m2m_changed signal in the legacy format, while the new API
used the new-format handler added in AAP-82996.
This change makes the old API produce the same new-format entry by:
- Skipping the legacy activity_stream_associate for Role_members changes
- Letting the mirrored RoleUserAssignment creation fire the new-format
record_role_assignment_activity_stream handler instead
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Record DAB RBAC role assignment events in the activity stream
* The steam message was showing incorrectly, it was inverted based on the legacy setup
* object_relationship_type was missing, this is set n the legacy handler and emit_activity_stream_change emits it.
* Skip cascade deletes from non-assignment origins, same reasoning as sync_assignments_to_old_rbac_delete
The EE sidecar container creates /etc/receptor/receptor.conf on pod
startup. If the task container's dispatcherd reaches the heartbeat
before the config exists, the instance rejoins the cluster at full
capacity with no receptor — causing fatal job failures with no
recovery path.
Three layers of defense:
1. dispatcherd management command: poll for the receptor config file
before entering the main loop (10s timeout, exits with error to
let k8s restart the container).
2. Heartbeat: if get_receptor_ctl() raises FileNotFoundError (config
or socket missing), mark the instance offline so no jobs are
dispatched to it. Previously this was silently ignored and the
instance kept full capacity.
3. should_update_config(): catch FileNotFoundError and return True so
write_receptor_config() can create the file on first boot instead
of crashing.
Prior art: #12698 (closed without merge) took a broader approach with
custom exception types. See PR comment for overlap analysis.
Closes: AAP-89010
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Migrate Python linting and formatting toolchain from Black (formatter)
and Flake8 (linter) to Ruff, which provides both in a single tool.
- pyproject.toml: remove [tool.black], add [tool.ruff] config sections
- tox.ini: replace black+flake8 deps/commands with ruff in linters env
- awxkit/tox.ini: replace flake8 with ruff in lint env
- Makefile: replace `black` target with `format` + `lint`, keep `black`
as legacy alias, update `check` and `api-lint`
- pre-commit.sh: use `ruff format --check`, honor AWX_IGNORE_RUFF
- requirements_dev.txt: swap black/flake8 for ruff
- Dockerfile.j2: install ruff instead of black
- CONTRIBUTING.md: update tool references
- Fix noqa comments to use # separator instead of ; (ruff requirement)
- Add noqa: F841 for false positive in awxkit/cli/__init__.py
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use shared lock for project copy when no sync is needed
When a job template runs and no project sync is required (the common
case for slice jobs), multiple jobs were serializing behind an exclusive
lock even though they were only reading the shared project directory to
copy it.
Introduce read/write lock semantics using fcntl LOCK_SH/LOCK_EX:
- acquire_lock gains an exclusive= parameter (default True, preserving
existing behavior for all callers that don't pass it)
- sync_and_copy always acquires LOCK_SH first, then refreshes DB state
and calls get_sync_needs() under the lock — preserving the invariant
that the sync check always happens under a lock (no TOCTOU window)
- If sync is needed, LOCK_SH is released and LOCK_EX is acquired before
proceeding; sync_and_copy_without_lock re-checks internally
- project.refresh_from_db() is guarded by project.pk to handle unsaved
model instances used in unit tests
This allows slice jobs backed by the same project to copy the project
tree concurrently instead of sequentially. Measured improvement on a
200MB/54k-file project with 8 slice jobs: 140.5s total lock wait → 0s,
wall time 42.6s → 16.9s (2.5x).
Assisted-by: Claude Code via Google Vertex AI <cmeyers+claude@redhat.com>
* AAP-87084 — Revert UnifiedJob queryset to DAB APIs, scope actor role filter
The previous patch (7242fe89) inlined all DAB RBAC internals into
UnifiedJobAccess.filtered_queryset to pre-compute role IDs and avoid
repeated subqueries. While it fixed the UNION regression from c84575c,
it created 70 lines of duplicated logic inconsistent with the
UnifiedJobTemplate path and fetched ALL user roles regardless of
relevance.
This commit:
- Reverts filtered_queryset to the clean post-f8fa690 form that
delegates to DAB's access_ids_qs / accessible_pk_qs APIs
- Adds _scoped_actor_role_filter() as a temporary shim that pre-filters
RoleUserAssignment by content_type_id, auto-including Org and Team CTs
for inherited permissions and NULL for global roles
- Wires the scoped filter into UnifiedJobTemplate.accessible_pk_qs
- Adds tests for custom global roles, mixed permissions, and unrelated
role exclusion
The shim duplicates logic planned for DAB's _actor_role_filter
content_type_ids parameter; once that merges, replace and delete.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Remove unused ContentType and RoleUserAssignment imports to fix flake8
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* AAP-87084 — Remove _scoped_actor_role_filter shim, use DAB _actor_role_filter directly
The scoped content_type_ids parameter now lives in DAB's
_actor_role_filter. The AWX shim and its hardcoded Org/Team CT
inclusions are no longer needed. The unified job accessible_pk_qs
uses the unscoped _actor_role_filter for correctness.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Fixed migration from old to new rbac for sys auditor
* Added first migration
* Added testing for migrations and fix
* Removed attempts to remove platform auditor assignments
* Fixed migration test failures
When mintmaker bumps tekton resources it is failing on running the
pipeline-migration-tool. This runs as a post upgrade task and fails when
trying to run against tekton bundles. Disabling this to allow CI checks
to pass and merge updated pipeline bundles.
Signed-off-by: Ryan Williams <3375653+ryankwilliams@users.noreply.github.com>
Use SeparateDatabaseAndState with CREATE INDEX IF NOT EXISTS so the
migration succeeds when the index was already created manually (via
awx-manage create_host_summary_index or raw SQL workaround).
Forward-port of tower#7895 (stable-2.7).
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Adds `create_host_summary_index` management command that creates the
composite index (host_id, id DESC) on main_jobhostsummary using
CREATE INDEX CONCURRENTLY.
The command is idempotent: it checks for existing valid/invalid indexes,
drops invalid indexes from failed prior attempts, and skips if the index
already exists. Disables statement_timeout for the session to avoid
killing the index build on large tables where ALTER ROLE timeout is set.
Forward-port of tower#7893 (stable-2.6).
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* AAP-87084 — Replace UNION with OR + pre-computed role set in unified job RBAC query
The UNION ALL approach introduced by AAP-81173 forces PostgreSQL to
materialize all accessible job IDs across four RBAC branches before
any filtering or LIMIT can apply. This causes a 10x per-call regression
on the unified jobs list (36ms → 366ms) and a 3x regression on the
dashboard date-bucketed aggregation (88.9ms → 261.5ms).
Pre-compute the user's role IDs once as a Python list and pass them as
literal parameters to OR-based RoleEvaluation filters. This eliminates
the 4x redundant roleuserassignment subquery scans and restores
single-pass filtering with early LIMIT exit.
Resolves: AAP-87084, AAP-87087
* Assert RBAC query list is non-empty before checking for UNION
Ensures the test does not pass vacuously if no RBAC query is captured.
* Add test for singleton permission shortcut branches
Exercises the UJT, inventory, and org auditor singleton permission
paths in filtered_queryset() to increase coverage on new code.
* Add test for team-granted permissions in unified job list
Verifies that a user who can view a job template only through a team
assignment (not direct) sees the corresponding unified jobs.
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* [AAP-82668] Skip old RBAC sync on cascade-deleted assignments
When a non-RBAC parent (e.g. Organization) is deleted, Django cascades
to RoleUserAssignment/RoleTeamAssignment and fires post_delete for each.
The sync_assignments_to_old_rbac_delete handler would then do 3-4
FK/GFK queries per assignment to sync removals to the old Role model —
entirely redundant since the old Role M2M tables cascade-delete from the
same content object.
Use Django's post_delete `origin` kwarg (4.1+) to detect this: when
origin is a Model instance whose app_label is not dab_rbac, the delete
is a cascade from a non-RBAC parent and the sync is skipped.
Measured on 150 teams / 321 users / 48K assignments:
Baseline: 78.8s
With fix: 20.4s (via service-index endpoint)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: add regression tests for old RBAC sync after bulk claims
Tests that save_user_claims (which uses bulk_create, skipping signals)
correctly syncs old Role.members when run through AwxJWTAuthentication.
Covers add, remove, and multi-org/team scenarios.
* test: call process_permissions instead of internal _sync_old_rbac
Tests now exercise the public AwxJWTAuthentication.process_permissions()
API with the JWT layer mocked, rather than calling the private
_sync_old_rbac method directly.
The test_bulk_claims_skips_old_rbac_signals test asserts that
save_user_claims does NOT populate old Role.members via signals.
On current DAB, save_user_claims uses the serial give_permission
path which fires signals normally, so old Role.members IS populated.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* AAP-81173 — Replace 4-way OR with UNION in unified job list RBAC query
The UnifiedJobAccess.filtered_queryset() method used a 4-way OR to
determine which unified jobs a user can see. Under load, the OR forces
PostgreSQL to evaluate all four branches in a single plan, preventing
branch-specific index optimization and causing 35 hours of DB time per
30-minute Scale Lab window.
Split each RBAC branch (template read_role, inventory update, ad-hoc
command, org auditor) into separate querysets combined with UNION, giving
the planner an independent optimal plan per branch. The UNION result is
wrapped in pk__in= for compatibility with BaseAccess.get_queryset()
prefetch_related and the workflowapproval filter.
This follows the same pattern proven in AAP-83319 (team list UNION fix).
* AAP-81173 — Add UnifiedJobPagination to prevent COUNT regression
The pk__in UNION pattern used for RBAC filtering forces the large
outer table as the driving table for COUNT(*), requiring PostgreSQL
to materialize all subquery result sets. On large deployments this
produces catastrophic query times (see AAP-83773 for the identical
issue on activity_stream).
Override the paginator count to use an unfiltered
UnifiedJob.objects.count() — the over-count is acceptable for
pagination UI. Also fix test_unified_job_list_rando_sees_nothing
to assert on results length instead of count, since count is now
unfiltered.
* AAP-81173 — Add unit tests for UnifiedJobPagination coverage
Cover the UnifiedJobPaginator.count cached property and the
count_disabled branch in UnifiedJobPagination.paginate_queryset
to satisfy SonarCloud's 90% new-code coverage gate.
* AAP-81173 — Address review feedback: save/restore paginator class, format consistency
- Fix Pagination.paginate_queryset() to save/restore django_paginator_class
instead of hardcoding DjangoPaginator in the finally block. This lets
subclasses set the class attribute without needing to override the method.
- Remove UnifiedJobPagination.paginate_queryset() override — now only needs
to set django_paginator_class = UnifiedJobPaginator as a class attribute.
- Format by_org_auditor consistently with the other three UNION branches.
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
awxkit has its own tox.ini and when `tox -e linters` runs, awxkit/.tox/
can get populated with a py3 virtualenv. Since flake8 is configured to
scan the `awxkit` directory, it recurses into
awxkit/.tox/py3/lib/python3.12/site-packages/ and reports hundreds of
false positives (F405, E265, E266) from third-party packages like PyYAML.
Adding .tox to the flake8 exclude list prevents this — matching the
existing exclusion of `env` for virtualenvs.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
dict() with keyword arguments is idiomatic for Ansible module
argument_spec definitions. Suppress the "use literal instead of
constructor" rule (python:S7498) for awx_collection/plugins/modules/.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jake Jackson <jljacks93@gmail.com>
* fix: prefer scm_revision as the cache_id if available
* add tests
* fix: make sure galaxy requirements are always updated when a new revision is pulled
* make sure existing project cache id is used when available
---------
Co-authored-by: Liam Allen <lallen@redhat.com>
* AAP-83773 — Use unfiltered count for activity stream pagination
Forward-port of tower#7606 (stable-2.6).
The RBAC-filtered COUNT(*) on activity_stream takes ~36 min per call
on large tables (713K rows) due to the pk__in subquery shape introduced
by the AAP-81860 LEFT JOIN fix. Replace with an unfiltered table count
for pagination -- an approximate over-count is harmless for UI page
navigation while the actual page results remain RBAC-filtered.
* AAP-83773 — Add unit tests for ActivityStreamPagination
Cover ActivityStreamPaginator and ActivityStreamPagination to satisfy
SonarCloud coverage gate on new lines in awx/api/pagination.py.
* AAP-83773 — Save/restore django_paginator_class in base Pagination
Address review feedback from lallen92: use a save/restore pattern in
Pagination.paginate_queryset() so subclasses only need to set the class
attribute. Remove the now-redundant paginate_queryset override from
ActivityStreamPagination.
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Liam Allen <lallen@redhat.com>
* AAP-84057: Set PostgreSQL statement_timeout on web worker DB connections
When uwsgi's harakiri kills a worker, the PostgreSQL backend continues
running the query indefinitely. These abandoned queries accumulate and
create resource contention for all other queries.
Add a connection_created signal handler that sets statement_timeout on
new DB connections. Under uwsgi, the timeout is auto-derived from the
harakiri value (minus 5s margin so PostgreSQL cancels the query before
uwsgi kills the worker). Outside uwsgi (task workers, migrations),
no timeout is applied. A manual DATABASE_STATEMENT_TIMEOUT setting
is available as a fallback for non-uwsgi deployments.
* Remove timeout value caching because it brings no significant gains
* Use proportional margin between statement_timeout and harakiri timeout
* Fix zero-harakiri test to patch fake uwsgi module instead of None
* Refactor statement_timeout from signal handler to connection string
Move statement_timeout configuration from a connection_created signal
handler (extra SQL round-trip per connection) to a dynaconf merge
function that sets it via the libpq OPTIONS connection string parameter.
This mirrors the existing merge_application_name() pattern and
eliminates the SET statement on every new connection.
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Django's cascade collector materializes all JobHostSummary IDs into a
single UPDATE ... IN (...) to SET_NULL on Host.last_job_host_summary.
With many jobs x hosts this exceeds PostgreSQL's max memory allocation
size for a single query (1GB).
Pre-delete JHS rows and clear Host FK references in chunks of 1000 job
IDs using raw SQL before Django's .delete() runs, so the cascade
collector finds nothing to collect.
Assisted-by: Claude Code via Google Vertex AI