Skip to content

Bug and stability fixes - #871

Merged
gabriel-samfira merged 18 commits into
cloudbase:mainfrom
gabriel-samfira:bug-and-stability-fixes
Sep 8, 2026
Merged

Bug and stability fixes#871
gabriel-samfira merged 18 commits into
cloudbase:mainfrom
gabriel-samfira:bug-and-stability-fixes

Conversation

@gabriel-samfira

@gabriel-samfira gabriel-samfira commented Sep 8, 2026

Copy link
Copy Markdown
Member

This PR addresses a bunch of issues in the scaleset workers, HTTP clients, database watcher and a few other places. Overall the changes include:

  • Serialize watcher events both during the emission and the consumption of the events. We should never parallelize messages we send or receive from a channel unless they are completely independent from one another.
  • The watcher consumers and producers must be non blocking.
  • all HTTP clients should have proper timeouts to prevent situations where one HTTP call hangs everything

These changes (some inspired from some of the PRs) should fix issues recently reported in:

Use a wait group to wait for each individual notification to be sent
in parralel to all consumers. This prevents out of order notifications
caused by slow consumers.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
If a websocket client disconnected or timed out or had its network
connection dropped, this would trigger a Stop() in the websocket worker.
When stopping the websocket worker servicing that client, there was the
potential to block for at least 10 seconds as we attempted to send a
writeMessage(), during which time we also held c.mux locked. While this
happens, the database watcher consumer stops draining messages, cascading
upwards, blocking the producer as it attempts to keep sending messages to
all consumers (including our now blocked consumer).

A misbehaving client should not be able to DoS the watcher.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
This change increases the per consummer channel buffer from 1 to 128,
allowing more time for slower consummers to catch up. Normally, consummers
should consume messages at memory speed and handle parallelization in their
own routines, but under extreme load, funky things can still happen.

Increasing the buffer gives us a bit more time under load, while still
ensuring ordering of messages.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
This commit changes the consolidate function to act on a snapshot of the
instance it manages. The global mux lock is swapped out for a consolidate
lock. We want to only run one consolidate on the instance at any given
point in time. This way, as we act on consolidating the state in the provider,
updates to the instance object in memory are not blocked. The next time
consolidate runs, we can act upon the new state.

This change also guards the write-back of the provider result to the
in-memory copy of the instance. While the provider call is in flight,
a newer update (like a user requested force delete) may have landed on
the in-memory instance via the watcher. Both the persisted result and
watcher events are full row re-reads from the database, so we only
overwrite the cached instance if it is not newer than the row we just
persisted. Overwriting a newer state would revert it, and no future
event would re-deliver it.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
Renamed updateArgsFromProviderInstance to persistProviderInstanceState.
The old name didn't properly suggest that the new state would persist
to DB.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
This change adds some timeouts for the HTTP client used to fetch the
release index and actual tools. The timeouts are set on connect, TLS handshakes,
responses etc.

This change also adds a new reconcile loop that consumes a buffered channel of
at most 1 update/event. The trigger function that writes to the channel will always
make sure to always only leave the last event to the channel, so even if the reconcile
loop takes a long time to process the previous event and we get a burst of updates, only
the last update will be left in the channel buffer.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
This change removes dead client sessions when we fail to write to it.

This change also uses CompareAndSwap for the `running` atomic.Bool. That way
multiple calls to stop won't block as the first Stop() to run procedes to
tare down the worker. We also close the DB watcher consumer as soon as possible
to make sure that as we close the worker, the producer doesn't stall due to
a potentially full consumer notification channel.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
The parameters to slog.Debug were evaluated at defer time. To get the
desired effect, we need to add the log line to a closure and defer that.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
We need to update the forge client in 2 situations:

* The credentials for a forge are spawwed out
* The credentials themselves are updated (new PAT/app)

When that happens, the wather generates an event that the pool filters
for via its consumer. Database watcher events must be consumed in a
non blocking manner. So when a new update event comes in that signals
the need for an update to the client, we must consume that event and
make sure that something (a loop in this case) wakes up and actually
updates the client.

This change makes sure that the client gets updated, regardless of how
long that takes, in a non blocking way. If an update "storm" comes in
(the user updates the credentials multiple times), only the last signal
is added to the queue. So the last update is always guaranteed to signal
that we need to update the client.

Aditionally, this change (and a few more incomming) sets a timeout on the
HTTP client we use.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
Add separate timeouts for the scaleset client HTTP client and
the message queue longpoll client. The scaleset client now uses
a 60s overall request timeout and the longpoll client uses a 100s timeout.

This change also uses a goroutine for the initial ratelimit population,
unblocking the construction of the client if a forge responds slowly or
is simply unavailable.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
This change is inspired by cloudbase#850.

We currently have 2 major problems here and potentially in other places
throughout the code:

