<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #45715 # Details This PR refactors the way the charts module stores historical data to use the [roaring bitmap](https://github.com/RoaringBitmap/roaring) package instead of saving raw bitmaps. See [this blurb](https://github.com/RoaringBitmap/roaring#how-does-roaring-compares-with-the-alternatives) to learn how roaring compresses data, but TL;DR for our purposes it represents a huge improvement especially for larger deployments where host ID numbers may be very large. In testing, some data was reduced 96%. The majority of the changes in this PR are straight swapping of types from `[]byte` to `*roaring.Bitmap` in vars and function signatures, and updating the internals of our bit math helpers to use roaring methods instead of native AND and OR methods. I've tried to comment on all functional changes. Since the charts have been shipped already, so there will be data in the wild in the prior "dense" format, the code still handles dense bitmaps on _read_, but will always _write_ roaring bitmaps. The majority of the data will therefore have turned over within 30 days on its own, but I plan on a follow-up PR that will transform open rows when the cron runs so that we should be guaranteed to turn over completely within 30 days. # 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 - Tests updated to accommodate the new format, and existing unchanged tests act as proof against regression - [X] QA'd all new/changed functionality manually - Using a tool that dumps the `host_scd_data` rows data into a JSON file (with the keys being entity_id+data and the values being host IDs on that date), compared the data from main branch and this and confirmed they're identical - With a host count of ~9000, some of which have IDs of over 1,000,000, the data storage requirements were: * 82,558,976 bytes for dense * 2,867,200 for roaring (a 96% decrease) For unreleased bug fixes in a release candidate, one of: - [X] Confirmed that the fix is not expected to adversely impact load test results - should hugely improve - [X] Alerted the release DRI if additional load testing is needed ## Database migrations - [X] Checked schema for all modified table for columns that will auto-update timestamps during migration. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Implemented roaring bitmaps in historical data collection to optimize bitmap handling for chart data aggregation * Added encoding support to bitmap storage schema for flexible data representation <!-- end of auto-generated comment: release notes by coderabbit.ai -->
136 lines
4.1 KiB
Go
136 lines
4.1 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/RoaringBitmap/roaring"
|
|
"github.com/fleetdm/fleet/v4/server/chart/internal/types"
|
|
"golang.org/x/sync/singleflight"
|
|
)
|
|
|
|
// hostFilterCacheTTL is how long a host-ID bitmap is served from cache before
|
|
// being recomputed. Kept well under the chart collection cadence (10m) so data
|
|
// and mask staleness stay roughly aligned.
|
|
const hostFilterCacheTTL = 60 * time.Second
|
|
|
|
// hostBitmapFetcher is the signature used by cache callers to compute a fresh
|
|
// bitmap on a miss. Returning an error bypasses caching for that call.
|
|
type hostBitmapFetcher func(ctx context.Context) (*roaring.Bitmap, error)
|
|
|
|
// hostFilterCache maps a canonicalized HostFilter to the bitmap of host IDs
|
|
// that match it. Entries are considered valid for ttl; concurrent misses for
|
|
// the same key are collapsed via singleflight. Expired entries are swept from
|
|
// the map opportunistically on each write, so stale keys (e.g. one-off
|
|
// IncludeHostIDs filters) don't accumulate indefinitely.
|
|
type hostFilterCache struct {
|
|
ttl time.Duration
|
|
clock func() time.Time
|
|
sf singleflight.Group
|
|
mu sync.RWMutex
|
|
entries map[string]hostFilterCacheEntry
|
|
}
|
|
|
|
type hostFilterCacheEntry struct {
|
|
bitmap *roaring.Bitmap
|
|
expiresAt time.Time
|
|
}
|
|
|
|
func newHostFilterCache(ttl time.Duration) *hostFilterCache {
|
|
return &hostFilterCache{
|
|
ttl: ttl,
|
|
clock: time.Now,
|
|
entries: make(map[string]hostFilterCacheEntry),
|
|
}
|
|
}
|
|
|
|
// Get returns the cached bitmap for the filter or computes a fresh one via
|
|
// fetch on miss/expiry. Concurrent misses for the same filter share one fetch.
|
|
//
|
|
// The returned *roaring.Bitmap is shared across callers; treat it as read-only
|
|
// (use roaring.And/Or/AndNot rather than (*Bitmap).And/Or/AndNot). The library
|
|
// is safe for concurrent reads but not concurrent reads-with-writes.
|
|
func (c *hostFilterCache) Get(ctx context.Context, filter *types.HostFilter, fetch hostBitmapFetcher) (*roaring.Bitmap, error) {
|
|
key := hashHostFilter(filter)
|
|
|
|
c.mu.RLock()
|
|
entry, ok := c.entries[key]
|
|
c.mu.RUnlock()
|
|
if ok && c.clock().Before(entry.expiresAt) {
|
|
return entry.bitmap, nil
|
|
}
|
|
|
|
val, err, _ := c.sf.Do(key, func() (any, error) {
|
|
// Re-check after acquiring the singleflight slot: another goroutine
|
|
// may have populated the cache while we were waiting.
|
|
c.mu.RLock()
|
|
entry, ok := c.entries[key]
|
|
c.mu.RUnlock()
|
|
if ok && c.clock().Before(entry.expiresAt) {
|
|
return entry.bitmap, nil
|
|
}
|
|
|
|
bitmap, err := fetch(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
now := c.clock()
|
|
c.mu.Lock()
|
|
// Sweep expired entries so keys we never see again don't leak. Cheap
|
|
// because this only runs on misses (already the slow path).
|
|
for k, e := range c.entries {
|
|
if !now.Before(e.expiresAt) {
|
|
delete(c.entries, k)
|
|
}
|
|
}
|
|
c.entries[key] = hostFilterCacheEntry{
|
|
bitmap: bitmap,
|
|
expiresAt: now.Add(c.ttl),
|
|
}
|
|
c.mu.Unlock()
|
|
return bitmap, nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return val.(*roaring.Bitmap), nil
|
|
}
|
|
|
|
// hashHostFilter produces a deterministic string key for a HostFilter. Slice
|
|
// fields are sorted and copied so caller mutations can't affect keying; a
|
|
// shared separator that can't appear in the encoded values keeps distinct
|
|
// filters from collapsing to the same key.
|
|
//
|
|
// TeamIDs specifically distinguishes nil from empty-non-nil — the two have
|
|
// different semantics (no filter vs match nothing) and must never share a
|
|
// cache entry.
|
|
func hashHostFilter(f *types.HostFilter) string {
|
|
if f == nil {
|
|
return "nil"
|
|
}
|
|
teams := slices.Clone(f.TeamIDs)
|
|
slices.Sort(teams)
|
|
labels := slices.Clone(f.LabelIDs)
|
|
slices.Sort(labels)
|
|
platforms := slices.Clone(f.Platforms)
|
|
slices.Sort(platforms)
|
|
include := slices.Clone(f.IncludeHostIDs)
|
|
slices.Sort(include)
|
|
exclude := slices.Clone(f.ExcludeHostIDs)
|
|
slices.Sort(exclude)
|
|
|
|
var b strings.Builder
|
|
if f.TeamIDs == nil {
|
|
b.WriteString("teams=nil")
|
|
} else {
|
|
fmt.Fprintf(&b, "teams=%v", teams)
|
|
}
|
|
fmt.Fprintf(&b, "|labels=%v|platforms=%s|include=%v|exclude=%v",
|
|
labels, strings.Join(platforms, ","), include, exclude)
|
|
return b.String()
|
|
}
|