Skip to content

Commit 4bb5b4c

Browse files
stefansundinDaniel Magliola
authored andcommitted
Add :most_recent aggregation to DirectFileStore
This reports the value that was set by a process most recently. The way this works is by tagging each value in the files with the timestamp of when they were set. For all existing aggregations, we ignore that timestamp and do what we've been doing so far. For `:most_recent`, we take the "maximum" entry according to its timestamp (i.e. the latest) and then return its value Signed-off-by: Stefan Sundin <stefan@stefansundin.com> Signed-off-by: Daniel Magliola <danielmagliola@gocardless.com>
1 parent 7129453 commit 4bb5b4c

3 files changed

Lines changed: 72 additions & 25 deletions

File tree

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -313,9 +313,9 @@ When instantiating metrics, there is an optional `store_settings` attribute. Thi
313313
to set up store-specific settings for each metric. For most stores, this is not used, but
314314
for multi-process stores, this is used to specify how to aggregate the values of each
315315
metric across multiple processes. For the most part, this is used for Gauges, to specify
316-
whether you want to report the `SUM`, `MAX` or `MIN` value observed across all processes.
317-
For almost all other cases, you'd leave the default (`SUM`). More on this on the
318-
*Aggregation* section below.
316+
whether you want to report the `SUM`, `MAX`, `MIN`, or `MOST_RECENT` value observed across
317+
all processes. For almost all other cases, you'd leave the default (`SUM`). More on this
318+
on the *Aggregation* section below.
319319

320320
Custom stores may also accept extra parameters besides `:aggregation`. See the
321321
documentation of each store for more details.
@@ -348,8 +348,8 @@ use case, you may need to control how this works. When using this store,
348348
each Metric allows you to specify an `:aggregation` setting, defining how
349349
to aggregate the multiple possible values we can get for each labelset. By default,
350350
Counters, Histograms and Summaries are `SUM`med, and Gauges report all their values (one
351-
for each process), tagged with a `pid` label. You can also select `SUM`, `MAX` or `MIN`
352-
for your gauges, depending on your use case.
351+
for each process), tagged with a `pid` label. You can also select `SUM`, `MAX`, `MIN`, or
352+
`MOST_RECENT` for your gauges, depending on your use case.
353353

354354
**Memory Usage**: When scraped by Prometheus, this store will read all these files, get all
355355
the values and aggregate them. We have notice this can have a noticeable effect on memory

lib/prometheus/client/data_stores/direct_file_store.rb

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ module DataStores
2929

3030
class DirectFileStore
3131
class InvalidStoreSettingsError < StandardError; end
32-
AGGREGATION_MODES = [MAX = :max, MIN = :min, SUM = :sum, ALL = :all]
32+
AGGREGATION_MODES = [MAX = :max, MIN = :min, SUM = :sum, ALL = :all, MOST_RECENT = :most_recent]
3333
DEFAULT_METRIC_SETTINGS = { aggregation: SUM }
3434
DEFAULT_GAUGE_SETTINGS = { aggregation: ALL }
3535

@@ -121,15 +121,15 @@ def all_values
121121
stores_for_metric.each do |file_path|
122122
begin
123123
store = FileMappedDict.new(file_path, true)
124-
store.all_values.each do |(labelset_qs, v)|
124+
store.all_values.each do |(labelset_qs, v, ts)|
125125
# Labels come as a query string, and CGI::parse returns arrays for each key
126126
# "foo=bar&x=y" => { "foo" => ["bar"], "x" => ["y"] }
127127
# Turn the keys back into symbols, and remove the arrays
128128
label_set = CGI::parse(labelset_qs).map do |k, vs|
129129
[k.to_sym, vs.first]
130130
end.to_h
131131

132-
stores_data[label_set] << v
132+
stores_data[label_set] << [v, ts]
133133
end
134134
ensure
135135
store.close if store
@@ -181,30 +181,41 @@ def process_id
181181
end
182182

183183
def aggregate_values(values)
184-
if @values_aggregation_mode == SUM
185-
values.inject { |sum, element| sum + element }
186-
elsif @values_aggregation_mode == MAX
187-
values.max
188-
elsif @values_aggregation_mode == MIN
189-
values.min
190-
elsif @values_aggregation_mode == ALL
191-
values.first
184+
# Each entry in the `values` array is a tuple of `value` and `timestamp`,
185+
# so for all aggregations except `MOST_RECENT`, we need to only take the
186+
# first value in each entry and ignore the second.
187+
if @values_aggregation_mode == MOST_RECENT
188+
latest_tuple = values.max { |a,b| a[1] <=> b[1] }
189+
latest_tuple.first # return the value without the timestamp
192190
else
193-
raise InvalidStoreSettingsError,
194-
"Invalid Aggregation Mode: #{ @values_aggregation_mode }"
191+
values = values.map(&:first) # Discard timestamps
192+
193+
if @values_aggregation_mode == SUM
194+
values.inject { |sum, element| sum + element }
195+
elsif @values_aggregation_mode == MAX
196+
values.max
197+
elsif @values_aggregation_mode == MIN
198+
values.min
199+
elsif @values_aggregation_mode == ALL
200+
values.first
201+
else
202+
raise InvalidStoreSettingsError,
203+
"Invalid Aggregation Mode: #{ @values_aggregation_mode }"
204+
end
195205
end
196206
end
197207
end
198208

