whispr 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/README.md +14 -0
- data/bin/whispr-create +34 -0
- data/bin/whispr-dump +34 -0
- data/bin/whispr-fetch +45 -0
- data/bin/whispr-info +34 -0
- data/bin/whispr-update +23 -0
- data/lib/whispr.rb +456 -0
- data/lib/whispr/archive.rb +220 -0
- data/lib/whispr/version.rb +3 -0
- metadata +59 -0
data/README.md
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
# Whispr
|
2
|
+
|
3
|
+
Whispr is a port of the [Graphite](http://graphite.wikidot.com/) [Whisper](https://github.com/graphite-project/) library in Ruby.
|
4
|
+
|
5
|
+
It supports the basic create, update and read (fetch,dump) operations.
|
6
|
+
|
7
|
+
|
8
|
+
## TODO
|
9
|
+
|
10
|
+
- unit tests
|
11
|
+
- whispr-resize
|
12
|
+
- whispr-merge
|
13
|
+
- whispr-set-aggregation-method
|
14
|
+
- rrd2whispr
|
data/bin/whispr-create
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "whispr"
|
4
|
+
require "optparse"
|
5
|
+
|
6
|
+
options = {:xff => 0.5, :overwrite => false, :aggregationMethod => :average}
|
7
|
+
opt_parser = OptionParser.new do |opts|
|
8
|
+
opts.on("--xFilesFactor Float", Float, "default 0.5") { |x| options[:xff] = x }
|
9
|
+
opts.on("--aggregationMethod String", String, "function to use when aggregating values #{Whispr::AGGR_TYPES[1..-1].map(&:to_s).inspect}") do |aggr|
|
10
|
+
aggr = aggr.intern
|
11
|
+
unless Whispr::AGGR_TYPES[1..-1].include?(aggr)
|
12
|
+
$stderr.puts "aggregationMethod must be one of: #{Whispr::AGGR_TYPES[1..-1]}"
|
13
|
+
exit 1
|
14
|
+
end
|
15
|
+
options[:aggregationMethod] = aggr
|
16
|
+
end
|
17
|
+
opts.on("--overwrite") {|o| options[:overwrite] = o }
|
18
|
+
opts.banner += " path timePerPoint:timeToStore [timePerPoint:timeToStore]*"
|
19
|
+
end
|
20
|
+
opt_parser.parse!
|
21
|
+
|
22
|
+
if ARGV.length < 2
|
23
|
+
$stderr.puts opt_parser
|
24
|
+
exit 1
|
25
|
+
end
|
26
|
+
|
27
|
+
path = ARGV.shift
|
28
|
+
unless File.exists?(File.dirname(path))
|
29
|
+
$stderr.puts "#{File.dirname(path)} does not exists"
|
30
|
+
exit 1
|
31
|
+
end
|
32
|
+
File.unlink if options[:overwrite] && File.exists?(path)
|
33
|
+
|
34
|
+
Whispr.create(path, ARGV.map{|a| Whispr.parse_retention_def(a) }, options)
|
data/bin/whispr-dump
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "whispr"
|
4
|
+
require "stringio"
|
5
|
+
|
6
|
+
if ARGV[0].nil? || ARGV[0] == "-"
|
7
|
+
data = StringIO.new(STDIN.read)
|
8
|
+
else
|
9
|
+
abort("data file #{ARGV[0]} does not exist") unless File.exist?(ARGV[0])
|
10
|
+
data = File.open(ARGV[0], "r")
|
11
|
+
end
|
12
|
+
|
13
|
+
whisper = Whispr.new(data)
|
14
|
+
|
15
|
+
puts "Meta data:"
|
16
|
+
puts " aggregation method: #{whisper.info[:aggregationMethod]}"
|
17
|
+
puts " max retention: #{whisper.info[:maxRetention]}"
|
18
|
+
puts " xFilesFactor: #{whisper.info[:xFilesFactor]}"
|
19
|
+
|
20
|
+
whisper.archives.each.with_index do |archive, i|
|
21
|
+
puts "\nArchive #{i} info:"
|
22
|
+
puts " offset #{archive.offset}"
|
23
|
+
puts " seconds per point #{archive.spp}"
|
24
|
+
puts " points #{archive.points}"
|
25
|
+
puts " retention #{archive.retention}"
|
26
|
+
puts " size #{archive.size}"
|
27
|
+
end
|
28
|
+
|
29
|
+
whisper.archives.each.with_index do |archive, i|
|
30
|
+
puts "\nArchive #{i} data: "
|
31
|
+
archive.to_enum.each do |point, timestamp, value|
|
32
|
+
puts sprintf("#{point}, #{timestamp}, %10.35g", value)
|
33
|
+
end
|
34
|
+
end
|
data/bin/whispr-fetch
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "whispr"
|
4
|
+
require "stringio"
|
5
|
+
require "optparse"
|
6
|
+
require "json"
|
7
|
+
|
8
|
+
options = {:from => Time.new - 86400, :until => Time.new}
|
9
|
+
OptionParser.new do |opts|
|
10
|
+
opts.on("--from Integer", "Unix epoch time of the beginning of your requested interval (default: 24 hours ago") do |f|
|
11
|
+
if ["n", "N"].include?(f[0])
|
12
|
+
f = Time.new + f[1..-1].to_i
|
13
|
+
end
|
14
|
+
options[:from] = Time.at(f.to_i)
|
15
|
+
end
|
16
|
+
opts.on("--until Integer", "Unix epoch time of the end of your requested interval (default: now)") do |u|
|
17
|
+
if ["n", "N"].include?(u[0])
|
18
|
+
u = Time.new + u[1..-1].to_i
|
19
|
+
end
|
20
|
+
options[:until] = Time.at(u.to_i)
|
21
|
+
end
|
22
|
+
opts.on("--pretty", "Show human-readable timestamps instead of unix times") { options[:pretty] = true }
|
23
|
+
opts.on("--json", "Output results in JSON form") { options[:json] = true }
|
24
|
+
end.parse!
|
25
|
+
|
26
|
+
if ARGV[0].nil? || ARGV[0] == "-"
|
27
|
+
data = StringIO.new(STDIN.read)
|
28
|
+
else
|
29
|
+
abort("data file #{ARGV[0]} does not exist") unless File.exist?(ARGV[0])
|
30
|
+
data = File.open(ARGV[0], "r")
|
31
|
+
end
|
32
|
+
|
33
|
+
|
34
|
+
(start_t, end_t, step), values = Whispr.new(data).fetch(options[:from], options[:until])
|
35
|
+
if options[:json]
|
36
|
+
puts JSON.dump({:start => start_t, :end => end_t, :step => step, :values => values })
|
37
|
+
else
|
38
|
+
t = start_t
|
39
|
+
values.each do |value|
|
40
|
+
time = options[:pretty] ? Time.at(t) : t
|
41
|
+
value = value ? sprintf("%f", value) : 'None'
|
42
|
+
puts "#{time}\t#{value}"
|
43
|
+
t += step
|
44
|
+
end
|
45
|
+
end
|
data/bin/whispr-info
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "whispr"
|
4
|
+
require "stringio"
|
5
|
+
|
6
|
+
if ARGV[0].nil? || ARGV[0] == "-"
|
7
|
+
data = StringIO.new(STDIN.read)
|
8
|
+
else
|
9
|
+
abort("data file #{ARGV[0]} does not exist") unless File.exist?(ARGV[0])
|
10
|
+
data = File.open(ARGV[0], "r")
|
11
|
+
end
|
12
|
+
|
13
|
+
info = Whispr.new(data).info
|
14
|
+
info[:fileSize] = data.size
|
15
|
+
|
16
|
+
unless (fields = Array(ARGV[1..-1])).empty?
|
17
|
+
fields.each do |field|
|
18
|
+
unless info.include?(field.to_sym)
|
19
|
+
puts "Unknown field '#{field}'. Valid fields are #{info.keys.join(", ")}"
|
20
|
+
exit 1
|
21
|
+
end
|
22
|
+
puts info[field]
|
23
|
+
end
|
24
|
+
exit 0
|
25
|
+
end
|
26
|
+
|
27
|
+
archives = info.delete(:archives)
|
28
|
+
|
29
|
+
info.each { |k,v| puts "#{k}: #{v}" }
|
30
|
+
|
31
|
+
archives.each_index do |i|
|
32
|
+
puts "\nArchive #{i}"
|
33
|
+
archives[i].each { |k,v| puts "#{k}: #{v}" }
|
34
|
+
end
|
data/bin/whispr-update
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "whispr"
|
4
|
+
require "stringio"
|
5
|
+
|
6
|
+
points = ARGV.select{|e| e.include?(":") }
|
7
|
+
if points.empty?
|
8
|
+
puts "#{File.basename($0)} path timestamp:value [timestamp:value]*"
|
9
|
+
exit -1
|
10
|
+
end
|
11
|
+
|
12
|
+
file = ARGV - points
|
13
|
+
if file.empty? || file == "-"
|
14
|
+
data = StringIO.new(STDIN.read, "r+")
|
15
|
+
else
|
16
|
+
abort("data file #{file[0]} does not exist") unless File.exist?(file[0])
|
17
|
+
data = File.open(file[0], "r+")
|
18
|
+
end
|
19
|
+
|
20
|
+
now = Time.now.to_i
|
21
|
+
points = points.map{|p| p.split(":") }.map{|t,v| [t == 'N' ? now : t.to_i, v.to_f] }
|
22
|
+
|
23
|
+
Whispr.new(data).update(*points)
|
data/lib/whispr.rb
ADDED
@@ -0,0 +1,456 @@
|
|
1
|
+
require 'whispr/version'
|
2
|
+
require 'whispr/archive'
|
3
|
+
require 'stringio'
|
4
|
+
|
5
|
+
class Whispr
|
6
|
+
module Error; end
|
7
|
+
class WhisprError < StandardError
|
8
|
+
include Error
|
9
|
+
def self.exception(e)
|
10
|
+
return e if e.nil? || e == self
|
11
|
+
ne = new(e.to_s)
|
12
|
+
ne.set_backtrace e.backtrace if e.respond_to?(:backtrace)
|
13
|
+
ne
|
14
|
+
end
|
15
|
+
end
|
16
|
+
class CorruptWhisprFile < WhisprError; end
|
17
|
+
class InvalidTimeInterval < WhisprError; end
|
18
|
+
class TimestampNotCovered < WhisprError; end
|
19
|
+
class InvalidAggregationMethod < WhisprError; end
|
20
|
+
class ArchiveBoundaryExceeded < WhisprError; end
|
21
|
+
class ValueError < WhisprError; end
|
22
|
+
class InvalidConfiguration < WhisprError; end
|
23
|
+
|
24
|
+
LONG_FMT = "N"
|
25
|
+
METADATA_FMT = "#{LONG_FMT*2}g#{LONG_FMT}"
|
26
|
+
METADATA_SIZE = 16
|
27
|
+
ARCHIVE_INFO_FMT = LONG_FMT * 3
|
28
|
+
ARCHIVE_INFO_SIZE = 12
|
29
|
+
POINT_FMT = "#{LONG_FMT}G"
|
30
|
+
POINT_SIZE = 12
|
31
|
+
CHUNK_SIZE = 16384
|
32
|
+
|
33
|
+
AGGR_TYPES = [
|
34
|
+
:_,
|
35
|
+
:average,
|
36
|
+
:sum,
|
37
|
+
:last,
|
38
|
+
:max,
|
39
|
+
:min
|
40
|
+
].freeze
|
41
|
+
|
42
|
+
class << self
|
43
|
+
|
44
|
+
def unitMultipliers
|
45
|
+
@unitMultipliers ||= {
|
46
|
+
's' => 1,
|
47
|
+
'm' => 60,
|
48
|
+
'h' => 3600,
|
49
|
+
'd' => 86400,
|
50
|
+
'w' => 86400 * 7,
|
51
|
+
'y' => 86400 * 365
|
52
|
+
}
|
53
|
+
end
|
54
|
+
|
55
|
+
def parse_retention_def(rdef)
|
56
|
+
raise ArgumentError.new("precision and points must be separated by a ':'") unless rdef && rdef.include?(":")
|
57
|
+
(precision, points) = rdef.strip.split(':')
|
58
|
+
if precision.to_i.to_s == precision
|
59
|
+
precision = precision.to_i * unitMultipliers['s']
|
60
|
+
else
|
61
|
+
_, precision, unit = precision.split(/([\d]+)/)
|
62
|
+
unit = 's' unless unit
|
63
|
+
raise ValueError.new("Invalid precision specification unit #{unit}") unless unitMultipliers[unit[0]]
|
64
|
+
precision = precision.to_i * unitMultipliers[unit[0]]
|
65
|
+
end
|
66
|
+
|
67
|
+
if points.to_i.to_s == points
|
68
|
+
points = points.to_i
|
69
|
+
else
|
70
|
+
_, points, unit = points.split(/([\d]+)/)
|
71
|
+
raise ValueError.new("Invalid retention specification unit #{unit}") unless unitMultipliers[unit[0]]
|
72
|
+
points = points.to_i * unitMultipliers[unit[0]] / precision
|
73
|
+
end
|
74
|
+
|
75
|
+
[precision, points]
|
76
|
+
end
|
77
|
+
|
78
|
+
# Create whipser file.
|
79
|
+
# @param [String] path
|
80
|
+
# @param [Array] archiveList each archive is an array with two elements: [secondsPerPoint,numberOfPoints]
|
81
|
+
# @param [Hash] opts
|
82
|
+
# @option opts [Float] :xff the fraction of data points in a propagation interval that must have known values for a propagation to occur
|
83
|
+
# @option opts [Symbol] :aggregationMethod the function to use when propogating data; must be one of AGGR_TYPES[1..-1]
|
84
|
+
# @option opts [Boolean] :overwrite (false)
|
85
|
+
# @raise [InvalidConfiguration] if the archiveList is inavlid, or if 'path' exists and :overwrite is not true
|
86
|
+
# @see Whsipr.validateArchiveList
|
87
|
+
def create(path, archiveList, opts = {})
|
88
|
+
opts = {:xff => 0.5, :aggregationMethod => :average, :sparse => false, :overwrite => false}.merge(opts)
|
89
|
+
unless AGGR_TYPES[1..-1].include?(opts[:aggregationMethod])
|
90
|
+
raise InvalidConfiguration.new("aggregationMethod must be one of #{AGGR_TYPES[1..-1]}")
|
91
|
+
end
|
92
|
+
|
93
|
+
validateArchiveList!(archiveList)
|
94
|
+
raise InvalidConfiguration.new("File #{path} already exists!") if File.exists?(path) && !opts[:overwrite]
|
95
|
+
|
96
|
+
# if file exists it will be truncated
|
97
|
+
File.open(path, "wb") do |fh|
|
98
|
+
fh.flock(File::LOCK_EX)
|
99
|
+
aggregationType = AGGR_TYPES.index(opts[:aggregationMethod])
|
100
|
+
oldest = archiveList.map{|spp, points| spp * points }.sort.last
|
101
|
+
packedMetadata = [aggregationType, oldest, opts[:xff], archiveList.length].pack(METADATA_FMT)
|
102
|
+
fh.write(packedMetadata)
|
103
|
+
headerSize = METADATA_SIZE + (ARCHIVE_INFO_SIZE * archiveList.length)
|
104
|
+
archiveOffsetPointer = headerSize
|
105
|
+
archiveList.each do |spp, points|
|
106
|
+
archiveInfo = [archiveOffsetPointer, spp, points].pack(ARCHIVE_INFO_FMT)
|
107
|
+
fh.write(archiveInfo)
|
108
|
+
archiveOffsetPointer += (points * POINT_SIZE)
|
109
|
+
end
|
110
|
+
|
111
|
+
if opts[:sparse]
|
112
|
+
fh.seek(archiveOffsetPointer - headerSize - 1)
|
113
|
+
fh.write("\0")
|
114
|
+
else
|
115
|
+
remaining = archiveOffsetPointer - headerSize
|
116
|
+
zeroes = "\x00" * CHUNK_SIZE
|
117
|
+
while remaining > CHUNK_SIZE
|
118
|
+
fh.write(zeroes)
|
119
|
+
remaining -= CHUNK_SIZE
|
120
|
+
end
|
121
|
+
fh.write(zeroes[0..remaining])
|
122
|
+
end
|
123
|
+
|
124
|
+
fh.flush
|
125
|
+
fh.fsync rescue nil
|
126
|
+
end
|
127
|
+
|
128
|
+
new(path)
|
129
|
+
end
|
130
|
+
|
131
|
+
# Is the provided archive list valid?
|
132
|
+
# @return [Boolean] true, false
|
133
|
+
def validArchiveList?(archiveList)
|
134
|
+
!(!!(validateArchiveList!(archiveList) rescue true))
|
135
|
+
end
|
136
|
+
|
137
|
+
# Validate an archive list without raising an exception
|
138
|
+
# @return [NilClass, InvalidConfiguration]
|
139
|
+
def validateArchiveList(archiveList)
|
140
|
+
validateArchiveList!(archiveList) rescue $!
|
141
|
+
end
|
142
|
+
|
143
|
+
# Validate an archive list
|
144
|
+
# An ArchiveList must:
|
145
|
+
# 1. Have at least one archive config. Example: [60, 86400]
|
146
|
+
# 2. No archive may be a duplicate of another.
|
147
|
+
# 3. Higher precision archives' precision must evenly divide all lower precision archives' precision.
|
148
|
+
# 4. Lower precision archives must cover larger time intervals than higher precision archives.
|
149
|
+
# 5. Each archive must have at least enough points to consolidate to the next archive
|
150
|
+
# @raise [InvalidConfiguration]
|
151
|
+
# @return [nil]
|
152
|
+
def validateArchiveList!(archiveList)
|
153
|
+
raise InvalidConfiguration.new("you must specify at least on archive configuration") if Array(archiveList).empty?
|
154
|
+
archiveList = archiveList.sort{|a,b| a[0] <=> b[0] }
|
155
|
+
archiveList[0..-2].each_with_index do |archive, i|
|
156
|
+
nextArchive = archiveList[i+1]
|
157
|
+
unless archive[0] < nextArchive[0]
|
158
|
+
raise InvalidConfiguration.new("A Whipser database may not be configured " +
|
159
|
+
"having two archives with the same precision " +
|
160
|
+
"(archive#{i}: #{archive}, archive#{i+1}: #{nextArchive})")
|
161
|
+
end
|
162
|
+
unless nextArchive[0] % archive[0] == 0
|
163
|
+
raise InvalidConfiguration.new("Higher precision archives' precision must " +
|
164
|
+
"evenly divide all lower precision archives' precision " +
|
165
|
+
"(archive#{i}: #{archive}, archive#{i+1}: #{nextArchive})")
|
166
|
+
end
|
167
|
+
|
168
|
+
retention = archive[0] * archive[1]
|
169
|
+
nextRetention = nextArchive[0] * nextArchive[1]
|
170
|
+
unless nextRetention > retention
|
171
|
+
raise InvalidConfiguration.new("Lower precision archives must cover larger " +
|
172
|
+
"time intervals than higher precision archives " +
|
173
|
+
"(archive#{i}: #{archive[1]}, archive#{i + 1}:, #{nextArchive[1]})")
|
174
|
+
end
|
175
|
+
|
176
|
+
archivePoints = archive[1]
|
177
|
+
pointsPerConsolidation = nextArchive[0] / archive[0]
|
178
|
+
unless archivePoints >= pointsPerConsolidation
|
179
|
+
raise InvalidConfiguration.new("Each archive must have at least enough points " +
|
180
|
+
"to consolidate to the next archive (archive#{i+1} consolidates #{pointsPerConsolidation} of " +
|
181
|
+
"archive#{i}'s points but it has only #{archivePoints} total points)")
|
182
|
+
end
|
183
|
+
end
|
184
|
+
nil
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
# @return [File, StringIO] file handle of the whisper file
|
189
|
+
attr_reader :fh
|
190
|
+
|
191
|
+
attr_accessor :auto_flush
|
192
|
+
alias :auto_flush? :auto_flush
|
193
|
+
|
194
|
+
def initialize(file, auto_flush = true)
|
195
|
+
@fh = file.is_a?(File) || file.is_a?(StringIO) ? file : File.open(file, 'r+')
|
196
|
+
@fh.binmode
|
197
|
+
@auto_flush = auto_flush
|
198
|
+
end
|
199
|
+
|
200
|
+
# @return [Hash]
|
201
|
+
def header
|
202
|
+
@header ||= read_header
|
203
|
+
end
|
204
|
+
alias :info :header
|
205
|
+
|
206
|
+
# @return [Array] Archives
|
207
|
+
# @see Whispr::Archive
|
208
|
+
def archives
|
209
|
+
@archives ||= info[:archives].map { |a| Archive.new(self, a) }
|
210
|
+
end
|
211
|
+
|
212
|
+
|
213
|
+
# Retrieve values from a whisper file within the given time window.
|
214
|
+
#
|
215
|
+
# The most appropriate archive within the whisper file will be chosen. The
|
216
|
+
# return value will be a two element Array. The first element will be a
|
217
|
+
# three element array containing the start time, end time and step. The
|
218
|
+
# second element will be a N element array containing each value at each
|
219
|
+
# step period.
|
220
|
+
#
|
221
|
+
# @see Archive#fetch
|
222
|
+
def fetch(fromTime, untilTime = Time.new)
|
223
|
+
fromTime = fromTime.to_i
|
224
|
+
untilTime = untilTime.to_i
|
225
|
+
now = Time.now.to_i
|
226
|
+
oldest = header[:maxRetention]
|
227
|
+
fromTime = oldest if fromTime < oldest
|
228
|
+
raise InvalidTimeInterval.new("Invalid time interval") unless fromTime < untilTime
|
229
|
+
untilTime = now if untilTime > now || untilTime < fromTime
|
230
|
+
|
231
|
+
diff = now - fromTime
|
232
|
+
archive = archives.find{|a| a.retention >= diff }
|
233
|
+
return archive.fetch(fromTime, untilTime)
|
234
|
+
end
|
235
|
+
|
236
|
+
# Update one or many points
|
237
|
+
# Each element of the points list should be a two dimensional Array where
|
238
|
+
# the first element is a timestamp and the second element is a value.
|
239
|
+
def update(*points)
|
240
|
+
return if points.empty?
|
241
|
+
# TODO lock the file
|
242
|
+
if points.length == 1
|
243
|
+
update_one(points[0][1], points[0][0])
|
244
|
+
else
|
245
|
+
update_many(points)
|
246
|
+
end
|
247
|
+
end
|
248
|
+
|
249
|
+
private
|
250
|
+
|
251
|
+
|
252
|
+
def read_header
|
253
|
+
o_pos = @fh.pos
|
254
|
+
|
255
|
+
begin
|
256
|
+
@fh.pos = 0
|
257
|
+
metadata = @fh.read(METADATA_SIZE)
|
258
|
+
aggr_type, max_retention, xff, arch_count = metadata.unpack(METADATA_FMT)
|
259
|
+
archives = arch_count.times.map do |i|
|
260
|
+
arch_info = @fh.read(ARCHIVE_INFO_SIZE)
|
261
|
+
offset, s_per_pnt, points = arch_info.unpack(ARCHIVE_INFO_FMT)
|
262
|
+
{ :retention => s_per_pnt * points,
|
263
|
+
:secondsPerPoint => s_per_pnt,
|
264
|
+
:points => points,
|
265
|
+
:size => points * POINT_SIZE,
|
266
|
+
:offset => offset
|
267
|
+
}
|
268
|
+
end
|
269
|
+
rescue => e
|
270
|
+
raise CorruptWhisprFile.exception(e)
|
271
|
+
ensure
|
272
|
+
@fh.pos = o_pos
|
273
|
+
end
|
274
|
+
|
275
|
+
{ :maxRetention => max_retention,
|
276
|
+
:xFilesFactor => xff,
|
277
|
+
:aggregationMethod => AGGR_TYPES[aggr_type],
|
278
|
+
:archives => archives
|
279
|
+
}
|
280
|
+
end
|
281
|
+
|
282
|
+
def update_one(value, timestamp = nil)
|
283
|
+
now = Time.new.to_i
|
284
|
+
timestamp = now if timestamp.nil?
|
285
|
+
diff = now - timestamp
|
286
|
+
if !(diff < header[:maxRetention] && diff >= 0)
|
287
|
+
raise TimestampNotCovered, "Timestamp (#{timestamp}) not covered by any archives in this database"
|
288
|
+
end
|
289
|
+
|
290
|
+
aidx = (0 ... archives.length).find { |i| archives[i].retention > diff }
|
291
|
+
archive = archives[aidx]
|
292
|
+
lowerArchives = archives[aidx + 1 .. - 1]
|
293
|
+
|
294
|
+
myInterval = timestamp - (timestamp % archive.spp)
|
295
|
+
myPackedPoint = [myInterval, value].pack(POINT_FMT)
|
296
|
+
@fh.seek(archive.offset)
|
297
|
+
baseInterval, baseValue = @fh.read(POINT_SIZE).unpack(POINT_FMT)
|
298
|
+
|
299
|
+
if baseInterval == 0
|
300
|
+
# this file's first update
|
301
|
+
@fh.seek(archive.offset)
|
302
|
+
@fh.write(myPackedPoint)
|
303
|
+
baseInterval, baseValue = myInterval, value
|
304
|
+
else
|
305
|
+
timeDistance = myInterval - baseInterval
|
306
|
+
pointDistance = timeDistance / archive.spp
|
307
|
+
byteDistance = pointDistance * POINT_SIZE
|
308
|
+
myOffset = archive.offset + (byteDistance % archive.size)
|
309
|
+
@fh.seek(myOffset)
|
310
|
+
@fh.write(myPackedPoint)
|
311
|
+
end
|
312
|
+
|
313
|
+
higher = archive
|
314
|
+
lowerArchives.each do |lower|
|
315
|
+
break unless propagate(myInterval, higher, lower)
|
316
|
+
higher = lower
|
317
|
+
end
|
318
|
+
|
319
|
+
@fh.flush if auto_flush?
|
320
|
+
end
|
321
|
+
|
322
|
+
def update_many(points)
|
323
|
+
# order points by timestamp, newest first
|
324
|
+
points = points.map{|ts, v| [ts.to_i, v.to_f ] }.sort {|b,a| a[0] <=> b[0] }
|
325
|
+
now = Time.new.to_i
|
326
|
+
archives = self.archives.to_enum
|
327
|
+
currentArchive = archives.next
|
328
|
+
currentPoints = []
|
329
|
+
points.each do |point|
|
330
|
+
age = now - point[0]
|
331
|
+
while currentArchive.retention < age
|
332
|
+
unless currentPoints.empty?
|
333
|
+
currentPoints.reverse! # put points in chronological order
|
334
|
+
currentArchive.update_many(currentPoints)
|
335
|
+
currentPoints = []
|
336
|
+
end
|
337
|
+
begin
|
338
|
+
currentArchive = archives.next
|
339
|
+
rescue StopIteration
|
340
|
+
currentArchive = nil
|
341
|
+
break
|
342
|
+
end
|
343
|
+
end
|
344
|
+
# drop remaining points that don't fit in the database
|
345
|
+
break unless currentArchive
|
346
|
+
|
347
|
+
currentPoints << point
|
348
|
+
end
|
349
|
+
|
350
|
+
if currentArchive && !currentPoints.empty?
|
351
|
+
# don't forget to commit after we've checked all the archives
|
352
|
+
currentPoints.reverse!
|
353
|
+
currentArchive.update_many(currentPoints)
|
354
|
+
end
|
355
|
+
|
356
|
+
@fh.flush if auto_flush?
|
357
|
+
end
|
358
|
+
|
359
|
+
def propagate(timestamp, higher, lower)
|
360
|
+
aggregationMethod = header[:aggregationMethod]
|
361
|
+
xff = header[:xFilesFactor]
|
362
|
+
|
363
|
+
lowerIntervalStart = timestamp - (timestamp % lower.spp)
|
364
|
+
lowerIntervalEnd = lowerIntervalStart + lower.spp
|
365
|
+
@fh.seek(higher.offset)
|
366
|
+
higherBaseInterval, higherBaseValue = @fh.read(POINT_SIZE).unpack(POINT_FMT)
|
367
|
+
|
368
|
+
if higherBaseInterval == 0
|
369
|
+
higherFirstOffset = higher.offset
|
370
|
+
else
|
371
|
+
timeDistance = lowerIntervalStart - higherBaseInterval
|
372
|
+
pointDistance = timeDistance / higher.spp
|
373
|
+
byteDistance = pointDistance * POINT_SIZE
|
374
|
+
higherFirstOffset = higher.offset + (byteDistance % higher.size)
|
375
|
+
end
|
376
|
+
|
377
|
+
higherPoints = lower.spp / higher.spp
|
378
|
+
higherSize = higherPoints * POINT_SIZE
|
379
|
+
relativeFirstOffset = higherFirstOffset - higher.offset
|
380
|
+
relativeLastOffset = (relativeFirstOffset + higherSize) % higher.size
|
381
|
+
higherLastOffset = relativeLastOffset + higher.offset
|
382
|
+
@fh.seek(higherFirstOffset)
|
383
|
+
|
384
|
+
if higherFirstOffset < higherLastOffset
|
385
|
+
# don't wrap the archive
|
386
|
+
seriesString = @fh.read(higherLastOffset - higherFirstOffset)
|
387
|
+
else
|
388
|
+
# wrap the archive
|
389
|
+
higherEnd = higher.offset + higher.size
|
390
|
+
seriesString = @fh.read(higherEnd - higherFirstOffset)
|
391
|
+
@fh.seek(higher.offset)
|
392
|
+
seriesString += @fh.read(higherLastOffset - higher.offset)
|
393
|
+
end
|
394
|
+
|
395
|
+
points = seriesString.length / POINT_SIZE
|
396
|
+
unpackedSeries = seriesString.unpack(POINT_FMT * points)
|
397
|
+
|
398
|
+
# construct a list of values
|
399
|
+
neighborValues = points.times.map{}
|
400
|
+
currentInterval = lowerIntervalStart
|
401
|
+
step = higher.spp
|
402
|
+
(0..unpackedSeries.length).step(2) do |i|
|
403
|
+
pointTime = unpackedSeries[i]
|
404
|
+
neighborValues[i/2] = unpackedSeries[i+1] if pointTime == currentInterval
|
405
|
+
currentInterval += step
|
406
|
+
end
|
407
|
+
|
408
|
+
knownValues = neighborValues.select { |v| !v.nil? }
|
409
|
+
return false if knownValues.empty?
|
410
|
+
if (knownValues.length / neighborValues.length).to_f < header[:xFilesFactor]
|
411
|
+
return false
|
412
|
+
end
|
413
|
+
|
414
|
+
# we have enough data to propagate a value
|
415
|
+
aggregateValue = aggregate(aggregationMethod, knownValues)
|
416
|
+
myPackedPoint = [lowerIntervalStart, aggregateValue].pack(POINT_FMT)
|
417
|
+
@fh.seek(lower.offset)
|
418
|
+
lowerBaseInterval, lowerBaseValue = @fh.read(POINT_SIZE).unpack(POINT_FMT)
|
419
|
+
|
420
|
+
if lowerBaseInterval == 0
|
421
|
+
# first propagated update to this lower archive
|
422
|
+
@fh.seek(lower.offset)
|
423
|
+
@fh.write(myPackedPoint)
|
424
|
+
else
|
425
|
+
timeDistance = lowerIntervalStart - lowerBaseInterval
|
426
|
+
pointDistance = timeDistance / lower.spp
|
427
|
+
byteDistance = pointDistance * POINT_SIZE
|
428
|
+
lowerOffset = lower.offset + (byteDistance % lower.size)
|
429
|
+
@fh.seek(lowerOffset)
|
430
|
+
@fh.write(myPackedPoint)
|
431
|
+
end
|
432
|
+
true
|
433
|
+
end
|
434
|
+
|
435
|
+
def aggregate(aggregationMethod, knownValues)
|
436
|
+
case aggregationMethod
|
437
|
+
when :average
|
438
|
+
(knownValues.inject(0){|sum, i| sum + i } / knownValues.length).to_f
|
439
|
+
when :sum
|
440
|
+
knownValues.inject(0){|sum, i| sum + i }
|
441
|
+
when :last
|
442
|
+
knownValues[-1]
|
443
|
+
when :max
|
444
|
+
v = knownValues[0]
|
445
|
+
knownValues[1..-1].each { |k| v = k if k > v }
|
446
|
+
v
|
447
|
+
when :min
|
448
|
+
v = knownValues[0]
|
449
|
+
knownValues[1..-1].each { |k| v = k if k < v }
|
450
|
+
v
|
451
|
+
else
|
452
|
+
raise InvalidAggregationMethod, "Unrecognized aggregation method #{aggregationMethod}"
|
453
|
+
end
|
454
|
+
end
|
455
|
+
|
456
|
+
end
|
@@ -0,0 +1,220 @@
|
|
1
|
+
class Whispr
|
2
|
+
class Archive
|
3
|
+
include Enumerable
|
4
|
+
|
5
|
+
# @return [Hash] the archive header
|
6
|
+
attr_reader :header
|
7
|
+
# @return [Fixnum] the start location in the whisper file of this Archive
|
8
|
+
attr_reader :offset
|
9
|
+
# @return [Fixnum] the number of points in this archive
|
10
|
+
attr_reader :points
|
11
|
+
# @return [Fixnum] the total size of this archive (points * POINT_SIZE)
|
12
|
+
attr_reader :size
|
13
|
+
# @return [Fixnum] number of seconds worth of data retained by this archive
|
14
|
+
attr_reader :retention
|
15
|
+
# @return [Fixnum] seconds per point
|
16
|
+
attr_reader :spp
|
17
|
+
# @return [Whispr} the Whisper that contains this Archive
|
18
|
+
attr_reader :whisper
|
19
|
+
|
20
|
+
def initialize(whisper, header)
|
21
|
+
@whisper = whisper
|
22
|
+
@header = header
|
23
|
+
@offset = @header[:offset]
|
24
|
+
@points = @header[:points]
|
25
|
+
@size = @header[:size]
|
26
|
+
@retention = @header[:retention]
|
27
|
+
@spp = @header[:secondsPerPoint]
|
28
|
+
@eoa = @size * @points + @offset
|
29
|
+
end
|
30
|
+
|
31
|
+
# Retrieve each point from the archive.
|
32
|
+
#
|
33
|
+
# If a block is provided each point is read directly from
|
34
|
+
# the whisper file one at a time and yielded. If a block
|
35
|
+
# is not provided, all points are read from the file and
|
36
|
+
# returned as an enum.
|
37
|
+
#
|
38
|
+
# Each point is represented as a three element Array. The first
|
39
|
+
# element is the index of the point. The second element is the
|
40
|
+
# timestamp of the point and the third element is the value of
|
41
|
+
# the point.
|
42
|
+
def each(&blk)
|
43
|
+
return slurp.to_enum unless block_given?
|
44
|
+
o_pos = @whisper.fh.pos
|
45
|
+
begin
|
46
|
+
@whisper.fh.pos = @offset
|
47
|
+
points.times {|i| yield(i, *next_point) }
|
48
|
+
ensure
|
49
|
+
@whisper.fh.pos = o_pos
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# Has the end of the archive been reached?
|
54
|
+
def eoa?
|
55
|
+
@whisper.fh.pos >= @eoa
|
56
|
+
end
|
57
|
+
|
58
|
+
def to_enum
|
59
|
+
slurp.to_enum
|
60
|
+
end
|
61
|
+
|
62
|
+
# Retrieve the next point from the whisper file.
|
63
|
+
# @api private
|
64
|
+
def next_point
|
65
|
+
return nil if @whisper.fh.pos >= @eoa || @whisper.fh.pos < @offset
|
66
|
+
@whisper.fh.read(POINT_SIZE).unpack(POINT_FMT)
|
67
|
+
end
|
68
|
+
|
69
|
+
# Retrieve all points for this archive from the whisper file.
|
70
|
+
#
|
71
|
+
# Each point is represented as a three element Array. The first
|
72
|
+
# element is the index of the point. The second element is the
|
73
|
+
# timestamp of the point and the third element is the value of
|
74
|
+
# the point.
|
75
|
+
#
|
76
|
+
# @return [Array]
|
77
|
+
def slurp
|
78
|
+
o_pos = @whisper.fh.pos
|
79
|
+
@whisper.fh.pos = @offset
|
80
|
+
data = @whisper.fh.read(@size).unpack(POINT_FMT * @points)
|
81
|
+
@points.times.map { |i| [i, data.shift, data.shift] }
|
82
|
+
ensure
|
83
|
+
@whisper.fh.pos = o_pos
|
84
|
+
end
|
85
|
+
|
86
|
+
# Retrieve values for a time period from an archive within a whisper file
|
87
|
+
#
|
88
|
+
# The return value will be a two element Array. The first element will be
|
89
|
+
# a three element array containing the start time, end time and step. The
|
90
|
+
# second element will be a N element array containing each value at each
|
91
|
+
# step period.
|
92
|
+
#
|
93
|
+
# @see Whispr#fetch
|
94
|
+
def fetch(fromTime, untilTime)
|
95
|
+
fromInterval = (fromTime - (fromTime % spp)) + spp
|
96
|
+
untilInterval = (untilTime - (untilTime % spp)) + spp
|
97
|
+
o_pos = @whisper.fh.pos
|
98
|
+
begin
|
99
|
+
@whisper.fh.seek(offset)
|
100
|
+
baseInterval, baseValue = @whisper.fh.read(POINT_SIZE).unpack(POINT_FMT)
|
101
|
+
if baseInterval == 0
|
102
|
+
step = spp
|
103
|
+
points = (untilInterval - fromInterval) / step
|
104
|
+
timeInfo = [fromInterval, untilInterval, step]
|
105
|
+
return [timeInfo, points.times.map{}]
|
106
|
+
end
|
107
|
+
|
108
|
+
# Determine fromOffset
|
109
|
+
timeDistance = fromInterval - baseInterval
|
110
|
+
pointDistance = timeDistance / spp
|
111
|
+
byteDistance = pointDistance * POINT_SIZE
|
112
|
+
fromOffset = offset + (byteDistance % size)
|
113
|
+
|
114
|
+
# Determine untilOffset
|
115
|
+
timeDistance = untilInterval - baseInterval
|
116
|
+
pointDistance = timeDistance / spp
|
117
|
+
byteDistance = pointDistance * POINT_SIZE
|
118
|
+
untilOffset = offset + (byteDistance % size)
|
119
|
+
|
120
|
+
# Reall all the points in the interval
|
121
|
+
@whisper.fh.seek(fromOffset)
|
122
|
+
if fromOffset < untilOffset
|
123
|
+
# we don't wrap around the archive
|
124
|
+
series = @whisper.fh.read(untilOffset - fromOffset)
|
125
|
+
else
|
126
|
+
# we wrap around the archive, so we need two reads
|
127
|
+
archiveEnd = offset + size
|
128
|
+
series = @whisper.fh.read(archiveEnd - fromOffset)
|
129
|
+
@whisper.fh.seek(offset)
|
130
|
+
series += @whisper.fh.read(untilOffset - offset)
|
131
|
+
end
|
132
|
+
|
133
|
+
points = series.length / POINT_SIZE
|
134
|
+
series = series.unpack(POINT_FMT * points)
|
135
|
+
currentInterval = fromInterval
|
136
|
+
step = spp
|
137
|
+
valueList = points.times.map{}
|
138
|
+
(0..series.length).step(2) do |i|
|
139
|
+
pointTime = series[i]
|
140
|
+
if pointTime == currentInterval
|
141
|
+
pointValue = series[i+1]
|
142
|
+
valueList[i/2] = pointValue
|
143
|
+
end
|
144
|
+
currentInterval += step
|
145
|
+
end
|
146
|
+
|
147
|
+
timeInfo = [fromInterval, untilInterval, step]
|
148
|
+
ensure
|
149
|
+
@whisper.fh.pos = o_pos
|
150
|
+
end
|
151
|
+
[timeInfo, valueList]
|
152
|
+
end
|
153
|
+
|
154
|
+
def update_many(points)
|
155
|
+
step = spp
|
156
|
+
alignedPoints = points.map { |ts, v| [(ts - (ts % step)), v] }
|
157
|
+
# Create a packed string for each contiguous sequence of points
|
158
|
+
packedStrings = []
|
159
|
+
previousInterval = nil
|
160
|
+
currentString = ''
|
161
|
+
alignedPoints.each do |interval, value|
|
162
|
+
next if interval == previousInterval
|
163
|
+
if previousInterval.nil? || (interval == previousInterval + step)
|
164
|
+
currentString += [interval, value].pack(POINT_FMT)
|
165
|
+
else
|
166
|
+
numberOfPoints = currentString.length / POINT_SIZE
|
167
|
+
startInterval = previousInterval - (step * (numberOfPoints - 1))
|
168
|
+
packedStrings << [startInterval, currentString]
|
169
|
+
currentString = [interval, value].pack(POINT_FMT)
|
170
|
+
end
|
171
|
+
previousInterval = interval
|
172
|
+
end
|
173
|
+
if !currentString.empty?
|
174
|
+
numberOfPoints = currentString.length / POINT_SIZE
|
175
|
+
startInterval = previousInterval - (step * (numberOfPoints - 1))
|
176
|
+
packedStrings << [startInterval, currentString]
|
177
|
+
end
|
178
|
+
|
179
|
+
# Read base point and determine where our writes will start
|
180
|
+
@whisper.fh.seek(offset)
|
181
|
+
baseInterval, baseValue = @whisper.fh.read(POINT_SIZE).unpack(POINT_FMT)
|
182
|
+
baseInterval = packedStrings[0][0] if baseInterval == 0
|
183
|
+
packedStrings.each do |interval, packedString|
|
184
|
+
timeDistance = interval - baseInterval
|
185
|
+
pointDistance = timeDistance / step
|
186
|
+
byteDistance = pointDistance * POINT_SIZE
|
187
|
+
myOffset = offset + (byteDistance % size)
|
188
|
+
@whisper.fh.seek(myOffset)
|
189
|
+
archiveEnd = offset + size
|
190
|
+
bytesBeyond = (myOffset + packedString.length) - archiveEnd
|
191
|
+
|
192
|
+
if bytesBeyond > 0
|
193
|
+
@whisper.fh.write(packedString[0..-bytesBeyond])
|
194
|
+
if(@whisper.fh.pos != archiveEnd)
|
195
|
+
raise ArchiveBoundaryExceeded.new("archiveEnd=#{archiveEnd} pos=#{@whisper.fh.pos} bytesBeyond=#{bytesBeyond} len(packedString)=#{packedString.length}")
|
196
|
+
end
|
197
|
+
@whisper.fh.seek(offset)
|
198
|
+
@whisper.fh.write(packedString[-bytesBeyond..-1])
|
199
|
+
else
|
200
|
+
@whisper.fh.write(packedString)
|
201
|
+
end
|
202
|
+
end # interval, packedString|
|
203
|
+
|
204
|
+
# Now we propagate the updates to the lower-precision archives
|
205
|
+
higher = self
|
206
|
+
@whisper.archives.select{|a| a.spp > spp }.each do |lower|
|
207
|
+
lowerIntervals = alignedPoints.map{|p| p[0] - (p[0] % lower.spp) }
|
208
|
+
propagateFurther = false
|
209
|
+
lowerIntervals.uniq.each do |interval|
|
210
|
+
propagateFuther = @whisper.send(:propagate, interval, higher, lower)
|
211
|
+
end
|
212
|
+
break unless propagateFurther
|
213
|
+
higher = lower
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
|
218
|
+
|
219
|
+
end
|
220
|
+
end
|
metadata
ADDED
@@ -0,0 +1,59 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: whispr
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Caleb Crane
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2012-08-14 00:00:00.000000000 Z
|
13
|
+
dependencies: []
|
14
|
+
description: ''
|
15
|
+
email: whispr@simulacre.org
|
16
|
+
executables:
|
17
|
+
- whispr-create
|
18
|
+
- whispr-dump
|
19
|
+
- whispr-fetch
|
20
|
+
- whispr-info
|
21
|
+
- whispr-update
|
22
|
+
extensions: []
|
23
|
+
extra_rdoc_files: []
|
24
|
+
files:
|
25
|
+
- lib/whispr/archive.rb
|
26
|
+
- lib/whispr/version.rb
|
27
|
+
- lib/whispr.rb
|
28
|
+
- bin/whispr-create
|
29
|
+
- bin/whispr-dump
|
30
|
+
- bin/whispr-fetch
|
31
|
+
- bin/whispr-info
|
32
|
+
- bin/whispr-update
|
33
|
+
- README.md
|
34
|
+
homepage: http://github.com/simulacre/whispr
|
35
|
+
licenses: []
|
36
|
+
post_install_message:
|
37
|
+
rdoc_options: []
|
38
|
+
require_paths:
|
39
|
+
- lib
|
40
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ! '>='
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: '0'
|
46
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
47
|
+
none: false
|
48
|
+
requirements:
|
49
|
+
- - ! '>='
|
50
|
+
- !ruby/object:Gem::Version
|
51
|
+
version: '0'
|
52
|
+
requirements: []
|
53
|
+
rubyforge_project:
|
54
|
+
rubygems_version: 1.8.24
|
55
|
+
signing_key:
|
56
|
+
specification_version: 3
|
57
|
+
summary: Read and write Graphite Whisper round-robin files
|
58
|
+
test_files: []
|
59
|
+
has_rdoc:
|