perf cache chart series by rollup bucket

- reuse generated series until the current source rollup expires - bound and prune the per-worker chart cache - advertise a matching private browser cache lifetime - expose and test chart cache hit diagnostics

Commit
2bc145b0e9afbac219dda0b1a1f7027f7fb9656f
Author
GPT-5 medium <codex@openai.com>
Author date
Committer
GPT-5 medium <codex@openai.com>
Committer date
Changed files
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Reading.pm
index 63db532d..0751db40 100644..100644
@@ -10,6 +10,8 @@
10 10 my $MAX_SERIES_RANGE = max_series_range();
11 11 my $SERIES_GAP_SECONDS = 30;
12 12 my $MINUTE_SECONDS = 60;
13 Added: my $MAX_SERIES_CACHE_ENTRIES = 256;
14 Added: my %SERIES_CACHE;
13 15
14 16 sub list ($self) {
15 17 my $probe = $self->param('probe') // '';
@@ -95,6 +97,18 @@
95 97 my $unit = ( grep { $_->{key} eq $probe } $self->probes->@* )[0]{unit};
96 98 my $rollup_seconds
97 99 = $window->{bucket_stride} >= 3_600 ? 3_600 : $MINUTE_SECONDS;
100 Added: my $now = time;
101 Added: my $expires_epoch
102 Added: = int( $now / $rollup_seconds ) * $rollup_seconds + $rollup_seconds;
103 Added: my $cache_key = join q{:}, $probe, $timeframe, $range, $smooth,
104 Added: $window->{start_epoch}, $window->{end_epoch};
105 Added: my $cached = $SERIES_CACHE{$cache_key};
106 Added:
107 Added: if ( $cached && $cached->{expires_epoch} > $now ) {
108 Added: return render_series_payload( $self, $cached->{payload}, 'HIT',
109 Added: $cached->{expires_epoch} );
110 Added: }
111 Added:
98 112 my $rollups = $self->sqlite->db->query(
99 113 q{
100 114 WITH ordered AS (
@@ -139,20 +153,52 @@
139 153 my $points = aggregate_series( $rollups, $window, $probe, $unit, $smooth,
140 154 $rollup_seconds );
141 155
142 Removed: $self->render(
143 Removed: json => {
144 Removed: probe => $probe,
145 Removed: timeframe => $timeframe,
146 Removed: range => 0 + $range,
147 Removed: smooth => 0 + $smooth,
148 Removed: band => {
149 Removed: lower_statistic => 'minimum',
150 Removed: upper_statistic => 'maximum',
151 Removed: },
152 Removed: gap_seconds => $SERIES_GAP_SECONDS,
153 Removed: readings => $points,
156 Added: my $payload = {
157 Added: probe => $probe,
158 Added: timeframe => $timeframe,
159 Added: range => 0 + $range,
160 Added: smooth => 0 + $smooth,
161 Added: band => {
162 Added: lower_statistic => 'minimum',
163 Added: upper_statistic => 'maximum',
154 164 },
155 Removed: );
165 Added: gap_seconds => $SERIES_GAP_SECONDS,
166 Added: readings => $points,
167 Added: };
168 Added:
169 Added: prune_series_cache($now);
170 Added: $SERIES_CACHE{$cache_key} = {
171 Added: expires_epoch => $expires_epoch,
172 Added: payload => $payload,
173 Added: };
174 Added:
175 Added: return render_series_payload( $self, $payload, 'MISS', $expires_epoch );
176 Added: }
177 Added:
178 Added: sub prune_series_cache ($now) {
179 Added: for my $key ( keys %SERIES_CACHE ) {
180 Added: delete $SERIES_CACHE{$key}
181 Added: if $SERIES_CACHE{$key}{expires_epoch} <= $now;
182 Added: }
183 Added:
184 Added: if ( keys(%SERIES_CACHE) >= $MAX_SERIES_CACHE_ENTRIES ) {
185 Added: my ($oldest) = sort {
186 Added: $SERIES_CACHE{$a}{expires_epoch}
187 Added: <=> $SERIES_CACHE{$b}{expires_epoch}
188 Added: } keys %SERIES_CACHE;
189 Added: delete $SERIES_CACHE{$oldest};
190 Added: }
191 Added:
192 Added: return;
193 Added: }
194 Added:
195 Added: sub render_series_payload ( $self, $payload, $cache_status, $expires_epoch ) {
196 Added: my $max_age = int( $expires_epoch - time );
197 Added: $max_age = 0 if $max_age < 0;
198 Added:
199 Added: $self->res->headers->header( 'X-FAPG-Chart-Cache' => $cache_status );
200 Added: $self->res->headers->cache_control("private, max-age=$max_age");
201 Added: return $self->render( json => $payload );
156 202 }
157 203
158 204 sub status ($self) {
roles/dashboard/t/04-readings-series.t
index 97028560..0b3300da 100644..100644
@@ -28,8 +28,14 @@
28 28
29 29 $t->get_ok('/api/readings/ec/series?timeframe=day')
30 30 ->status_is(200)
31 Added: ->header_is( 'X-FAPG-Chart-Cache' => 'MISS' )
31 32 ->json_hasnt('/readings/48');
32 33 assert_series_reading( $t, 1050, 1000, 1100, 2 );
34 Added:
35 Added: $t->get_ok('/api/readings/ec/series?timeframe=day')
36 Added: ->status_is(200)
37 Added: ->header_is( 'X-FAPG-Chart-Cache' => 'HIT' )
38 Added: ->header_like( 'Cache-Control' => qr{\bmax-age=\d+\b} );
33 39
34 40 $t->get_ok('/api/readings/ec/series?timeframe=month')
35 41 ->status_is(200)