Skip to content

feat(seer): Add helper for bulk updating Seer project settings#115756

Merged
srest2021 merged 5 commits into
masterfrom
srest2021/CW-1285-helpers
May 19, 2026
Merged

feat(seer): Add helper for bulk updating Seer project settings#115756
srest2021 merged 5 commits into
masterfrom
srest2021/CW-1285-helpers

Conversation

@srest2021
Copy link
Copy Markdown
Member

@srest2021 srest2021 commented May 18, 2026

Relates to CW-1285

Support both single-project and bulk-project Seer settings updates via update_seer_project_settings and bulk_update_seer_project_settings.

  • Refactor update_seer_project_settings so that the extracted option-mapping logic can be reused by both the single-project and upcoming bulk-project code paths.
  • Add bulk_update_seer_project_settings helper that bulk creates and deletes project options, instead of using per-project loops. We use _raw_delete to bypass per-row post_delete cache reloads, and manually reload each project's cache at the end (bulk_create also bypasses post_save cache reloads).

Why _raw_delete + manual reload_cache?

ProjectOptionManager auto-connects a post_delete signal handler that calls reload_cache on every ProjectOption row deletion. However, bulk_create does not trigger any post_save signal, so we have to reload each project's cache at the end of the transaction anyway. For a bulk update touching N projects × up to 7 option keys, that's up to 7N redundant reload_cache calls for row deletions.

We considered three approaches:

1. Per-project loop (sequential)

Use update_option/delete_option per project inside a single transaction. Simplest, but extremely slow due to serialized DB round-trips.

2. Per-project loop (thread pool, ~10 workers)

Parallelize the per-project loop with ThreadPoolExecutor. ~3x speedup over sequential, but still much slower than bulk due to per-project round-trips. Also adds complexity: connections.close_all() per thread, no shared transaction, choosing a worker count.

3. Bulk ops with _raw_delete (chosen)

_raw_delete is a private Django QuerySet method that does not hit post_delete. We pair it with bulk_create for upserts and do a single reload_cache per project at the end. This gives us exactly N cache refreshes instead of up to 7N.

We chose _raw_delete over post_delete.disconnect/connect because signal disconnect is global — another request thread deleting a ProjectOption during our window would miss its signal. _raw_delete is scoped to the queryset, so no thread-safety concern.

_raw_delete is private API but stable and has been in Django since 1.5 (2013)--only 2 commits 58b27e0 and ddefc3f have ever touched it and both are cosmetic changes.

Local benchmarks (max_workers=10 for thread pool)

n=   5  bulk=0.060s  loop=0.376s  pool=0.148s  pool_vs_loop=2.5x  bulk_vs_loop=6.2x
n=  25  bulk=0.093s  loop=2.191s  pool=0.668s  pool_vs_loop=3.3x  bulk_vs_loop=23.5x
n= 100  bulk=0.277s  loop=12.858s  pool=4.473s  pool_vs_loop=2.9x  bulk_vs_loop=46.5x
n= 500  bulk=3.866s  loop=70.040s  pool=24.923s  pool_vs_loop=2.8x  bulk_vs_loop=18.1x

Bulk is 6-46x faster than the sequential loop and 3-9x faster than the thread pool. At N=100 (99.9% of orgs), bulk completes in <0.3s vs 13s for the loop.

The bulk approach is also the easiest to migrate away from if we move off ProjectOption in the future — the bulk query logic stays the same, we'd just swap the model. A thread pool approach would require ripping out concurrency machinery.

…ings

Refactor update_seer_project_settings to extract _get_seer_project_options_to_update
so the option-mapping logic can be reused by both the single-project and upcoming
bulk-project code paths. Add bulk_update_seer_project_settings that uses bulk_create
with upsert and bulk delete instead of per-project loops.
@linear-code
Copy link
Copy Markdown

linear-code Bot commented May 18, 2026

CW-1285

@github-actions github-actions Bot added the Scope: Backend Automatically applied to PRs that change backend components label May 18, 2026
scannerAutomation: bool


def update_seer_project_settings(project: Project, data: SeerProjectSettingsUpdate) -> None:
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function's logic is unchanged, we just return the options to set and clear instead of doing it right there, so that we can do both single and bulk db ops.

).exists()


class TestBulkUpdateSeerProjectSettings(TestCase):
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept this pretty simple since the business logic is already tested in TestUpdateSeerProjectSettings.

# For all projects, manually reload cache and invalidate Relay config
# since bulk ProjectOption operations bypass update_option/delete_option.
for project_id in project_ids:
ProjectOption.objects.reload_cache(project_id, "projectoption.bulk_set_value")
Copy link
Copy Markdown
Member Author

@srest2021 srest2021 May 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that we always reload the cache for each project, instead of checking first if any options were actually changed like ProjectOptionManager.set_value does. I thought it might get too complicated otherwise

