Skip to content

RHINENG-25740: remove long time locking from culling job - #2159

Merged
MichaelMraka merged 7 commits into
RedHatInsights:masterfrom
MichaelMraka:pr2
Apr 17, 2026
Merged

RHINENG-25740: remove long time locking from culling job#2159
MichaelMraka merged 7 commits into
RedHatInsights:masterfrom
MichaelMraka:pr2

Conversation

@MichaelMraka

@MichaelMraka MichaelMraka commented Apr 17, 2026

Copy link
Copy Markdown
Collaborator

Secure Coding Practices Checklist GitHub Link

Secure Coding Checklist

  • Input Validation
  • Output Encoding
  • Authentication and Password Management
  • Session Management
  • Access Control
  • Cryptographic Practices
  • Error Handling and Logging
  • Data Protection
  • Communication Security
  • System Configuration
  • Database Security
  • File Management
  • Memory Management
  • General Coding Practices

Summary by Sourcery

Refactor system culling and staleness handling to avoid long-lived database locks and simplify deletion routines.

Bug Fixes:

  • Prevent long-running culling and stale-marking operations from holding locks across many rows by processing systems individually in separate transactions.

Enhancements:

  • Simplify the delete_system database function to return a single UUID and remove bulk deletion helper functions in favor of per-system operations.
  • Reimplement deleteCulledSystems and markSystemsStale in application code using GORM queries and per-row transactions instead of stored procedures.
  • Update the admin system delete handler to use the new delete_system signature and adjust tests to account for changed return types and behavior.

Tests:

  • Adapt system culling and staleness tests to the new per-row transactional behavior and int64 counters for affected rows.

Chores:

  • Add schema migration 150 to introduce the new delete_system function and drop obsolete deletion and staleness functions.

to aviod locking system_inventory for long time
there's already delete_system() for removal of single system
which we will use to avoid locking
to aviod locking system_inventory for long time
for deleteCulledSystems() and markSystemsStale()
@MichaelMraka
MichaelMraka requested a review from a team as a code owner April 17, 2026 09:23
@sourcery-ai

sourcery-ai Bot commented Apr 17, 2026

Copy link
Copy Markdown

Reviewer's Guide

Refactors system culling and stale-marking to avoid long-held locks by deleting and updating systems one-by-one in separate transactions, simplifies the delete_system DB function interface, and removes now-unneeded bulk PL/pgSQL helper functions while updating callers and tests accordingly.

File-Level Changes

Change Details Files
Simplify delete_system function to return a single UUID and remove bulk delete and stale-marking SQL helpers, with corresponding schema migration.
  • Change delete_system from a SETOF/RETURN TABLE to returning a single UUID and explicitly capture the deleted inventory_id into a variable before returning it
  • Return NULL from delete_system when no matching system/account is found instead of an empty result
  • Remove delete_systems, delete_culled_systems, and mark_stale_systems PL/pgSQL functions from the base schema
  • Add migration 150 to introduce the new delete_system signature and drop the old helper functions, plus a down migration to restore the previous functions and behavior
  • Bump schema_migrations version from 149 to 150 to pick up the new migration
database_admin/schema/create_schema.sql
database_admin/migrations/150_delete_functions.up.sql
database_admin/migrations/150_delete_functions.down.sql
Reimplement system culling logic in Go to delete culled systems one-by-one in independent transactions, avoiding long-lived locks.
  • Replace use of delete_culled_systems SQL function with a GORM query that selects culled systems (by inventory_id) up to the configured limit
  • For each selected inventory_id, run delete_system(inventory_id) inside its own CancelableDB transaction and accumulate the number of rows actually deleted
  • On per-system delete failure, log a warning including the inventory ID and continue processing remaining systems instead of failing the whole batch
  • Change deleteCulledSystems return type from int to int64 to match RowsAffected semantics
tasks/system_culling/system_culling.go
Reimplement stale-marking logic in Go to update each candidate system in its own short transaction instead of using a bulk PL/pgSQL function.
  • Replace mark_systems_stale SQL function usage with a GORM-based query to select candidate systems where stale != (stale_warning_timestamp < now()) up to a limit
  • Carry rh_account_id, id, and computed expiration status back into Go and perform per-row updates in separate CancelableDB transactions
  • Accumulate the number of updated rows via RowsAffected and log warnings per system on failure; continue processing other candidates on error
  • Change markSystemsStale return type from int to int64 and adjust ordering to keep deterministic processing
tasks/system_culling/system_culling.go
Update Go callers and tests to align with the new function signatures and types.
  • Adjust SystemDeleteHandler to call delete_system directly and drop the obsolete deleted_inventory_id column alias in the SELECT
  • Update system culling and stale-marking tests to expect int64 counts instead of int and to match the new function behaviors
turnpike/controllers/admin.go
tasks/system_culling/system_culling_test.go

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@github-actions

