Files
Scott Gress d7fa35e417 Implement roaring bitmaps for historical data collection (#45709)
<!-- 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 -->
2026-05-19 09:34:29 -05:00

237 lines
7.3 KiB
Go

// Package chart provides blob utility helpers, dataset implementations, and
// shared constants for the chart bounded context. Public API types live in
// server/chart/api; internal types (HostFilter, Datastore) live in
// server/chart/internal/types.
//
// # Bitmap encoding
//
// Host-set bitmaps are stored in host_scd_data.host_bitmap. Two on-disk
// formats are supported, discriminated by the host_scd_data.encoding_type
// column:
//
// - EncodingDense (0): a raw bit-array sized to (max_id_in_set / 8) + 1.
// Bit n set iff host n is in the set. The original format; legacy rows
// written before this encoding was introduced read with encoding_type = 0
// via the column DEFAULT.
//
// - EncodingRoaring (1): the standard portable RoaringBitmap/roaring
// serialization (Bitmap.ToBytes() output). All new writes use this
// encoding; legacy dense rows are decoded into roaring at the I/O
// boundary via DecodeBitmap and either age out via retention or are
// overwritten on the next state transition.
//
// # Storage form vs op form
//
// Two distinct in-memory representations:
//
// - Blob{Bytes, Encoding} — storage form. Used only at the database I/O
// boundary. Constructed by HostIDsToBlob / BitmapToBlob. Consumed by
// INSERT / UPDATE statements.
//
// - *roaring.Bitmap — op form. Used for all bitwise operations
// (BlobAND/OR/ANDNOT/Popcount) and in-memory bitmap manipulation.
// Constructed by NewBitmap or DecodeBitmap. Encoding-awareness lives
// in DecodeBitmap and BitmapToBlob only.
//
// All BitmapToBlob calls invoke RunOptimize before serializing, so the
// same host set always produces byte-equal Blob.Bytes. This is not
// load-bearing for correctness (change detection uses roaring.Equals on
// op-form bitmaps) but is a desirable storage property.
package chart
import (
"math"
"strconv"
"github.com/RoaringBitmap/roaring"
)
// Encoding identifies the on-disk format of a host_bitmap blob. The constants
// here correspond directly to the host_scd_data.encoding_type column values.
const (
EncodingDense uint8 = 0
EncodingRoaring uint8 = 1
)
// Blob is the storage form of a host-set bitmap. Bytes is the serialized
// payload as written to host_scd_data.host_bitmap; Encoding is the matching
// host_scd_data.encoding_type column value. A nil Bytes represents the empty
// host set regardless of Encoding.
type Blob struct {
Bytes []byte
Encoding uint8
}
// NewBitmap builds a *roaring.Bitmap from a host ID list. Calls RunOptimize
// before returning so that subsequent serialization (via BitmapToBlob) is
// byte-deterministic for the input set. Host IDs of 0 are skipped — Fleet
// host IDs are AUTO_INCREMENT starting at 1.
func NewBitmap(ids []uint) *roaring.Bitmap {
rb := roaring.New()
for _, id := range ids {
if id == 0 || id > math.MaxUint32 {
continue
}
rb.Add(uint32(id))
}
rb.RunOptimize()
return rb
}
// BitmapToBlob serializes a *roaring.Bitmap into the storage form. Always
// returns Encoding = EncodingRoaring. Calls RunOptimize defensively (safe to
// invoke multiple times) so callers do not need to remember to do so.
// Bitmaps with cardinality 0 serialize to a nil byte slice.
func BitmapToBlob(rb *roaring.Bitmap) Blob {
if rb == nil || rb.IsEmpty() {
return Blob{Encoding: EncodingRoaring}
}
rb.RunOptimize()
return Blob{Bytes: serializeBitmap(rb), Encoding: EncodingRoaring}
}
// serializeBitmap wraps Bitmap.ToBytes; isolated so the encoder path has a
// single call site if we ever swap serialization formats.
func serializeBitmap(rb *roaring.Bitmap) []byte {
out, err := rb.ToBytes()
if err != nil {
// Bitmap.ToBytes only errors on internal buffer issues that aren't
// reachable for in-memory bitmaps; treat as a programmer error.
panic("chart: roaring.Bitmap.ToBytes failed: " + err.Error())
}
return out
}
// HostIDsToBlob is the convenience composition of NewBitmap + BitmapToBlob for
// callers going directly from a host-id list to storage form. Empty input
// returns Blob{Bytes: nil, Encoding: EncodingRoaring}.
func HostIDsToBlob(ids []uint) Blob {
return BitmapToBlob(NewBitmap(ids))
}
// hostIDsToDenseBlob is the pre-change dense encoder, retained for tests and
// for constructing legacy-row fixtures in the migration tests. Production
// writes go through HostIDsToBlob (which produces roaring) instead.
func hostIDsToDenseBlob(ids []uint) []byte {
if len(ids) == 0 {
return nil
}
var maxID uint
for _, id := range ids {
if id > maxID {
maxID = id
}
}
blob := make([]byte, maxID/8+1)
for _, id := range ids {
blob[id/8] |= 1 << (id % 8)
}
return blob
}
// DecodeBitmap converts storage form to op form. Dispatches on Blob.Encoding:
// roaring blobs are deserialized via the library; legacy dense blobs are
// walked byte-by-byte and each set bit added to a fresh roaring bitmap.
// A nil or empty Bytes slice returns an empty bitmap regardless of Encoding.
// An unknown encoding value returns an error.
func DecodeBitmap(b Blob) (*roaring.Bitmap, error) {
if len(b.Bytes) == 0 {
return roaring.New(), nil
}
switch b.Encoding {
case EncodingRoaring:
rb := roaring.New()
if _, err := rb.FromBuffer(b.Bytes); err != nil {
return nil, err
}
return rb, nil
case EncodingDense:
return decodeDense(b.Bytes), nil
default:
return nil, errUnknownEncoding(b.Encoding)
}
}
// decodeDense walks a dense bitmap byte-by-byte and inserts each set bit's
// position as a uint32 into a fresh roaring bitmap. O(byte count) work.
func decodeDense(blob []byte) *roaring.Bitmap {
rb := roaring.New()
for i, byteVal := range blob {
if byteVal == 0 {
continue
}
base := uint32(i) * 8
for bit := range uint32(8) {
if byteVal&(1<<bit) != 0 {
rb.Add(base + bit)
}
}
}
return rb
}
type errUnknownEncoding uint8
func (e errUnknownEncoding) Error() string {
return "chart: unknown bitmap encoding " + strconv.Itoa(int(e))
}
// BitmapToHostIDs returns the set bits of a *roaring.Bitmap as a sorted []uint.
// Thin convenience over roaring.Bitmap.ToArray (which returns []uint32) for
// callers that work in uint at the Fleet boundary.
func BitmapToHostIDs(rb *roaring.Bitmap) []uint {
if rb == nil {
return nil
}
arr := rb.ToArray()
out := make([]uint, len(arr))
for i, v := range arr {
out[i] = uint(v)
}
return out
}
// BlobPopcount returns the cardinality of the bitmap. A nil bitmap is treated
// as the empty set.
func BlobPopcount(rb *roaring.Bitmap) uint64 {
if rb == nil {
return 0
}
return rb.GetCardinality()
}
// BlobAND returns the intersection of a and b as a new bitmap. nil operands
// are treated as the empty set; the result is the empty set.
func BlobAND(a, b *roaring.Bitmap) *roaring.Bitmap {
if a == nil || b == nil {
return roaring.New()
}
return roaring.And(a, b)
}
// BlobOR returns the union of a and b as a new bitmap. nil operands are
// treated as the empty set.
func BlobOR(a, b *roaring.Bitmap) *roaring.Bitmap {
switch {
case a == nil && b == nil:
return roaring.New()
case a == nil:
return b.Clone()
case b == nil:
return a.Clone()
}
return roaring.Or(a, b)
}
// BlobANDNOT returns a \ mask: the bits set in a but not in mask, as a new
// bitmap. nil a returns the empty set; nil mask returns a clone of a.
func BlobANDNOT(a, mask *roaring.Bitmap) *roaring.Bitmap {
if a == nil {
return roaring.New()
}
if mask == nil {
return a.Clone()
}
return roaring.AndNot(a, mask)
}