* We do have the real risk of messages being dropped due to slow consumers
* Using a goroutine to send messages to consumers, runs the real risk of
messages being processed out of order.

The initial thought was that consumers be built in a way that would read
the message from the channel, and hand that work off imediately to some
worker without blocking. In practice, well...I failed at that.

While consumers should still be non blocking when reading watcher events,
we need to guard against blocking consumers closer to the source of the
events.

This change does several things to address that:

* We buffer the channels to more than just 1 message (128 in this commit)
* We build an ordered, unbound queue of ordered messages per consumer. This
is a tradeoff between not losing messages and potentially growing memory usage
forever due to a potentially dead consumer.

A dead consumer is less likely. A slow one is more likely. It is up to the
consumers if they coalesce messages as they come in, drop them (not all
may be actionable, as is the case where we trigger a tools sync), or if
all messages MUST be acted upon (as is the case for runner states, where
a transition from running to pending_delete requires the worker to actually
do something.

Consumers MUST of course do everything possible to either service each
request or error out due to timeouts or whatever other failure mode is
appropriate.

Some logging was added. So if a consumer becomes slow, operators can set
up alerts for that and potentially act upon it.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
When an agent notices that the runner has finished, it sends a status
update back to GARM and we transition the instance to pending_delete.
We used a forced transition for this, which can move an instance
backwards from deleting to pending_delete, behind the back of the
provider worker which may be in the middle of removing it.

Force transitions should be done only for recovery cases. We now use a
validated update. If the transition is rejected because the instance is
already being deleted, the outcome the agent asked for is already under
way, so we treat it as success.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
The consolidate loop had no case for instances in deleting, so a manager
whose cached state landed there would idle forever. Restarts are
recovered by the scaleset worker on startup, but the manager can strand
its own cached state at runtime: if a pass errors out after setting
deleting but before recording the outcome (the deleted write or the
pending_delete requeue fails transiently), no further event will move
the status.

Deleting is now treated as an interrupted delete and re-driven.
Providers report a missing instance as success, so this is safe.
Provider errors requeue the instance for every non forced path. If the
deleted write is refused because the row regressed onto the deletion
lane, we retry it with force, as the provider resource is confirmed gone
at that point.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
We used to spawn a goroutine for each event consumed from the watcher.
The mutex inside the handlers guarantees mutual exclusion but not
ordering, so two updates for the same instance could be applied in
reverse, leaving stale or ghost entries in the runners map. Events are
now forwarded to an unbounded queue and applied in order by a single
goroutine, the same pattern the provider worker uses. The watcher drain
is never blocked.

Scale down had two problems, observed live when a runner was removed 60
milliseconds after its provider create returned:

* Victim selection skipped only active and terminated runners, so a
runner that was still bootstrapping (status running, runner status
pending) was a valid target. Scale down removes idle capacity, nothing
else. Only idle runners are considered now. Stuck bootstraps are the
reaper's job.
* Runners already on their way out satisfy part of the delta, but they
were counted during the removal loop, which made the outcome depend on
map iteration order. They are now counted before any victim is picked.

runnerCount() now only excludes deleted instances. Anything else,
including instances in pending_delete or deleting, is still physically
present in the IaaS and counts as capacity.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
The listener context was created from context.Background() and the slog
values were copied over from the worker context. The only thing the
detached context bought us was preserving the log attributes. Deriving
from the worker context keeps the log values, means the longpoll dies
together with the worker, and the cancel func still allows restarting
the listener without restarting the worker.

The per attempt timeout around GetMessage() was also removed. The
longpoll HTTP client now enforces its own timeout, so the context only
needs to carry cancellation.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
The watcher dispatch goroutine now owns closing the messages channel.
Loops that did not check the ok value on receive would spin on zero
value payloads once the channel closed, forwarding empty events to
their queues. The loops now exit cleanly when the consumer channel is
closed.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
@gabriel-samfira

Copy link
Copy Markdown
Member Author

Ok. I have a bunch of typos in the commit messages. Too lazy to fix them.

The loop could only terminate because loop() is the single caller. That
invariant is implicit and easy to break. Two non blocking sends with an
evict in between give the same latest-wins behavior without depending on
who calls this.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
Account for the fact that between the time ListRunners() runs and
consolidateRunnerState gets a chance to iterate over the runners in the DB,
we might have new runners added. This change adds a timestamp at which we
listed runners in the GH API so we can ignore any newer runners added to
the DB.

We also attempt to remove the runner from GH anyway, in case our code
failed to guard against accidental removals. This will prevent removing
active runners, at the cost of extra API calls and possible runner churn
for idle runners.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
@gabriel-samfira
gabriel-samfira merged commit 55cbbc3 into cloudbase:main Sep 8, 2026
5 checks passed
@gabriel-samfira
gabriel-samfira deleted the bug-and-stability-fixes branch September 8, 2026 18:39
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