Files
fleet/server/service/apple_mdm_batched.go
T
Magnus JensenandClaude b42a154cf6 Optimize Apple profile reconciler approach by moving logic to code (#45573)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Closes #46153 

This PR is big, but I found it worth it to include in the same PR to
keep the mental change context in one place.

This PR moves away from our previous version of a big SQL computing the
desired state and label membership with big union branches. It does so
by switching the model up completely, first:
- We batch read hosts (current hardcoded is 5k), and we always iterate
5k hosts and then decide if they have changes, so that means a tick
(30s) could read 5k hosts that DOES NOT require changes, but that is
computed in code after, rather than relying on a big SQL to do it
(twice).
- We then for those hosts, bulk fetch label memberships, their related
team profiles and current rows. This performs much better as we can
lookup everything we need by primary key or super fast indexed columns,
simple fetch all these calls.
- Then once gathered the information we move to the code to determine if
the operation is install, remove, NO-OP (Desired state calculation),
then we check the label membership to further determine it's final
action.
- We then move to what we did before, which is queue the correct command
etc.

It comes with some slight caveats, which is we now load a lot more data
into memory (but before we could spike worse), so when loadtesting we
watched CPU/Memory utilization, which never seemed to spike as the
datasets are kept as small as possible.

_Cleanup will come in a follow-up PR where we remove all the old code._

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [X] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [X] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.

## Testing

- [X] Added/updated automated tests
- [ ] QA'd all new/changed functionality manually

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Performance**
* Optimized Apple profile and DDM (Declarations) reconciliation engine
with batched processing for significantly improved performance in
environments with large numbers of Apple-enrolled hosts.
* Implemented cursor-based pagination for more efficient reconciliation
across large fleets.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/fleetdm/fleet/pull/45573?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-29 09:46:17 +02:00

138 lines
4.7 KiB
Go

package service
import (
"context"
"encoding/pem"
"fmt"
"log/slog"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
)
// reconcileAppleProfilesBatchSize bounds how many distinct hosts the
// batched Apple MDM reconciliation cron processes per tick. The cron uses
// a host_uuid cursor (persisted in Redis via the mysqlredis wrapper) to
// page through the host universe in batches, smoothing the writer pressure
// that the legacy unbounded reconciliation generates during bulk events
// (team transfers, profile changes).
//
// var (not const) so tests can override it.
var reconcileAppleProfilesBatchSize = 5000
// ReconcileAppleProfilesBatched is the batched Apple MDM profile
// reconciler cron entry point. It pulls one bounded host window per
// tick (cursor in Redis), then delegates the compute + execute pipeline
// to the shared apple_mdm package so the same desired-state logic runs
// for the cron, the per-host enrollment path, and the DDM reconciler.
func ReconcileAppleProfilesBatched(
ctx context.Context,
ds fleet.Datastore,
commander *apple_mdm.MDMAppleCommander,
redisKeyValue fleet.AdvancedKeyValueStore,
logger *slog.Logger,
certProfilesLimit int,
) (err error) {
appConfig, err := ds.AppConfig(ctx)
if err != nil {
return fmt.Errorf("reading app config: %w", err)
}
if !appConfig.MDM.EnabledAndConfigured {
return nil
}
assets, err := ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{
fleet.MDMAssetCACert,
}, nil)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting Apple SCEP")
}
block, _ := pem.Decode(assets[fleet.MDMAssetCACert].Value)
if block == nil || block.Type != "CERTIFICATE" {
return ctxerr.New(ctx, "failed to decode PEM block from SCEP certificate")
}
if err := ensureFleetProfiles(ctx, ds, logger, block.Bytes); err != nil {
logger.ErrorContext(ctx, "unable to ensure fleetd configuration profiles are in place", "details", err)
}
cursor, err := ds.GetMDMAppleReconcileCursor(ctx)
if err != nil {
logger.WarnContext(ctx, "failed to read apple MDM reconcile cursor; starting from beginning", "err", err)
cursor = ""
}
hosts, allProfiles, hostLabels, currentByHost, err := ds.GetAppleProfileReconcileSnapshot(ctx, cursor, reconcileAppleProfilesBatchSize)
if err != nil {
return ctxerr.Wrap(ctx, err, "loading apple profile reconcile snapshot")
}
logger.DebugContext(ctx, "batched reconcile: loaded snapshot",
"cursor", cursor, "hosts_in_batch", len(hosts), "profile_count", len(allProfiles))
if len(hosts) == 0 {
if cursor != "" {
logger.DebugContext(ctx, "apple MDM reconcile pass complete; resetting cursor", "cursor", cursor)
if cerr := ds.SetMDMAppleReconcileCursor(ctx, ""); cerr != nil {
logger.WarnContext(ctx, "failed to reset apple MDM reconcile cursor", "err", cerr)
}
}
return nil
}
var nextCursor string
if len(hosts) >= reconcileAppleProfilesBatchSize {
nextCursor = hosts[len(hosts)-1].UUID
}
defer func() {
switch {
case err != nil:
logger.WarnContext(ctx, "batched reconcile: tick errored; cursor not advanced",
"cursor", cursor, "next_cursor", nextCursor, "err", err)
case cursor != nextCursor:
if cerr := ds.SetMDMAppleReconcileCursor(ctx, nextCursor); cerr != nil {
logger.WarnContext(ctx, "failed to advance apple MDM reconcile cursor", "err", cerr)
} else {
logger.DebugContext(ctx, "batched reconcile: cursor advanced",
"cursor", cursor, "next_cursor", nextCursor)
}
default:
logger.DebugContext(ctx, "batched reconcile: tick complete, cursor unchanged",
"cursor", cursor)
}
}()
if cursor != "" || nextCursor != "" {
logger.DebugContext(ctx, "apple MDM reconcile tick using cursor",
"cursor", cursor, "next_cursor", nextCursor,
"batch_size", reconcileAppleProfilesBatchSize,
"hosts_in_batch", len(hosts),
)
}
profilesWithBrokenLabel := make(map[string]struct{})
profilesByTeam := make(map[uint][]*fleet.AppleProfileForReconcile, 4)
for _, p := range allProfiles {
profilesByTeam[p.TeamID] = append(profilesByTeam[p.TeamID], p)
if p.HasBrokenLabel() {
profilesWithBrokenLabel[p.ProfileUUID] = struct{}{}
}
}
toInstall, toRemove := apple_mdm.ComputeReconcileDeltas(hosts, hostLabels, currentByHost, profilesByTeam, profilesWithBrokenLabel)
toInstall = fleet.FilterMacOSOnlyProfilesFromIOSIPadOS(toInstall)
logger.DebugContext(ctx, "batched reconcile: computed deltas",
"to_install", len(toInstall), "to_remove", len(toRemove))
if len(toInstall) == 0 && len(toRemove) == 0 {
return nil
}
_, err = apple_mdm.ExecuteReconcileBatch(
ctx, ds, commander, redisKeyValue, logger,
appConfig, certProfilesLimit, toInstall, toRemove,
)
return err
}