Skip to content

Commit e9697a8

Browse files
byPixelTVCopilot
andcommitted
feat: implement query point budget and downsampling for server data points
Co-authored-by: Copilot <copilot@github.com>
1 parent 2e8ca18 commit e9697a8

3 files changed

Lines changed: 200 additions & 3 deletions

File tree

data/query.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,3 +405,28 @@ func BuildInfluxQueryFromParams(params QueryParams) (string, int, string, error)
405405

406406
return query, dataPoints, step, nil
407407
}
408+
409+
// QueryPointBudget returns the query point budget for a given time range.
410+
// Longer ranges use a smaller budget so the graph stays readable instead of
411+
// over-representing the most recent, dense portion of the series.
412+
func QueryPointBudget(start string) (maxDataPoints, minDataPoints int) {
413+
rangeInMinutes, err := timeToMinutes(start)
414+
if err != nil {
415+
return 500, 10
416+
}
417+
418+
rangeInMinutes = math.Abs(rangeInMinutes)
419+
420+
switch {
421+
case rangeInMinutes >= 525600:
422+
return 120, 10
423+
case rangeInMinutes >= 43200:
424+
return 240, 10
425+
case rangeInMinutes >= 10080:
426+
return 180, 10
427+
case rangeInMinutes >= 1440:
428+
return 240, 10
429+
default:
430+
return 500, 10
431+
}
432+
}

data/servers.go

