sixty 0.1.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,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ require_relative 'sql'
6
+
7
+ module Sixty
8
+ # A query plan, reduced to its shape.
9
+ #
10
+ # ── Why this is the signal worth having ───────────────────────────────────
11
+ #
12
+ # Everything else this agent measures is a symptom: rows went up, latency went
13
+ # up, a method makes more queries than it did. A plan change is the *cause* —
14
+ # the moment a query stops using an index is the moment it becomes the
15
+ # incident, and every other number only reflects it afterwards, usually days
16
+ # afterwards, once the table is large enough for the difference to show.
17
+ #
18
+ # It is also the finding a person can act on without understanding any of
19
+ # this. "This got slower" invites a shrug; "this stopped using
20
+ # index_orders_on_user_id" names the fix.
21
+ #
22
+ # ── The rules that make this safe to run in production ────────────────────
23
+ #
24
+ # 1. EXPLAIN, never EXPLAIN ANALYZE. ANALYZE *executes* the statement to
25
+ # measure it — doubling the load of every explained query and, for anything
26
+ # that writes, performing the write a second time.
27
+ # 2. GENERIC_PLAN, so no parameter is ever bound. Postgres 16 added it exactly
28
+ # for this. It is what keeps the privacy boundary intact by construction
29
+ # rather than by a promise to strip values afterwards.
30
+ # 3. Read-only statements only, refused before they reach the database.
31
+ # 4. Off the request path entirely. The Node agent issues its EXPLAIN after
32
+ # the caller's promise has settled; Ruby's notification subscriber runs
33
+ # *inside* the caller's stack with the connection still checked out, so
34
+ # doing the same here would put a second round trip in front of a user.
35
+ # Instead the statement is queued and the flush thread explains it later on
36
+ # a connection of its own. The plan lands one window late, which is nothing
37
+ # — a plan is a property of the statement and the schema, not of the call.
38
+ module Plans
39
+ MAX_PLANS = 500
40
+ MAX_PENDING = 100
41
+
42
+ # Only statements that read. Anything else is refused here rather than
43
+ # relying on GENERIC_PLAN being side-effect free.
44
+ READ_ONLY = /\A\s*(select|with)\b/i.freeze
45
+ # A statement already carrying its own EXPLAIN, or several statements at
46
+ # once, is not something to wrap in another EXPLAIN.
47
+ UNSAFE = /\A\s*explain\b|;\s*\S/i.freeze
48
+
49
+ STRUCTURAL = [
50
+ 'Node Type', 'Join Type', 'Strategy', 'Relation Name',
51
+ 'Index Name', 'Scan Direction', 'Parent Relationship'
52
+ ].freeze
53
+
54
+ MAX_DEPTH = 12
55
+ MAX_NODES = 120
56
+
57
+ class << self
58
+ def mutex
59
+ @mutex ||= Mutex.new
60
+ end
61
+
62
+ def plans
63
+ @plans ||= {}
64
+ end
65
+
66
+ def pending
67
+ @pending ||= {}
68
+ end
69
+
70
+ def get(key)
71
+ mutex.synchronize { plans[key] }
72
+ end
73
+
74
+ def set(key, plan)
75
+ mutex.synchronize do
76
+ # Bounded like every other per-operation cache here. A process that
77
+ # has seen five hundred distinct statements will not learn much from
78
+ # the next one.
79
+ return if plans.size >= MAX_PLANS || plans.key?(key)
80
+
81
+ plans[key] = plan
82
+ end
83
+ end
84
+
85
+ # Remember a statement to explain on the next flush.
86
+ #
87
+ # `attempted` is marked at enqueue rather than at success, so a statement
88
+ # the planner refuses — an older Postgres with no GENERIC_PLAN, an
89
+ # untypable parameter, a temp table that no longer exists — is tried once
90
+ # and then left alone instead of retried on every execution forever.
91
+ #
92
+ # ── Two refusals, both about values ──────────────────────────────────
93
+ #
94
+ # **MySQL gets no plans at all.** It has no equivalent of GENERIC_PLAN: a
95
+ # statement can only be explained with its parameters bound, so capturing
96
+ # a plan would mean *retaining* somebody's query values in order to
97
+ # compose a command out of them. The signal is worth a great deal — an
98
+ # index that stopped being used names a cause rather than a symptom — and
99
+ # it is still not worth holding customer data to get. `@sixty-sh/node`
100
+ # refuses this for the same reason, and MongoDB is refused a third time on
101
+ # the same grounds.
102
+ #
103
+ # **A statement that arrived with literals in it is refused too**, even on
104
+ # Postgres. `where id = $1` carries no data and is safe to hand back;
105
+ # `where id = 42` is what an application with prepared statements turned
106
+ # off produces, and explaining it would mean keeping the values in memory
107
+ # until the next flush. The lexer decides which is which, because the
108
+ # question is exactly "would normalization have removed anything".
109
+ def enqueue(text, key, dialect: :postgres)
110
+ return unless dialect == :postgres
111
+ return unless text.is_a?(String) && READ_ONLY.match?(text) && !UNSAFE.match?(text)
112
+ return unless Sql.value_free?(text)
113
+
114
+ mutex.synchronize do
115
+ return if attempted.key?(key) || pending.size >= MAX_PENDING || plans.size >= MAX_PLANS
116
+
117
+ attempted[key] = true
118
+ pending[key] = text
119
+ end
120
+ end
121
+
122
+ # Drain the queue, explaining each statement with the caller's runner.
123
+ #
124
+ # @param runner [#call] takes a SQL string, returns the parsed `QUERY PLAN`
125
+ # value (an Array or Hash), or raises. Supplied by the ActiveRecord
126
+ # instrumentation so this file never has to know what a connection is.
127
+ def capture_pending(runner)
128
+ queued = mutex.synchronize do
129
+ taken = pending
130
+ @pending = {}
131
+ taken
132
+ end
133
+
134
+ queued.each do |key, text|
135
+ explained = runner.call("explain (generic_plan, format json) #{text}")
136
+ shaped = shape(explained)
137
+ next unless shaped
138
+
139
+ set(key, shaped.merge(key: plan_key(shaped[:shape])))
140
+ rescue StandardError
141
+ # Swallowed, deliberately and completely. None of the ordinary reasons
142
+ # this fails is the application's problem, and an observability agent
143
+ # that turns a planning quirk into a runtime error has done far more
144
+ # harm than the signal is worth.
145
+ next
146
+ end
147
+ end
148
+
149
+ # Reduce an EXPLAIN (FORMAT JSON) result to a stable, comparable shape.
150
+ #
151
+ # A plan as Postgres emits it is mostly numbers — startup cost, total
152
+ # cost, row estimate, width, loops — and every one of those moves whenever
153
+ # the statistics move, which is after every autovacuum. A detector
154
+ # comparing plans literally would fire constantly and mean nothing. What
155
+ # matters is structural and changes rarely: this query used to use an
156
+ # index and now scans the table.
157
+ def shape(explain)
158
+ explain = JSON.parse(explain) if explain.is_a?(String)
159
+ root = explain.is_a?(Array) ? explain.dig(0, 'Plan') : explain&.dig('Plan')
160
+ return nil unless root
161
+
162
+ nodes = 0
163
+ scans = []
164
+
165
+ walk = lambda do |node, depth|
166
+ return nil if node.nil? || depth > MAX_DEPTH || nodes >= MAX_NODES
167
+
168
+ nodes += 1
169
+ out = {}
170
+ STRUCTURAL.each { |field| out[field] = node[field] unless node[field].nil? }
171
+
172
+ if node['Node Type'].is_a?(String) && node['Node Type'].include?('Scan')
173
+ relation = node['Relation Name']
174
+ index = node['Index Name']
175
+ if relation
176
+ scans << (index ? "#{node['Node Type']} #{relation} using #{index}" : "#{node['Node Type']} #{relation}")
177
+ end
178
+ end
179
+
180
+ # The presence of a filter is structural; its contents are not.
181
+ out['Filtered'] = true if node['Filter']
182
+ out['IndexCond'] = true if node['Index Cond']
183
+ out['HashCond'] = true if node['Hash Cond']
184
+
185
+ children = Array(node['Plans']).map { |child| walk.call(child, depth + 1) }.compact
186
+ out['Plans'] = children unless children.empty?
187
+ out
188
+ end
189
+
190
+ shaped = walk.call(root, 0)
191
+ return nil unless shaped
192
+
193
+ # Two lists, because they answer different questions. `scans` is every
194
+ # scan node, which is what a person reading a summary wants. `seqScans`
195
+ # is only the sequential ones — the reads with no index — which is what
196
+ # the detector reports as a defect.
197
+ { shape: shaped, summary: summarize(shaped), scans: scans, seqScans: sequential_scans(shaped) }
198
+ end
199
+
200
+ # A stable identity for a shape, so "did the plan change" is one string
201
+ # comparison. Key order is normalised on the way in: Postgres emits fields
202
+ # consistently today, but a shape whose identity depended on that would
203
+ # report a change the first time a minor release reordered them.
204
+ def plan_key(shape)
205
+ JSON.generate(canonical(shape))
206
+ end
207
+
208
+ def sequential_scans(shape)
209
+ found = []
210
+ walk = lambda do |node|
211
+ found << node['Relation Name'] if node['Node Type'] == 'Seq Scan' && node['Relation Name']
212
+ Array(node['Plans']).each { |child| walk.call(child) }
213
+ end
214
+ walk.call(shape) if shape
215
+ found
216
+ end
217
+
218
+ def reset!
219
+ mutex.synchronize do
220
+ @plans = {}
221
+ @pending = {}
222
+ @attempted = {}
223
+ end
224
+ end
225
+
226
+ private
227
+
228
+ def attempted
229
+ @attempted ||= {}
230
+ end
231
+
232
+ # A one-line description, for a feed card with no room for a tree. It
233
+ # reads outside-in — the top node is what the query *is*, the scans are
234
+ # what it costs — because "Aggregate over Seq Scan on orders" is the
235
+ # sentence a person would say out loud.
236
+ def summarize(shape)
237
+ top = shape['Node Type'] || 'Plan'
238
+ scans = []
239
+ collect = lambda do |node|
240
+ if node['Node Type'].is_a?(String) && node['Node Type'].include?('Scan') && node['Relation Name']
241
+ scans << "#{node['Node Type'].sub(' Scan', '')} on #{node['Relation Name']}"
242
+ end
243
+ Array(node['Plans']).each { |child| collect.call(child) }
244
+ end
245
+ collect.call(shape)
246
+ scans.empty? ? top : "#{top} over #{scans.uniq.join(', ')}"
247
+ end
248
+
249
+ def canonical(node)
250
+ case node
251
+ when Array then node.map { |n| canonical(n) }
252
+ when Hash then node.keys.sort.each_with_object({}) { |k, out| out[k] = canonical(node[k]) }
253
+ else node
254
+ end
255
+ end
256
+ end
257
+ end
258
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/railtie'
4
+ require_relative 'instrument/rack'
5
+ require_relative 'instrument/active_record'
6
+ require_relative 'instrument/action_controller'
7
+
8
+ module Sixty
9
+ # Rails wiring.
10
+ #
11
+ # The whole install is `gem 'sixty'` plus a key in the environment. Everything
12
+ # below happens on boot, in the order the framework allows it: the middleware
13
+ # goes on the stack, the query subscriber attaches once ActiveRecord is
14
+ # loaded, and controllers are wrapped through `on_load` so this file never
15
+ # forces a constant into existence that the application had not asked for.
16
+ #
17
+ # ── Where the middleware sits, and why it matters ─────────────────────────
18
+ #
19
+ # Second from the top, after `ActionDispatch::RequestId`, so the span covers
20
+ # essentially the whole request — including the time other middleware spends,
21
+ # which is where a surprising amount of a slow request lives (a session store
22
+ # doing a database read, a rate limiter calling Redis). Inserting it lower
23
+ # would make every one of those invisible and quietly attribute their time to
24
+ # the controller.
25
+ class Railtie < ::Rails::Railtie
26
+ config.sixty = ActiveSupport::OrderedOptions.new
27
+
28
+ initializer 'sixty.init', before: :load_config_initializers do |app|
29
+ options = app.config.sixty.to_h
30
+ state = Sixty.init(options)
31
+
32
+ if state
33
+ app.middleware.insert_after(::ActionDispatch::RequestId, Sixty::Instrument::Rack)
34
+ Sixty::Instrument::ActionController.install
35
+ end
36
+ end
37
+
38
+ # ActiveRecord is subscribed to on `:active_record` load rather than in the
39
+ # initializer above, because in a Rails app that has not touched a model yet
40
+ # ActiveRecord::Base is not loaded — and referencing it to attach a
41
+ # subscriber would eagerly load the ORM (and open a connection) in processes
42
+ # that never needed one.
43
+ initializer 'sixty.active_record' do
44
+ ActiveSupport.on_load(:active_record) do
45
+ Sixty::Instrument::ActiveRecord.install(Sixty.config) if Sixty.enabled?
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sixty
4
+ # Argument shape: what a query *is*, with none of what it is about.
5
+ #
6
+ # SECURITY BOUNDARY, and the document-database counterpart to Sixty::Sql.
7
+ #
8
+ # For SQL, identity is the statement with its literals stripped — a string
9
+ # with things taken out of it. That approach has no analogue here. A Mongo
10
+ # filter is a tree in which the values sit *beside* the keys, arbitrarily
11
+ # deep, so there is no text to lex and nothing to remove: the values are
12
+ # structurally interleaved with the only part we are allowed to keep.
13
+ #
14
+ # So this walk never removes anything. It emits keys, and a value has no path
15
+ # to the output at all — not a redaction that could miss a case, but a
16
+ # construction in which the unsafe half is unreachable. If you are tempted to
17
+ # add "just the value, when it is a small integer, for readability": don't.
18
+ # That is the change that turns a structural guarantee back into a filter with
19
+ # exceptions.
20
+ #
21
+ # The other job is bounding cardinality, exactly as SQL normalization does.
22
+ # Identity that moved with the data would make every query a new operation,
23
+ # and an operation with no history has nothing to drift against.
24
+ module Shape
25
+ # Bounds on the walk, so a pathological argument cannot produce an unbounded
26
+ # identity. Both sit far above anything a real query reaches, and the depth
27
+ # cap is also what makes a cyclic structure terminate.
28
+ MAX_DEPTH = 6
29
+ MAX_KEYS = 24
30
+
31
+ # How many stages of an ordered structure — an aggregation pipeline — count.
32
+ MAX_STAGES = 12
33
+
34
+ module_function
35
+
36
+ # The shape of one argument tree: `filter{createdAt{$gte},status}`.
37
+ #
38
+ # Keys are sorted, because two call sites writing the same query with the
39
+ # keys in a different order are the same query, and insertion order would
40
+ # make them two operations that never accumulate a shared history.
41
+ #
42
+ # Only a Hash or an Array is structure. Everything else is a *value*,
43
+ # however many attributes it happens to carry — and that is not a nicety:
44
+ # a BSON::ObjectId, a Time, a BSON::Regexp or any application object has
45
+ # internals that are not the query's structure, and walking them would put
46
+ # meaningless keys into the identity of every query that filters by `_id`,
47
+ # which is most of them.
48
+ def shape_of(value, depth = 0)
49
+ return '' if depth > MAX_DEPTH
50
+
51
+ case value
52
+ when Array
53
+ # An array contributes the shape of its first element, never its length.
54
+ # `{_id: {'$in' => [...]}}` with three ids and with three thousand is one
55
+ # operation — the same bargain SQL normalization strikes when it folds
56
+ # `in (?, ?, ?)` down to `in (?)`.
57
+ value.empty? ? '' : shape_of(value.first, depth + 1)
58
+ when Hash
59
+ keys = value.keys.sort_by(&:to_s).first(MAX_KEYS)
60
+ return '' if keys.empty?
61
+
62
+ keys.map do |key|
63
+ inner = shape_of(value[key], depth + 1)
64
+ inner.empty? ? key.to_s : "#{key}{#{inner}}"
65
+ end.join(',')
66
+ else
67
+ ''
68
+ end
69
+ end
70
+
71
+ # The shape of an *ordered* structure, where every element matters.
72
+ #
73
+ # An aggregation pipeline is a list whose entries are deliberately different
74
+ # from one another — `[{$match}, {$group}, {$sort}]` — so it is structure,
75
+ # not data, and taking only the first element the way `shape_of` does would
76
+ # collapse every pipeline in the application to the shape of whatever it
77
+ # happened to start with. `$match → $group` and `$match → $lookup → $unwind`
78
+ # would be one operation, which is the difference between a working query
79
+ # and the N+1 that replaced it.
80
+ def shape_of_sequence(values)
81
+ return '' unless values.is_a?(Array) && !values.empty?
82
+
83
+ stages = values.first(MAX_STAGES).map do |stage|
84
+ inner = shape_of(stage, 1)
85
+ inner.empty? ? '[]' : "[#{inner}]"
86
+ end
87
+ stages << '[…]' if values.length > MAX_STAGES
88
+ stages.join
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+
5
+ module Sixty
6
+ # DDSketch — quantile sketch with *relative* error guarantees.
7
+ #
8
+ # A byte-for-byte port of packages/core/src/sketch.js. The format is the
9
+ # contract between every agent and the collector: the collector decodes these
10
+ # blobs with the JavaScript implementation, merges them across releases, and
11
+ # computes the percentiles a finding is made of. A Ruby sketch that decoded
12
+ # into slightly different buckets would not error anywhere — it would produce
13
+ # a p95 that quietly disagrees with the one a Node service in the same org
14
+ # reports, which is the kind of wrong this project exists to avoid.
15
+ #
16
+ # So the header layout, the varint encoding and the bucket indexing are
17
+ # reproduced exactly rather than reimplemented idiomatically, and
18
+ # test/sketch_test.rb checks the bytes against fixtures generated by the
19
+ # JavaScript writer.
20
+ #
21
+ # Why a sketch at all: latency is heavy-tailed and multimodal. A mean moves
22
+ # too little to detect a real p95 regression, and merges of DDSketches are
23
+ # lossless — which is what lets the collector store one sketch per 5-minute
24
+ # bucket and union arbitrary windows without the numbers drifting.
25
+ class Sketch
26
+ MAGIC = 0xd5
27
+ VERSION = 1
28
+ MIN_VALUE = 1e-9
29
+ # 1 magic + 1 version + 5 doubles + 1 flag.
30
+ HEADER_BYTES = 43
31
+
32
+ attr_reader :alpha, :count, :sum, :zero_count, :buckets
33
+
34
+ # @param alpha [Float] relative accuracy, e.g. 0.01 for 1%
35
+ def initialize(alpha = 0.01)
36
+ @alpha = alpha
37
+ @gamma = (1 + alpha) / (1 - alpha)
38
+ @log_gamma = Math.log(@gamma)
39
+ @buckets = {}
40
+ @zero_count = 0
41
+ @count = 0
42
+ @sum = 0.0
43
+ @min = Float::INFINITY
44
+ @max = -Float::INFINITY
45
+ end
46
+
47
+ def add(value, count = 1)
48
+ value = value.to_f
49
+ return self if value.nan? || value.infinite? || count <= 0
50
+
51
+ value = 0.0 if value.negative?
52
+
53
+ @count += count
54
+ @sum += value * count
55
+ @min = value if value < @min
56
+ @max = value if value > @max
57
+
58
+ if value < MIN_VALUE
59
+ @zero_count += count
60
+ return self
61
+ end
62
+
63
+ i = index(value)
64
+ @buckets[i] = (@buckets[i] || 0) + count
65
+ self
66
+ end
67
+
68
+ # Lossless when both sketches share alpha. Mutates self.
69
+ def merge(other)
70
+ return self if other.nil? || other.count.zero?
71
+ raise ArgumentError, "cannot merge sketches with different alpha" if (other.alpha - @alpha).abs > 1e-12
72
+
73
+ other.buckets.each { |i, c| @buckets[i] = (@buckets[i] || 0) + c }
74
+ @zero_count += other.zero_count
75
+ @count += other.count
76
+ @sum += other.sum
77
+ @min = other.min if other.min < @min
78
+ @max = other.max if other.max > @max
79
+ self
80
+ end
81
+
82
+ def quantile(q)
83
+ return nil if @count.zero?
84
+ return @min if q <= 0
85
+ return @max if q >= 1
86
+
87
+ rank = q * (@count - 1)
88
+ return 0.0 if rank < @zero_count
89
+
90
+ acc = @zero_count
91
+ @buckets.keys.sort.each do |i|
92
+ acc += @buckets[i]
93
+ # Clamped to observed bounds: a bucket's nominal value can fall slightly
94
+ # outside [min, max], and a p99 above any observed value reads as a bug.
95
+ return [[value_at(i), @min].max, @max].min if acc > rank
96
+ end
97
+ @max
98
+ end
99
+
100
+ def mean
101
+ @count.zero? ? nil : @sum / @count
102
+ end
103
+
104
+ # Compact binary form: header + zigzag-varint delta-encoded buckets.
105
+ def to_binary
106
+ indices = @buckets.keys.sort
107
+ body = +''.b
108
+ prev = 0
109
+ indices.each do |i|
110
+ write_varint(body, zigzag(i - prev))
111
+ write_varint(body, @buckets[i].round)
112
+ prev = i
113
+ end
114
+
115
+ header = [MAGIC, VERSION].pack('C2')
116
+ # 'E' is an IEEE-754 double, little-endian — the same layout DataView
117
+ # writes with littleEndian = true.
118
+ header += [@alpha, @zero_count.to_f, @sum.to_f,
119
+ @count.zero? ? 0.0 : @min.to_f,
120
+ @count.zero? ? 0.0 : @max.to_f].pack('E5')
121
+ header += [indices.empty? ? 0 : 1].pack('C')
122
+ header + body
123
+ end
124
+
125
+ # Base64 of the binary form, which is how the agent ships it over JSON.
126
+ def to_base64
127
+ Base64.strict_encode64(to_binary)
128
+ end
129
+
130
+ # Only the tests decode; the agent is write-only. Kept here anyway because a
131
+ # codec whose two halves live in different languages is a codec nobody can
132
+ # check locally.
133
+ def self.from_binary(bytes)
134
+ bytes = bytes.b
135
+ return new if bytes.bytesize < HEADER_BYTES
136
+ raise ArgumentError, 'not a DDSketch buffer' unless bytes.getbyte(0) == MAGIC
137
+
138
+ version = bytes.getbyte(1)
139
+ raise ArgumentError, "unsupported sketch version #{version}" unless version == VERSION
140
+
141
+ alpha, zero_count, sum, min, max = bytes.byteslice(2, 40).unpack('E5')
142
+ sketch = new(alpha)
143
+ sketch.instance_variable_set(:@zero_count, zero_count)
144
+ sketch.instance_variable_set(:@sum, sum)
145
+
146
+ offset = HEADER_BYTES
147
+ total = zero_count
148
+ prev = 0
149
+ while offset < bytes.bytesize
150
+ delta, offset = read_varint(bytes, offset)
151
+ count, offset = read_varint(bytes, offset)
152
+ i = prev + unzigzag(delta)
153
+ sketch.buckets[i] = (sketch.buckets[i] || 0) + count
154
+ total += count
155
+ prev = i
156
+ end
157
+
158
+ sketch.instance_variable_set(:@count, total)
159
+ sketch.instance_variable_set(:@min, total.positive? ? min : Float::INFINITY)
160
+ sketch.instance_variable_set(:@max, total.positive? ? max : -Float::INFINITY)
161
+ sketch
162
+ end
163
+
164
+ protected
165
+
166
+ attr_reader :min, :max
167
+
168
+ private
169
+
170
+ def index(value)
171
+ (Math.log(value) / @log_gamma).ceil
172
+ end
173
+
174
+ def value_at(i)
175
+ (2 * (@gamma**i)) / (@gamma + 1)
176
+ end
177
+
178
+ def zigzag(n)
179
+ n >= 0 ? n * 2 : -n * 2 - 1
180
+ end
181
+
182
+ def write_varint(out, n)
183
+ n = [0, n.round].max
184
+ while n >= 0x80
185
+ out << ((n & 0x7f) | 0x80).chr
186
+ n /= 128
187
+ end
188
+ out << n.chr
189
+ end
190
+
191
+ def self.unzigzag(n)
192
+ n.even? ? n / 2 : -(n + 1) / 2
193
+ end
194
+
195
+ def self.read_varint(bytes, offset)
196
+ result = 0
197
+ shift = 1
198
+ loop do
199
+ b = bytes.getbyte(offset)
200
+ offset += 1
201
+ result += (b & 0x7f) * shift
202
+ break if (b & 0x80).zero?
203
+
204
+ shift *= 128
205
+ end
206
+ [result, offset]
207
+ end
208
+
209
+ private_class_method :unzigzag, :read_varint
210
+ end
211
+ end