@srest2021 srest2021 marked this pull request as ready for review May 18, 2026 21:36
@srest2021 srest2021 requested a review from a team as a code owner May 18, 2026 21:36
Comment thread src/sentry/seer/autofix/utils.py Outdated
Comment thread src/sentry/seer/autofix/utils.py
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 184eeb2. Configure here.

Comment thread src/sentry/seer/autofix/utils.py Outdated
Comment thread src/sentry/seer/autofix/utils.py
@getsentry getsentry deleted a comment from github-actions Bot May 19, 2026
"sentry:seer_scanner_automation", data["scannerAutomation"], default=True
with transaction.atomic(using=router.db_for_write(ProjectOption)):
# Lock project rows to serialize concurrent writes.
list(Project.objects.select_for_update().filter(id__in=project_ids).order_by("id"))
Copy link
Copy Markdown
Member Author

@srest2021 srest2021 May 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Necessary to select for update, because we are modifying multiple related options that should be treated as a group (like handoff). Here is our current project count distribution--99.9% of our orgs have less than 500 projects, 99.34% less than 25. Given those stats, wondering if it's necessary to batch so that we don't lock up many projects at once / cause timeouts? I don't see any precedent for this in other code, but figured I'd bring it up

@srest2021 srest2021 changed the title ref(seer): Add helper for bulk updating Seer project settings feat(seer): Add helper for bulk updating Seer project settings May 19, 2026
Comment thread src/sentry/seer/autofix/utils.py
Comment thread src/sentry/seer/autofix/utils.py Outdated

# Manually reload each project's cache, since _raw_delete and bulk_create
# bypass the cache reloading in update_option and delete_option.
for project_id in project_ids:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if this ends up being prohibitively slow, we could always move this to an async task.

Comment thread src/sentry/seer/autofix/utils.py Outdated
Copy link
Copy Markdown
Member

@JoshFerge JoshFerge left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks great! just a couple of small comments.

@srest2021 srest2021 merged commit 721d1fe into master May 19, 2026
68 checks passed
@srest2021 srest2021 deleted the srest2021/CW-1285-helpers branch May 19, 2026 19:12
srest2021 added a commit that referenced this pull request May 19, 2026
Fixes CW-1285, AIML-2753

Depends on #115230, #115756

Adds a bulk/org-level endpoint for managing per-project Seer settings
across multiple projects:

- `GET /api/0/organizations/{org}/seer/projects/` — paginated list with
search/sort/filter
- `PUT /api/0/organizations/{org}/seer/projects/` — bulk update across
multiple projects

Use the helpers from #115037 and
#115756 to translate high-level
fields (agent, integrationId, stoppingPoint, scannerAutomation) into
project options.

### Supported filters (via `query` parameter)

| Filter | Operators | Example |
|---|---|---|
| `id` | `=`, `!=`, `IN`, `NOT IN` | `id:1`, `id:[1,2,3]` |
| `name` (free text) | `=`, `!=` | `my-project` |
| `reposCount` | `=`, `!=`, `>`, `<`, `>=`, `<=` | `reposCount:>0` |
| `stoppingPoint` | `=`, `!=`, `IN`, `NOT IN` | `stoppingPoint:off` |
| `agent` | `=`, `!=`, `IN`, `NOT IN` | `agent:seer`,
`!agent:cursor_background_agent` |

### Supported sort fields (via `sortBy` parameter)

`name`, `-name`, `reposCount`, `-reposCount`, `agent`, `-agent`,
`stoppingPoint`, `-stoppingPoint`

### Example: GET paginated list with default sort

```
GET /api/0/organizations/sentry/seer/projects/
```

```json
[
    {
        "projectId": "2",
        "projectSlug": "test-seer-settings",
        "agent": "seer",
        "integrationId": null,
        "stoppingPoint": "code_changes",
        "scannerAutomation": true,
        "reposCount": 1
    },
    {
        "projectId": "5",
        "projectSlug": "z-project",
        "agent": "cursor_background_agent",
        "integrationId": "42",
        "stoppingPoint": "open_pr",
        "scannerAutomation": false,
        "reposCount": 3
    }
]
```

### Example: GET with search and sort

```
GET /api/0/organizations/sentry/seer/projects/

{"query": "reposCount:>0 agent:seer", "sortBy": "-reposCount"}
```

Returns only Seer-agent projects with at least one repo, sorted by repo
count descending.

### Example: GET with free text search

```
GET /api/0/organizations/sentry/seer/projects/

{"query": "my-project"}
```

Matches against both project name and slug (case-insensitive).

### Example: PUT bulk update with filter

```
PUT /api/0/organizations/sentry/seer/projects/

{"query": "agent:cursor_background_agent reposCount:>0", "stoppingPoint": "open_pr"}
```

Returns `204 No Content`. Updates only projects matching the query.

### Example: PUT bulk update all projects

```
PUT /api/0/organizations/sentry/seer/projects/

{"scannerAutomation": false}
```

Returns `204 No Content`. With no query, updates all accessible
projects.

---------

Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Backend Automatically applied to PRs that change backend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants