vm-client 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,306 @@
1
+ # Custom Data Stores
2
+
3
+ Stores are basically an abstraction over a Hash, whose keys are in turn a Hash of labels
4
+ plus a metric name. The intention behind having different data stores is solving
5
+ different requirements for different production scenarios, or performance trade-offs.
6
+
7
+ The most common of these scenarios are pre-fork servers like Unicorn, which have multiple
8
+ separate processes gathering metrics. If each of these had their own store, the metrics
9
+ reported on each Prometheus scrape would be different, depending on which process handles
10
+ the request. Solving this requires some sort of shared storage between these processes,
11
+ and there are many ways to solve this problem, each with their own trade-offs.
12
+
13
+ This abstraction allows us to easily plug in the most adequate store for each scenario.
14
+
15
+ ## Interface
16
+
17
+ `Store` exposes a `for_metric` method, which returns a store-specific and metric-specific
18
+ `MetricStore` object, which represents a "view" onto the actual internal storage for one
19
+ particular metric. Each metric / collector object will have a references to this
20
+ `MetricStore` and interact with it directly.
21
+
22
+ The `MetricStore` class must expose `synchronize`, `set`, `increment`, `get` and `all_values`
23
+ methods, which are explained in the code sample below. Its initializer should be called
24
+ only by `Store#for_metric`, not directly.
25
+
26
+ All values stored are `Float`s.
27
+
28
+ Internally, a `Store` can store the data however it needs to, based on its requirements.
29
+ For example, a store that needs to work in a multi-process environment needs to have a
30
+ shared section of memory, via either Files, an MMap, an external database, or whatever the
31
+ implementor chooses for their particular use case.
32
+
33
+ Each `Store` / `MetricStore` will also choose how to divide responsibilities over the
34
+ storage of values. For some use cases, each `MetricStore` may have their own individual
35
+ storage, whereas for others, the `Store` may own a central storage, and `MetricStore`
36
+ objects will access it through the `Store`. This depends on the design choices of each `Store`.
37
+
38
+ `Store` and `MetricStore` MUST be thread safe. This applies not only to operations on
39
+ stored values (`set`, `increment`), but `MetricStore` must also expose a `synchronize`
40
+ method that would allow a Metric to increment multiple values atomically (Histograms need
41
+ this, for example).
42
+
43
+ Ideally, multiple keys should be modifiable simultaneously, but this is not a
44
+ hard requirement.
45
+
46
+ This is what the interface looks like, in practice:
47
+
48
+ ```ruby
49
+ module Prometheus
50
+ module Client
51
+ module DataStores
52
+ class CustomStore
53
+
54
+ # Return a MetricStore, which provides a view of the internal data store,
55
+ # catering specifically to that metric.
56
+ #
57
+ # - `metric_settings` specifies configuration parameters for this metric
58
+ # specifically. These may or may not be necessary, depending on each specific
59
+ # store and metric. The most obvious example of this is for gauges in
60
+ # multi-process environments, where the developer needs to choose how those
61
+ # gauges will get aggregated between all the per-process values.
62
+ #
63
+ # The settings that the store will accept, and what it will do with them, are
64
+ # 100% Store-specific. Each store should document what settings it will accept
65
+ # and how to use them, so the developer using that store can pass the appropriate
66
+ # instantiating the Store itself, and the Metrics they declare.
67
+ #
68
+ # - `metric_type` is specified in case a store wants to validate that the settings
69
+ # are valid for the metric being set up. It may go unused by most Stores
70
+ #
71
+ # Even if your store doesn't need these two parameters, the Store must expose them
72
+ # to make them swappable.
73
+ def for_metric(metric_name, metric_type:, metric_settings: {})
74
+ # Generally, here a Store would validate that the settings passed in are valid,
75
+ # and raise if they aren't.
76
+ validate_metric_settings(metric_type: metric_type,
77
+ metric_settings: metric_settings)
78
+ MetricStore.new(store: self,
79
+ metric_name: metric_name,
80
+ metric_type: metric_type,
81
+ metric_settings: metric_settings)
82
+ end
83
+
84
+
85
+ # MetricStore manages the data for one specific metric. It's generally a view onto
86
+ # the central store shared by all metrics, but it could also hold the data itself
87
+ # if that's better for the specific scenario
88
+ class MetricStore
89
+ # This constructor is internal to this store, so the signature doesn't need to
90
+ # be this. No one other than the Store should be creating MetricStores
91
+ def initialize(store:, metric_name:, metric_type:, metric_settings:)
92
+ end
93
+
94
+ # Metrics may need to modify multiple values at once (Histograms do this, for
95
+ # example). MetricStore needs to provide a way to synchronize those, in addition
96
+ # to all of the value modifications being thread-safe without a need for simple
97
+ # Metrics to call `synchronize`
98
+ def synchronize
99
+ raise NotImplementedError
100
+ end
101
+
102
+ # Store a value for this metric and a set of labels
103
+ # Internally, may add extra "labels" to disambiguate values between,
104
+ # for example, different processes
105
+ def set(labels:, val:)
106
+ raise NotImplementedError
107
+ end
108
+
109
+ def increment(labels:, by: 1)
110
+ raise NotImplementedError
111
+ end
112
+
113
+ # Return a value for a set of labels
114
+ # Will return the same value stored by `set`, as opposed to `all_values`, which
115
+ # may aggregate multiple values.
116
+ #
117
+ # For example, in a multi-process scenario, `set` may add an extra internal
118
+ # label tagging the value with the process id. `get` will return the value for
119
+ # "this" process ID. `all_values` will return an aggregated value for all
120
+ # process IDs.
121
+ def get(labels:)
122
+ raise NotImplementedError
123
+ end
124
+
125
+ # Returns all the sets of labels seen by the Store, and the aggregated value for
126
+ # each.
127
+ #
128
+ # In some cases, this is just a matter of returning the stored value.
129
+ #
130
+ # In other cases, the store may need to aggregate multiple values for the same
131
+ # set of labels. For example, in a multiple process it may need to `sum` the
132
+ # values of counters from each process. Or for `gauges`, it may need to take the
133
+ # `max`. This is generally specified in `metric_settings` when calling
134
+ # `Store#for_metric`.
135
+ def all_values
136
+ raise NotImplementedError
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
143
+ ```
144
+
145
+ ## Conventions
146
+
147
+ - Your store MAY require or accept extra settings for each metric on the call to `for_metric`.
148
+ - You SHOULD validate these parameters to make sure they are correct, and raise if they aren't.
149
+ - If your store needs to aggregate multiple values for the same metric (for example, in
150
+ a multi-process scenario), you MUST accept a setting to define how values are aggregated.
151
+ - This setting MUST be called `:aggregation`
152
+ - It MUST support, at least, `:sum`, `:max` and `:min`.
153
+ - It MAY support other aggregation modes that may apply to your requirements.
154
+ - It MUST default to `:sum`
155
+
156
+ ## Testing your Store
157
+
158
+ In order to make it easier to test your store, the basic functionality is tested using
159
+ `shared_examples`:
160
+
161
+ `it_behaves_like Prometheus::Client::DataStores`
162
+
163
+ Follow the simple structure in `synchronized_spec.rb` for a starting point.
164
+
165
+ Note that if your store stores data somewhere other than in-memory (in files, Redis,
166
+ databases, etc), you will need to do cleanup between tests in a `before` block.
167
+
168
+ The tests for `DirectFileStore` have a good example at the top of the file. This file also
169
+ has some examples on testing multi-process stores, checking that aggregation between
170
+ processes works correctly.
171
+
172
+ ## Benchmarking your custom data store
173
+
174
+ If you are developing your own data store, you probably want to benchmark it to see how
175
+ it compares to the built-in ones, and to make sure it achieves the performance you want.
176
+
177
+ The Prometheus Ruby Client includes some benchmarks (in the `spec/benchmarks` directory)
178
+ to help you with this, and also with validating that your store works correctly.
179
+
180
+ The `README` in that directory contains more information what these benchmarks are for,
181
+ and how to use them.
182
+
183
+ ## Extra Stores and Research
184
+
185
+ In the process of abstracting stores away, and creating the built-in ones, GoCardless
186
+ has created a good amount of research, benchmarks, and experimental stores, which
187
+ weren't useful to include in this repo, but may be a useful resource or starting point
188
+ if you are building your own store.
189
+
190
+ Check out the [GoCardless Data Stores Experiments](https://github.com/gocardless/prometheus-client-ruby-data-stores-experiments)
191
+ repository for these.
192
+
193
+ ## Sample, imaginary multi-process Data Store
194
+
195
+ This is just an example of how one could implement a data store, and a clarification on
196
+ the "aggregation" point
197
+
198
+ Important: This is a **toy example**, intended simply to show how this could work / how to
199
+ implement these interfaces.
200
+
201
+ There are some key pieces of code missing, which are fairly uninteresting, this only shows
202
+ the parts that illustrate the idea of storing multiple different values, and aggregating
203
+ them
204
+
205
+ ```ruby
206
+ module Prometheus
207
+ module Client
208
+ module DataStores
209
+ # Stores all the data in a magic data structure that keeps cross-process data, in a
210
+ # way that all processes can read it, but each can write only to their own set of
211
+ # keys.
212
+ # It doesn't care how that works, this is not an actual solution to anything,
213
+ # just an example of how the interface would work with something like that.
214
+ #
215
+ # Metric Settings have one possible key, `aggregation`, which must be one of
216
+ # `AGGREGATION_MODES`
217
+ class SampleMagicMultiprocessStore
218
+ AGGREGATION_MODES = [MAX = :max, MIN = :min, SUM = :sum]
219
+ DEFAULT_METRIC_SETTINGS = { aggregation: SUM }
220
+
221
+ def initialize
222
+ @internal_store = MagicHashSharedBetweenProcesses.new # PStore, for example
223
+ end
224
+
225
+ def for_metric(metric_name, metric_type:, metric_settings: {})
226
+ settings = DEFAULT_METRIC_SETTINGS.merge(metric_settings)
227
+ validate_metric_settings(metric_settings: settings)
228
+ MetricStore.new(store: self,
229
+ metric_name: metric_name,
230
+ metric_type: metric_type,
231
+ metric_settings: settings)
232
+ end
233
+
234
+ private
235
+
236
+ def validate_metric_settings(metric_settings:)
237
+ raise unless metric_settings.has_key?(:aggregation)
238
+ raise unless metric_settings[:aggregation].in?(AGGREGATION_MODES)
239
+ end
240
+
241
+ class MetricStore
242
+ def initialize(store:, metric_name:, metric_type:, metric_settings:)
243
+ @store = store
244
+ @internal_store = store.internal_store
245
+ @metric_name = metric_name
246
+ @aggregation_mode = metric_settings[:aggregation]
247
+ end
248
+
249
+ def set(labels:, val:)
250
+ @internal_store[store_key(labels)] = val.to_f
251
+ end
252
+
253
+ def get(labels:)
254
+ @internal_store[store_key(labels)]
255
+ end
256
+
257
+ def all_values
258
+ non_aggregated_values = all_store_values.each_with_object({}) do |(labels, v), acc|
259
+ if labels["__metric_name"] == @metric_name
260
+ label_set = labels.reject { |k,_| k.in?("__metric_name", "__pid") }
261
+ acc[label_set] ||= []
262
+ acc[label_set] << v
263
+ end
264
+ end
265
+
266
+ # Aggregate all the different values for each label_set
267
+ non_aggregated_values.each_with_object({}) do |(label_set, values), acc|
268
+ acc[label_set] = aggregate(values)
269
+ end
270
+ end
271
+
272
+ private
273
+
274
+ def all_store_values
275
+ # This assumes there's a something common that all processes can write to, and
276
+ # it's magically synchronized (which is not true of a PStore, for example, but
277
+ # would of some sort of external data store like Redis, Memcached, SQLite)
278
+
279
+ # This could also have some sort of:
280
+ # file_list = Dir.glob(File.join(path, '*.db')).sort
281
+ # which reads all the PStore files / MMapped files, etc, and returns a hash
282
+ # with all of them together, which then `values` and `label_sets` can use
283
+ end
284
+
285
+ # This method holds most of the key to how this Store works. Adding `_pid` as
286
+ # one of the labels, we hold each process's value separately, which we can
287
+ # aggregate later
288
+ def store_key(labels)
289
+ labels.merge(
290
+ {
291
+ "__metric_name" => @metric_name,
292
+ "__pid" => Process.pid
293
+ }
294
+ )
295
+ end
296
+
297
+ def aggregate(values)
298
+ # This is a horrible way to do this, just illustrating the point
299
+ values.send(@aggregation_mode)
300
+ end
301
+ end
302
+ end
303
+ end
304
+ end
305
+ end
306
+ ```
@@ -0,0 +1,368 @@
1
+ require 'fileutils'
2
+ require "cgi"
3
+
4
+ module Prometheus
5
+ module Client
6
+ module DataStores
7
+ # Stores data in binary files, one file per process and per metric.
8
+ # This is generally the recommended store to use to deal with pre-fork servers and
9
+ # other "multi-process" scenarios.
10
+ #
11
+ # Each process will get a file for a metric, and it will manage its contents by
12
+ # storing keys next to binary-encoded Floats, and keeping track of the offsets of
13
+ # those Floats, to be able to update them directly as they increase.
14
+ #
15
+ # When exporting metrics, the process that gets scraped by Prometheus will find
16
+ # all the files that apply to a metric, read their contents, and aggregate them
17
+ # (generally that means SUMming the values for each labelset).
18
+ #
19
+ # In order to do this, each Metric needs an `:aggregation` setting, specifying how
20
+ # to aggregate the multiple possible values we can get for each labelset. By default,
21
+ # Counters, Histograms and Summaries get `SUM`med, and Gauges will report `ALL`
22
+ # values, tagging each one with a `pid` label.
23
+ # For Gauges, it's also possible to set `SUM`, MAX` or `MIN` as aggregation, to get
24
+ # the highest / lowest value / or the sum of all the processes / threads.
25
+ #
26
+ # Before using this Store, please read the "`DirectFileStore` caveats and things to
27
+ # keep in mind" section of the main README in this repository. It includes a number
28
+ # of important things to keep in mind.
29
+
30
+ class DirectFileStore
31
+ class InvalidStoreSettingsError < StandardError; end
32
+ AGGREGATION_MODES = [MAX = :max, MIN = :min, SUM = :sum, ALL = :all, MOST_RECENT = :most_recent]
33
+ DEFAULT_METRIC_SETTINGS = { aggregation: SUM }
34
+ DEFAULT_GAUGE_SETTINGS = { aggregation: ALL }
35
+
36
+ def initialize(dir:)
37
+ @store_settings = { dir: dir }
38
+ FileUtils.mkdir_p(dir)
39
+ end
40
+
41
+ def for_metric(metric_name, metric_type:, metric_settings: {})
42
+ default_settings = DEFAULT_METRIC_SETTINGS
43
+ if metric_type == :gauge
44
+ default_settings = DEFAULT_GAUGE_SETTINGS
45
+ end
46
+
47
+ settings = default_settings.merge(metric_settings)
48
+ validate_metric_settings(metric_type, settings)
49
+
50
+ MetricStore.new(metric_name: metric_name,
51
+ store_settings: @store_settings,
52
+ metric_settings: settings)
53
+ end
54
+
55
+ private
56
+
57
+ def validate_metric_settings(metric_type, metric_settings)
58
+ unless metric_settings.has_key?(:aggregation) &&
59
+ AGGREGATION_MODES.include?(metric_settings[:aggregation])
60
+ raise InvalidStoreSettingsError,
61
+ "Metrics need a valid :aggregation key"
62
+ end
63
+
64
+ unless (metric_settings.keys - [:aggregation]).empty?
65
+ raise InvalidStoreSettingsError,
66
+ "Only :aggregation setting can be specified"
67
+ end
68
+
69
+ if metric_settings[:aggregation] == MOST_RECENT && metric_type != :gauge
70
+ raise InvalidStoreSettingsError,
71
+ "Only :gauge metrics support :most_recent aggregation"
72
+ end
73
+ end
74
+
75
+ class MetricStore
76
+ attr_reader :metric_name, :store_settings
77
+
78
+ def initialize(metric_name:, store_settings:, metric_settings:)
79
+ @metric_name = metric_name
80
+ @store_settings = store_settings
81
+ @values_aggregation_mode = metric_settings[:aggregation]
82
+ @store_opened_by_pid = nil
83
+
84
+ @lock = Monitor.new
85
+ end
86
+
87
+ # Synchronize is used to do a multi-process Mutex, when incrementing multiple
88
+ # values at once, so that the other process, reading the file for export, doesn't
89
+ # get incomplete increments.
90
+ #
91
+ # `in_process_sync`, instead, is just used so that two threads don't increment
92
+ # the same value and get a context switch between read and write leading to an
93
+ # inconsistency
94
+ def synchronize
95
+ in_process_sync do
96
+ internal_store.with_file_lock do
97
+ yield
98
+ end
99
+ end
100
+ end
101
+
102
+ def set(labels:, val:)
103
+ in_process_sync do
104
+ internal_store.write_value(store_key(labels), val.to_f)
105
+ end
106
+ end
107
+
108
+ def increment(labels:, by: 1)
109
+ if @values_aggregation_mode == DirectFileStore::MOST_RECENT
110
+ raise InvalidStoreSettingsError,
111
+ "The :most_recent aggregation does not support the use of increment"\
112
+ "/decrement"
113
+ end
114
+
115
+ key = store_key(labels)
116
+ in_process_sync do
117
+ internal_store.increment_value(key, by.to_f)
118
+ end
119
+ end
120
+
121
+ def get(labels:)
122
+ in_process_sync do
123
+ internal_store.read_value(store_key(labels))
124
+ end
125
+ end
126
+
127
+ def all_values
128
+ stores_data = Hash.new{ |hash, key| hash[key] = [] }
129
+
130
+ # There's no need to call `synchronize` here. We're opening a second handle to
131
+ # the file, and `flock`ing it, which prevents inconsistent reads
132
+ stores_for_metric.each do |file_path|
133
+ begin
134
+ store = FileMappedDict.new(file_path, true)
135
+ store.all_values.each do |(labelset_qs, v, ts)|
136
+ # Labels come as a query string, and CGI::parse returns arrays for each key
137
+ # "foo=bar&x=y" => { "foo" => ["bar"], "x" => ["y"] }
138
+ # Turn the keys back into symbols, and remove the arrays
139
+ label_set = CGI::parse(labelset_qs).map do |k, vs|
140
+ [k.to_sym, vs.first]
141
+ end.to_h
142
+
143
+ stores_data[label_set] << [v, ts]
144
+ end
145
+ ensure
146
+ store.close if store
147
+ end
148
+ end
149
+
150
+ # Aggregate all the different values for each label_set
151
+ aggregate_hash = Hash.new { |hash, key| hash[key] = 0.0 }
152
+ stores_data.each_with_object(aggregate_hash) do |(label_set, values), acc|
153
+ acc[label_set] = aggregate_values(values)
154
+ end
155
+ end
156
+
157
+ private
158
+
159
+ def in_process_sync
160
+ @lock.synchronize { yield }
161
+ end
162
+
163
+ def store_key(labels)
164
+ if @values_aggregation_mode == ALL
165
+ labels[:pid] = process_id
166
+ end
167
+
168
+ labels.to_a.sort.map{|k,v| "#{CGI::escape(k.to_s)}=#{CGI::escape(v.to_s)}"}.join('&')
169
+ end
170
+
171
+ def internal_store
172
+ if @store_opened_by_pid != process_id
173
+ @store_opened_by_pid = process_id
174
+ @internal_store = FileMappedDict.new(filemap_filename)
175
+ else
176
+ @internal_store
177
+ end
178
+ end
179
+
180
+ # Filename for this metric's PStore (one per process)
181
+ def filemap_filename
182
+ filename = "metric_#{ metric_name }___#{ process_id }.bin"
183
+ File.join(@store_settings[:dir], filename)
184
+ end
185
+
186
+ def stores_for_metric
187
+ Dir.glob(File.join(@store_settings[:dir], "metric_#{ metric_name }___*"))
188
+ end
189
+
190
+ def process_id
191
+ Process.pid
192
+ end
193
+
194
+ def aggregate_values(values)
195
+ # Each entry in the `values` array is a tuple of `value` and `timestamp`,
196
+ # so for all aggregations except `MOST_RECENT`, we need to only take the
197
+ # first value in each entry and ignore the second.
198
+ if @values_aggregation_mode == MOST_RECENT
199
+ latest_tuple = values.max { |a,b| a[1] <=> b[1] }
200
+ latest_tuple.first # return the value without the timestamp
201
+ else
202
+ values = values.map(&:first) # Discard timestamps
203
+
204
+ if @values_aggregation_mode == SUM
205
+ values.inject { |sum, element| sum + element }
206
+ elsif @values_aggregation_mode == MAX
207
+ values.max
208
+ elsif @values_aggregation_mode == MIN
209
+ values.min
210
+ elsif @values_aggregation_mode == ALL
211
+ values.first
212
+ else
213
+ raise InvalidStoreSettingsError,
214
+ "Invalid Aggregation Mode: #{ @values_aggregation_mode }"
215
+ end
216
+ end
217
+ end
218
+ end
219
+
220
+ private_constant :MetricStore
221
+
222
+ # A dict of doubles, backed by an file we access directly as a byte array.
223
+ #
224
+ # The file starts with a 4 byte int, indicating how much of it is used.
225
+ # Then 4 bytes of padding.
226
+ # There's then a number of entries, consisting of a 4 byte int which is the
227
+ # size of the next field, a utf-8 encoded string key, padding to an 8 byte
228
+ # alignment, and then a 8 byte float which is the value, and then a 8 byte
229
+ # float which is the unix timestamp when the value was set.
230
+ class FileMappedDict
231
+ INITIAL_FILE_SIZE = 1024*1024
232
+
233
+ attr_reader :capacity, :used, :positions
234
+
235
+ def initialize(filename, readonly = false)
236
+ @positions = {}
237
+ @used = 0
238
+
239
+ open_file(filename, readonly)
240
+ @used = @f.read(4).unpack('l')[0] if @capacity > 0
241
+
242
+ if @used > 0
243
+ # File already has data. Read the existing values
244
+ with_file_lock { populate_positions }
245
+ else
246
+ # File is empty. Init the `used` counter, if we're in write mode
247
+ if !readonly
248
+ @used = 8
249
+ @f.seek(0)
250
+ @f.write([@used].pack('l'))
251
+ end
252
+ end
253
+ end
254
+
255
+ # Return a list of key-value pairs
256
+ def all_values
257
+ with_file_lock do
258
+ @positions.map do |key, pos|
259
+ @f.seek(pos)
260
+ value, timestamp = @f.read(16).unpack('dd')
261
+ [key, value, timestamp]
262
+ end
263
+ end
264
+ end
265
+
266
+ def read_value(key)
267
+ if !@positions.has_key?(key)
268
+ init_value(key)
269
+ end
270
+
271
+ pos = @positions[key]
272
+ @f.seek(pos)
273
+ @f.read(8).unpack('d')[0]
274
+ end
275
+
276
+ def write_value(key, value)
277
+ if !@positions.has_key?(key)
278
+ init_value(key)
279
+ end
280
+
281
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
282
+ pos = @positions[key]
283
+ @f.seek(pos)
284
+ @f.write([value, now].pack('dd'))
285
+ @f.flush
286
+ end
287
+
288
+ def increment_value(key, by)
289
+ if !@positions.has_key?(key)
290
+ init_value(key)
291
+ end
292
+
293
+ pos = @positions[key]
294
+ @f.seek(pos)
295
+ value = @f.read(8).unpack('d')[0]
296
+
297
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
298
+ @f.seek(-8, :CUR)
299
+ @f.write([value + by, now].pack('dd'))
300
+ @f.flush
301
+ end
302
+
303
+ def close
304
+ @f.close
305
+ end
306
+
307
+ def with_file_lock
308
+ @f.flock(File::LOCK_EX)
309
+ yield
310
+ ensure
311
+ @f.flock(File::LOCK_UN)
312
+ end
313
+
314
+ private
315
+
316
+ def open_file(filename, readonly)
317
+ mode = if readonly
318
+ "r"
319
+ elsif File.exist?(filename)
320
+ "r+b"
321
+ else
322
+ "w+b"
323
+ end
324
+
325
+ @f = File.open(filename, mode)
326
+ if @f.size == 0 && !readonly
327
+ resize_file(INITIAL_FILE_SIZE)
328
+ end
329
+ @capacity = @f.size
330
+ end
331
+
332
+ def resize_file(new_capacity)
333
+ @f.truncate(new_capacity)
334
+ end
335
+
336
+ # Initialize a value. Lock must be held by caller.
337
+ def init_value(key)
338
+ # Pad to be 8-byte aligned.
339
+ padded = key + (' ' * (8 - (key.length + 4) % 8))
340
+ value = [padded.length, padded, 0.0, 0.0].pack("lA#{padded.length}dd")
341
+ while @used + value.length > @capacity
342
+ @capacity *= 2
343
+ resize_file(@capacity)
344
+ end
345
+ @f.seek(@used)
346
+ @f.write(value)
347
+ @used += value.length
348
+ @f.seek(0)
349
+ @f.write([@used].pack('l'))
350
+ @f.flush
351
+ @positions[key] = @used - 16
352
+ end
353
+
354
+ # Read position of all keys. No locking is performed.
355
+ def populate_positions
356
+ @f.seek(8)
357
+ while @f.pos < @used
358
+ padded_len = @f.read(4).unpack('l')[0]
359
+ key = @f.read(padded_len).unpack("A#{padded_len}")[0].strip
360
+ @positions[key] = @f.pos
361
+ @f.seek(16, :CUR)
362
+ end
363
+ end
364
+ end
365
+ end
366
+ end
367
+ end
368
+ end