Skip to content

Pass ctx to GORM connection, handle creating instances and ensure we omit associations - #926

Merged
gabriel-samfira merged 3 commits into
cloudbase:mainfrom
gabriel-samfira:db-layer-ctx-and-deadlock-fixes
Oct 22, 2026
Merged

gabriel-samfira merged 3 commits into
cloudbase:mainfrom
gabriel-samfira:db-layer-ctx-and-deadlock-fixes

Conversation

@gabriel-samfira

@gabriel-samfira gabriel-samfira commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

This change fixes a number of issues in the database layer:

  • We now pass ctx to all database layer functions. Back when we only had sqlite, there was little chance that a query would hang for any reason. Now that we support PostgreSQL, we need to account for potentially hanging queries/connections. To mitigate this, we have 2 layers: a default set of timeouts and the ability to send in a proper context, with timeout tot the DB layer functions. Most of this change is mechanical.
  • The second fix has to do with a runner potentially stuck in creating. When a runner is added, it first gets recorded to the database as pending_create. The consolidateState() function then picks it up and marks it as creating before calling the provider. When the provider returns, we mark the instance as running. If the call to the database to mark the instance as running, fails, we cleanup the runner form the IaaS, but nothing ever reaps the runner stuck in creating. Thic change addresses that scenario.
  • Lastly there is an ABBA deadlock in at the database layer. GORM saves belongs-to and has-many associations by default. In updateInstance(), we lock the instance for update, and because we never omitted associations, we update the scaleset, then we update the actual instance. Then in SetScaleSetLastMessageID(), we lock the scaleset for update, we update the instance associated with the scale set and then we update the scaleset. So both touch the same tables and each locks the respective row it wants to edit, but in reverse order than the other function, leading to an ABBA deadlock. The last commit omits all associations from the offending functions.

No query in the sql package carried a context. Every call site used
s.conn directly and most methods accepted a context only to discard it.
A query whose server never answered would park the calling goroutine for
the life of the process, with no deadline and no way to cancel it.

Pass the caller's context to gorm on every query and add a context to
the ControllerStore methods that had none. Add statement_timeout,
lock_timeout and connect_timeout to the PostgreSQL DSN as a backstop for
callers that still pass a context with no deadline.

Schema migrations now run before any runtime connection exists, over
connections they open and close themselves, with those timeouts
disabled. An AutoMigrate on a large table can legitimately outrun them,
and being aborted half way through leaves the schema in an unknown
state.

The provider worker also bounds its own store calls. The instance
manager gets 30 seconds per call and the bulk loads get two minutes,
since those run with the worker mutex held and stall every scale set
rather than one instance.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
An instance in creating has no owner once the create call returns
without recording an outcome. consolidateState had no case for it, the
scale set reconcile passes skip it, the transition table allows only
error and running, and DeleteRunner refuses it. The only way out was a
restart.

Reaching that switch proves no create is in flight, since consolidation
is serialized and runs on the same goroutine that performs the create.
Fail the instance and let the existing error lane delete it.

The write is not forced on purpose. If the runner booted and registered
anyway the row is already running, which does not allow a move to error,
so the state machine refuses us instead of killing a live runner.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
gorm saves associations by default and the store preloads them, so a
write to one row also wrote several other tables. updateInstance locks
the instance row and then upserted the parent scale set, while
SetScaleSetLastMessageID locks the scale set row and then upserted every
runner row and the owning entity. Each one holds what the other wants,
which PostgreSQL reports as a deadlock (SQLSTATE 40P01).

Even where the cycle does not close, a runner status write had to wait
on any uncommitted update of its scale set row. That is how a runner
ends up stuck in pending_create or creating while a scale set update
holds that row across a forge call.

updateScaleSet already carried an Omit for this. The two listener
checkpoint writes had none, and updatePool omitted the owner and the
tags but not Instances. Use Omit(clause.Associations) everywhere rather
than a list of names, since the list is what let Instances slip through.

The create paths set the foreign key explicitly, because gorm is what
copies the parent key across and sqlToParamsInstance reads ProviderName
off the association.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
@gabriel-samfira
gabriel-samfira merged commit 1ad3680 into cloudbase:main Oct 22, 2026
5 checks passed
@gabriel-samfira
gabriel-samfira deleted the db-layer-ctx-and-deadlock-fixes branch October 22, 2026 19:59
cbartz added a commit to canonical/github-runner-operators that referenced this pull request Oct 25, 2026
Pin GARM to the merge of cloudbase/garm#926. It bounds every PostgreSQL
query with a statement, lock and connect timeout, reaps runners left in
creating when recording them as running fails, and omits associations
when writing instances and scale sets to remove an ABBA deadlock
between those updates.
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.

1 participant