Copy link
Copy Markdown

SC Environment Impact Assessment

Overall Impact:NONE

No SC Environment-specific impacts detected in this PR.

What was checked

This PR was automatically scanned for:

  • Database migrations
  • ClowdApp configuration changes
  • Kessel integration changes
  • AWS service integrations (S3, RDS, ElastiCache)
  • Kafka topic changes
  • Secrets management changes
  • External dependencies

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 2 issues, and left some high level feedback:

  • The new deleteCulledSystems and SystemDeleteHandler code uses Exec("select delete_system(?::uuid)", ...); issuing a SELECT through Exec is driver‑dependent and may fail or always report RowsAffected = 0, so consider switching to Raw("select delete_system(?::uuid)", ...).Scan(&unused) (or changing the function to RETURNS void and using PERFORM in SQL) to make the behavior explicit and reliable.
  • Both deleteCulledSystems and markSystemsStale now select candidate rows in one transaction and then operate on each row in independent transactions without any locking on the initial query, which can lead to races or double processing under concurrency; consider adding row‑level locking (e.g., FOR UPDATE SKIP LOCKED via GORM) or another coordination mechanism to preserve the previous mutual‑exclusion semantics while still avoiding long‑held locks on large batches.
  • Inside deleteCulledSystems/markSystemsStale, each per‑row operation uses tasks.CancelableDB().Transaction(...) instead of the passed‑in tx, which may ignore the caller’s transaction/context; if that’s not intentional, it would be safer to derive the per‑row operations from the provided tx or its context.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new `deleteCulledSystems` and `SystemDeleteHandler` code uses `Exec("select delete_system(?::uuid)", ...)`; issuing a `SELECT` through `Exec` is driver‑dependent and may fail or always report `RowsAffected = 0`, so consider switching to `Raw("select delete_system(?::uuid)", ...).Scan(&unused)` (or changing the function to `RETURNS void` and using `PERFORM` in SQL) to make the behavior explicit and reliable.
- Both `deleteCulledSystems` and `markSystemsStale` now select candidate rows in one transaction and then operate on each row in independent transactions without any locking on the initial query, which can lead to races or double processing under concurrency; consider adding row‑level locking (e.g., `FOR UPDATE SKIP LOCKED` via GORM) or another coordination mechanism to preserve the previous mutual‑exclusion semantics while still avoiding long‑held locks on large batches.
- Inside `deleteCulledSystems`/`markSystemsStale`, each per‑row operation uses `tasks.CancelableDB().Transaction(...)` instead of the passed‑in `tx`, which may ignore the caller’s transaction/context; if that’s not intentional, it would be safer to derive the per‑row operations from the provided `tx` or its context.

## Individual Comments

### Comment 1
<location path="tasks/system_culling/system_culling.go" line_range="62-71" />
<code_context>
+	}
+
+	for _, id := range inventoryIDs {
+		var rowsAffected int64
+		delErr := tasks.CancelableDB().Transaction(func(tx2 *gorm.DB) error {
+			res := tx2.Exec("select delete_system(?::uuid)", id)
+			if res.Error != nil {
+				return res.Error
+			}
+			rowsAffected = res.RowsAffected
+			return nil
+		})
+		if delErr != nil {
+			utils.LogWarn("inventoryID", id, "err", delErr.Error(), "Delete culled system")
+			continue
+		}
+		nDeleted += rowsAffected
 	}

</code_context>
<issue_to_address>
**issue (bug_risk):** Using Exec + RowsAffected on a SELECTed function likely miscounts deletions

In PostgreSQL, `SELECT delete_system(?::uuid)` always returns one row per call, so `RowsAffected` will be `1` even when nothing was actually deleted. As a result, `nDeleted` will usually equal `len(inventoryIDs)` rather than the true number of deletions.

To keep an accurate count (and match the old `delete_culled_systems()` behavior), consider either:
- Having `delete_system` return a boolean/int and using `Row().Scan(&deleted)`; or
- Returning NULL vs non-NULL and checking the returned value in Go instead of relying on `RowsAffected`.
</issue_to_address>