199209
private_constant :MetricStore
200210

201-
# A dict of doubles, backed by an file we access directly a a byte array.
211+
# A dict of doubles, backed by an file we access directly as a byte array.
202212
#
203213
# The file starts with a 4 byte int, indicating how much of it is used.
204214
# Then 4 bytes of padding.
205215
# There's then a number of entries, consisting of a 4 byte int which is the
206216
# size of the next field, a utf-8 encoded string key, padding to an 8 byte
207-
# alignment, and then a 8 byte float which is the value.
217+
# alignment, and then a 8 byte float which is the value, and then a 8 byte
218+
# float which is the unix timestamp when the value was set.
208219
class FileMappedDict
209220
INITIAL_FILE_SIZE = 1024*1024
210221

@@ -236,7 +247,8 @@ def all_values
236247
@positions.map do |key, pos|
237248
@f.seek(pos)
238249
value = @f.read(8).unpack('d')[0]
239-
[key, value]
250+
timestamp = @f.read(8).unpack('d')[0]
251+
[key, value, timestamp]
240252
end
241253
end
242254
end
@@ -258,7 +270,7 @@ def write_value(key, value)
258270

259271
pos = @positions[key]
260272
@f.seek(pos)
261-
@f.write([value].pack('d'))
273+
@f.write([value, Time.now.to_f].pack('dd'))
262274
@f.flush
263275
end
264276

@@ -299,7 +311,7 @@ def resize_file(new_capacity)
299311
def init_value(key)
300312
# Pad to be 8-byte aligned.
301313
padded = key + (' ' * (8 - (key.length + 4) % 8))
302-
value = [padded.length, padded, 0.0].pack("lA#{padded.length}d")
314+
value = [padded.length, padded, 0.0, 0.0].pack("lA#{padded.length}dd")
303315
while @used + value.length > @capacity
304316
@capacity *= 2
305317
resize_file(@capacity)
@@ -310,7 +322,7 @@ def init_value(key)
310322
@f.seek(0)
311323
@f.write([@used].pack('l'))
312324
@f.flush
313-
@positions[key] = @used - 8
325+
@positions[key] = @used - 16
314326
end
315327

316328
# Read position of all keys. No locking is performed.
@@ -320,7 +332,7 @@ def populate_positions
320332
padded_len = @f.read(4).unpack('l')[0]
321333
key = @f.read(padded_len).unpack("A#{padded_len}")[0].strip
322334
@positions[key] = @f.pos
323-
@f.seek(8, :CUR)
335+
@f.seek(16, :CUR)
324336
end
325337
end
326338
end

spec/prometheus/client/data_stores/direct_file_store_spec.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,41 @@
267267
end
268268
end
269269

270+
context "with a metric that takes MOST_RECENT instead of SUM" do
271+
it "reports the most recently written value from different processes" do
272+
metric_store1 = subject.for_metric(
273+
:metric_name,
274+
metric_type: :gauge,
275+
metric_settings: { aggregation: :most_recent }
276+
)
277+
metric_store2 = subject.for_metric(
278+
:metric_name,
279+
metric_type: :gauge,
280+
metric_settings: { aggregation: :most_recent }
281+
)
282+
283+
allow(Process).to receive(:pid).and_return(12345)
284+
metric_store1.set(labels: { foo: "bar" }, val: 1)
285+
286+
allow(Process).to receive(:pid).and_return(23456)
287+
metric_store2.set(labels: { foo: "bar" }, val: 3) # Supercedes 'bar' in PID 12345
288+
metric_store2.set(labels: { foo: "baz" }, val: 2)
289+
metric_store2.set(labels: { foo: "zzz" }, val: 1)
290+
291+
allow(Process).to receive(:pid).and_return(12345)
292+
metric_store1.set(labels: { foo: "baz" }, val: 4) # Supercedes 'baz' in PID 23456
293+
294+
expect(metric_store1.all_values).to eq(
295+
{ foo: "bar" } => 3.0,
296+
{ foo: "baz" } => 4.0,
297+
{ foo: "zzz" } => 1.0,
298+
)
299+
300+
# Both processes should return the same value
301+
expect(metric_store1.all_values).to eq(metric_store2.all_values)
302+
end
303+
end
304+
270305
it "resizes the File if metrics get too big" do
271306
truncate_calls_count = 0
272307
allow_any_instance_of(Prometheus::Client::DataStores::DirectFileStore::FileMappedDict).

0 commit comments

Comments
 (0)