Lines changed: 114 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"context"
66
"encoding/json"
77
"fmt"
8+
"math"
89
"os"
910
"sort"
1011
)
@@ -60,12 +61,17 @@ func LoadServers(path string) ([]PingableServer, error) {
6061

6162
func QueryDataPoints(ip string, duration string) ([]ServerDataPoint, string, error) {
6263
queryApi := database.InfluxClient.QueryAPI(os.Getenv("INFLUXDB_ORG"))
64+
renderMaxDataPoints, minDataPoints := QueryPointBudget(duration)
65+
queryMaxDataPoints := renderMaxDataPoints
66+
if durationLongerThanADay(duration) {
67+
queryMaxDataPoints = renderMaxDataPoints * 4
68+
}
6369

6470
query, _, step, err := BuildInfluxQueryFromParams(QueryParams{
6571
Start: duration,
6672
ServerFilter: ip,
67-
MaxDataPoints: 500,
68-
MinDataPoints: 10,
73+
MaxDataPoints: queryMaxDataPoints,
74+
MinDataPoints: minDataPoints,
6975
UseAdaptive: false,
7076
})
7177

@@ -115,7 +121,19 @@ func QueryDataPoints(ip string, duration string) ([]ServerDataPoint, string, err
115121
return nil, "0m", err
116122
}
117123

118-
return mergeServerDataPoints(dataPoints, extremes), step, nil
124+
points := mergeServerDataPoints(dataPoints, extremes)
125+
points = downsampleServerDataPoints(points, renderMaxDataPoints)
126+
127+
return points, step, nil
128+
}
129+
130+
func durationLongerThanADay(duration string) bool {
131+
rangeInMinutes, err := timeToMinutes(duration)
132+
if err != nil {
133+
return false
134+
}
135+
136+
return math.Abs(rangeInMinutes) >= 1440
119137
}
120138

121139
func queryExtremeDataPoints(ip string, duration string) ([]ServerDataPoint, error) {
@@ -240,6 +258,99 @@ func mergeServerDataPoints(base []ServerDataPoint, extras []ServerDataPoint) []S
240258
return merged
241259
}
242260

261+
func downsampleServerDataPoints(points []ServerDataPoint, maxPoints int) []ServerDataPoint {
262+
if maxPoints <= 0 || len(points) <= maxPoints {
263+
return points
264+
}
265+
266+
sort.Slice(points, func(i, j int) bool {
267+
if points[i].Timestamp != points[j].Timestamp {
268+
return points[i].Timestamp < points[j].Timestamp
269+
}
270+
if points[i].PlayerCount != points[j].PlayerCount {
271+
return points[i].PlayerCount < points[j].PlayerCount
272+
}
273+
if points[i].Ip != points[j].Ip {
274+
return points[i].Ip < points[j].Ip
275+
}
276+
return points[i].Name < points[j].Name
277+
})
278+
279+
bucketCount := maxPoints / 4
280+
if bucketCount < 1 {
281+
bucketCount = 1
282+
}
283+
284+
target := make([]ServerDataPoint, 0, maxPoints)
285+
seen := make(map[string]struct{}, maxPoints)
286+
bucketSize := float64(len(points)) / float64(bucketCount)
287+
288+
appendPoint := func(point ServerDataPoint) {
289+
key := fmt.Sprintf("%d|%d|%s|%s", point.Timestamp, point.PlayerCount, point.Ip, point.Name)
290+
if _, ok := seen[key]; ok {
291+
return
292+
}
293+
seen[key] = struct{}{}
294+
target = append(target, point)
295+
}
296+
297+
for bucket := 0; bucket < bucketCount; bucket++ {
298+
start := int(math.Floor(float64(bucket) * bucketSize))
299+
end := int(math.Floor(float64(bucket+1) * bucketSize))
300+
if bucket == bucketCount-1 {
301+
end = len(points)
302+
}
303+
if start < 0 {
304+
start = 0
305+
}
306+
if end > len(points) {
307+
end = len(points)
308+
}
309+
if start >= end {
310+
continue
311+
}
312+
313+
bucketPoints := points[start:end]
314+
first := bucketPoints[0]
315+
last := bucketPoints[len(bucketPoints)-1]
316+
minPoint := first
317+
maxPoint := first
318+
319+
for _, point := range bucketPoints[1:] {
320+
if point.PlayerCount < minPoint.PlayerCount || (point.PlayerCount == minPoint.PlayerCount && point.Timestamp < minPoint.Timestamp) {
321+
minPoint = point
322+
}
323+
if point.PlayerCount > maxPoint.PlayerCount || (point.PlayerCount == maxPoint.PlayerCount && point.Timestamp < maxPoint.Timestamp) {
324+
maxPoint = point
325+
}
326+
}
327+
328+
bucketSelection := []ServerDataPoint{first, minPoint, maxPoint, last}
329+
for _, point := range bucketSelection {
330+
appendPoint(point)
331+
}
332+
}
333+
334+
sort.Slice(target, func(i, j int) bool {
335+
if target[i].Timestamp != target[j].Timestamp {
336+
return target[i].Timestamp < target[j].Timestamp
337+
}
338+
if target[i].PlayerCount != target[j].PlayerCount {
339+
return target[i].PlayerCount < target[j].PlayerCount
340+
}
341+
if target[i].Ip != target[j].Ip {
342+
return target[i].Ip < target[j].Ip
343+
}
344+
return target[i].Name < target[j].Name
345+
})
346+
347+
if len(target) > maxPoints {
348+
return target[:maxPoints]
349+
}
350+
351+
return target
352+
}
353+
243354
func recordValueToInt(value interface{}) (int, bool) {
244355
switch v := value.(type) {
245356
case int:

data/servers_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,64 @@ func TestMergeServerDataPointsKeepsExtremesAndOrder(t *testing.T) {
3636
}
3737
}
3838
}
39+
40+
func TestQueryPointBudgetScalesWithRange(t *testing.T) {
41+
tests := []struct {
42+
name string
43+
start string
44+
wantMax int
45+
wantMin int
46+
}{
47+
{name: "year", start: "-1y", wantMax: 120, wantMin: 10},
48+
{name: "month", start: "-1M", wantMax: 240, wantMin: 10},
49+
{name: "week", start: "-7d", wantMax: 180, wantMin: 10},
50+
{name: "short", start: "-6h", wantMax: 500, wantMin: 10},
51+
}
52+
53+
for _, tt := range tests {
54+
t.Run(tt.name, func(t *testing.T) {
55+
gotMax, gotMin := QueryPointBudget(tt.start)
56+
if gotMax != tt.wantMax || gotMin != tt.wantMin {
57+
t.Fatalf("QueryPointBudget(%q) = (%d, %d), want (%d, %d)", tt.start, gotMax, gotMin, tt.wantMax, tt.wantMin)
58+
}
59+
})
60+
}
61+
}
62+
63+
func TestDownsampleServerDataPointsPreservesExtremesAndBudget(t *testing.T) {
64+
points := []ServerDataPoint{
65+
{Timestamp: 1, PlayerCount: 10, Ip: "1.2.3.4", Name: "srv"},
66+
{Timestamp: 2, PlayerCount: 4, Ip: "1.2.3.4", Name: "srv"},
67+
{Timestamp: 3, PlayerCount: 9, Ip: "1.2.3.4", Name: "srv"},
68+
{Timestamp: 4, PlayerCount: 18, Ip: "1.2.3.4", Name: "srv"},
69+
{Timestamp: 5, PlayerCount: 7, Ip: "1.2.3.4", Name: "srv"},
70+
{Timestamp: 6, PlayerCount: 15, Ip: "1.2.3.4", Name: "srv"},
71+
{Timestamp: 7, PlayerCount: 2, Ip: "1.2.3.4", Name: "srv"},
72+
{Timestamp: 8, PlayerCount: 11, Ip: "1.2.3.4", Name: "srv"},
73+
}
74+
75+
downsampled := downsampleServerDataPoints(points, 4)
76+
if len(downsampled) > 4 {
77+
t.Fatalf("expected at most 4 points, got %d", len(downsampled))
78+
}
79+
80+
wants := map[int64]int{
81+
1: 10,
82+
4: 18,
83+
7: 2,
84+
8: 11,
85+
}
86+
87+
for _, point := range downsampled {
88+
if expected, ok := wants[point.Timestamp]; ok {
89+
if point.PlayerCount != expected {
90+
t.Fatalf("timestamp %d has player_count %d, want %d", point.Timestamp, point.PlayerCount, expected)
91+
}
92+
delete(wants, point.Timestamp)
93+
}
94+
}
95+
96+
if len(wants) != 0 {
97+
t.Fatalf("missing expected anchor points: %#v", wants)
98+
}
99+
}

0 commit comments

Comments
 (0)