### Comment 2
<location path="tasks/system_culling/system_culling.go" line_range="49" />
<code_context>
-		Find(&nDeletedArr).Error
-	if len(nDeletedArr) > 0 {
-		nDeleted = nDeletedArr[0]
+// systems are deleted in independent transactions to avoid locking multiple rows for long time
+func deleteCulledSystems(tx *gorm.DB, limitDeleted int) (nDeleted int64, err error) {
+	var inventoryIDs []string
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting a shared per-row transaction helper and a named candidate type to remove duplicated boilerplate and make the culling and staleness logic easier to follow.

You can keep the per-row transactional behavior but reduce the incidental complexity with a small helper and simpler data shapes.

### 1. Remove closure-mutated `rowsAffected` and per-function boilerplate

Extract a tiny helper that runs a per-row operation in its own transaction and returns the affected rows. That removes the “outer variable mutated in closure” and most boilerplate:

```go
func withPerRowTx(do func(tx *gorm.DB) (int64, error)) (int64, error) {
	var rowsAffected int64

	err := tasks.CancelableDB().Transaction(func(tx2 *gorm.DB) error {
		var opErr error
		rowsAffected, opErr = do(tx2)
		return opErr
	})
	if err != nil {
		return 0, err
	}
	return rowsAffected, nil
}
```

Then `deleteCulledSystems` becomes:

```go
func deleteCulledSystems(tx *gorm.DB, limitDeleted int) (int64, error) {
	var inventoryIDs []string
	if err := tx.Model(&models.SystemInventory{}).
		Where("culled_timestamp < ?", time.Now()).
		Order("id").
		Limit(limitDeleted).
		Pluck("inventory_id", &inventoryIDs).Error; err != nil {
		return 0, err
	}

	var nDeleted int64
	for _, id := range inventoryIDs {
		rows, err := withPerRowTx(func(tx2 *gorm.DB) (int64, error) {
			res := tx2.Exec("select delete_system(?::uuid)", id)
			return res.RowsAffected, res.Error
		})
		if err != nil {
			utils.LogWarn("inventoryID", id, "err", err.Error(), "Delete culled system")
			continue
		}
		nDeleted += rows
	}
	return nDeleted, nil
}
```

And `markSystemsStale` uses the same helper:

```go
for _, c := range candidates {
	rows, err := withPerRowTx(func(tx2 *gorm.DB) (int64, error) {
		res := tx2.Model(&models.SystemInventory{}).
			Where("rh_account_id = ? AND id = ?", c.RhAccountID, c.ID).
			Update("stale", c.Expired)
		return res.RowsAffected, res.Error
	})
	if err != nil {
		utils.LogWarn("rhAccountID", c.RhAccountID, "systemID", c.ID, "err", err.Error(), "Mark stale system")
		continue
	}
	nMarked += rows
}
```

This keeps semantics (per-row transactions, same logging) but removes duplicated plumbing and cross-closure state.

### 2. Use a named type and compute `Expired` in Go

You can simplify the query and get rid of the anonymous struct + computed SQL column by using a named candidate type and computing `Expired` in Go:

```go
type staleCandidate struct {
	RhAccountID           int       `gorm:"column:rh_account_id"`
	ID                    int64     `gorm:"column:id"`
	Stale                 bool      `gorm:"column:stale"`
	StaleWarningTimestamp time.Time `gorm:"column:stale_warning_timestamp"`
}

func markSystemsStale(tx *gorm.DB, markedLimit int) (int64, error) {
	now := time.Now()

	var candidates []staleCandidate
	if err := tx.Model(&models.SystemInventory{}).
		Select("rh_account_id, id, stale, stale_warning_timestamp").
		Where("stale != (stale_warning_timestamp < ?)", now).
		Order("rh_account_id").Order("id").
		Limit(markedLimit).
		Find(&candidates).Error; err != nil {
		return 0, err
	}

	var nMarked int64
	for _, c := range candidates {
		expired := c.StaleWarningTimestamp.Before(now)

		rows, err := withPerRowTx(func(tx2 *gorm.DB) (int64, error) {
			res := tx2.Model(&models.SystemInventory{}).
				Where("rh_account_id = ? AND id = ?", c.RhAccountID, c.ID).
				Update("stale", expired)
			return res.RowsAffected, res.Error
		})
		if err != nil {
			utils.LogWarn("rhAccountID", c.RhAccountID, "systemID", c.ID, "err", err.Error(), "Mark stale system")
			continue
		}
		nMarked += rows
	}

	return nMarked, nil
}
```

This:

- Keeps the per-row transaction and existing `WHERE stale != (...)` semantics.
- Moves the `expired` computation into Go, making the query projection simpler.
- Replaces the anonymous struct with a named, reusable type.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread tasks/system_culling/system_culling.go
Comment thread tasks/system_culling/system_culling.go
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 73.07692% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.30%. Comparing base (89a4dc5) to head (c81ac01).

Files with missing lines Patch % Lines
tasks/system_culling/system_culling.go 72.54% 8 Missing and 6 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2159      +/-   ##
==========================================
+ Coverage   59.29%   59.30%   +0.01%     
==========================================
  Files         135      135              
  Lines        8748     8785      +37     
==========================================
+ Hits         5187     5210      +23     
- Misses       3022     3030       +8     
- Partials      539      545       +6     
Flag Coverage Δ
unittests 59.30% <73.07%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@TenSt TenSt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

lgtm!

@TenSt TenSt self-assigned this Apr 17, 2026
@MichaelMraka
MichaelMraka merged commit 72a6b24 into RedHatInsights:master Apr 17, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants