forest_admin_datasource_intercom 1.49.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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/LICENSE +202 -0
- data/README.md +811 -0
- data/Rakefile +6 -0
- data/exe/forest_admin_intercom_probe +287 -0
- data/forest_admin_datasource_intercom.gemspec +38 -0
- data/lib/forest_admin_datasource_intercom/cache.rb +84 -0
- data/lib/forest_admin_datasource_intercom/client.rb +546 -0
- data/lib/forest_admin_datasource_intercom/collections/admin.rb +83 -0
- data/lib/forest_admin_datasource_intercom/collections/base_collection.rb +161 -0
- data/lib/forest_admin_datasource_intercom/collections/company/serializer.rb +44 -0
- data/lib/forest_admin_datasource_intercom/collections/company.rb +69 -0
- data/lib/forest_admin_datasource_intercom/collections/contact/serializer.rb +106 -0
- data/lib/forest_admin_datasource_intercom/collections/contact.rb +266 -0
- data/lib/forest_admin_datasource_intercom/collections/contact_identity.rb +77 -0
- data/lib/forest_admin_datasource_intercom/collections/conversation/serializer.rb +75 -0
- data/lib/forest_admin_datasource_intercom/collections/conversation/timeline.rb +40 -0
- data/lib/forest_admin_datasource_intercom/collections/conversation.rb +153 -0
- data/lib/forest_admin_datasource_intercom/collections/cursor_collection.rb +363 -0
- data/lib/forest_admin_datasource_intercom/collections/custom_attributes.rb +55 -0
- data/lib/forest_admin_datasource_intercom/collections/fetch_all_collection.rb +196 -0
- data/lib/forest_admin_datasource_intercom/collections/offset_collection.rb +311 -0
- data/lib/forest_admin_datasource_intercom/collections/records_by_id.rb +97 -0
- data/lib/forest_admin_datasource_intercom/collections/relations.rb +294 -0
- data/lib/forest_admin_datasource_intercom/collections/team.rb +70 -0
- data/lib/forest_admin_datasource_intercom/collections/team_membership.rb +51 -0
- data/lib/forest_admin_datasource_intercom/collections/ticket/derived_columns.rb +130 -0
- data/lib/forest_admin_datasource_intercom/collections/ticket/serializer.rb +70 -0
- data/lib/forest_admin_datasource_intercom/collections/ticket.rb +143 -0
- data/lib/forest_admin_datasource_intercom/collections/ticket_state.rb +40 -0
- data/lib/forest_admin_datasource_intercom/collections/ticket_type.rb +46 -0
- data/lib/forest_admin_datasource_intercom/collections/timeline.rb +40 -0
- data/lib/forest_admin_datasource_intercom/configuration.rb +226 -0
- data/lib/forest_admin_datasource_intercom/datasource.rb +78 -0
- data/lib/forest_admin_datasource_intercom/pagination/cursor_walker.rb +123 -0
- data/lib/forest_admin_datasource_intercom/query/caller_zone.rb +48 -0
- data/lib/forest_admin_datasource_intercom/query/condition_tree_translator.rb +169 -0
- data/lib/forest_admin_datasource_intercom/query/day_bounds.rb +75 -0
- data/lib/forest_admin_datasource_intercom/query/filter_value.rb +177 -0
- data/lib/forest_admin_datasource_intercom/query/operator_table.rb +54 -0
- data/lib/forest_admin_datasource_intercom/query/search_fields.rb +225 -0
- data/lib/forest_admin_datasource_intercom/query/search_fields.yml +663 -0
- data/lib/forest_admin_datasource_intercom/rate_limiter.rb +148 -0
- data/lib/forest_admin_datasource_intercom/retry_policy.rb +95 -0
- data/lib/forest_admin_datasource_intercom/schema/attribute_naming.rb +52 -0
- data/lib/forest_admin_datasource_intercom/schema/data_attributes_introspector.rb +88 -0
- data/lib/forest_admin_datasource_intercom/schema/ticket_attributes_introspector.rb +133 -0
- data/lib/forest_admin_datasource_intercom/throttle.rb +19 -0
- data/lib/forest_admin_datasource_intercom/version.rb +3 -0
- data/lib/forest_admin_datasource_intercom.rb +57 -0
- metadata +180 -0
data/Rakefile
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Enumerates what Intercom's search endpoints really filter, and with which
|
|
5
|
+
# operators, against a real workspace. The answer is what `search_fields.yml`
|
|
6
|
+
# holds and what the schema derives its filters from -- and it cannot be read
|
|
7
|
+
# off the documentation: measured during lot 1, `/tickets/search` refuses
|
|
8
|
+
# `company_id` with `invalid_field` although a ticket carries one.
|
|
9
|
+
#
|
|
10
|
+
# INTERCOM_ACCESS_TOKEN=... forest_admin_intercom_probe --endpoint tickets
|
|
11
|
+
# INTERCOM_ACCESS_TOKEN=... forest_admin_intercom_probe --region eu --out measured.yml
|
|
12
|
+
#
|
|
13
|
+
# Shipped with the gem rather than left in the repository: what it measures is
|
|
14
|
+
# the customer's workspace, and the operator running it has the gem installed
|
|
15
|
+
# and not a clone of the agent.
|
|
16
|
+
#
|
|
17
|
+
# It sends one search per (field, operator) cell, asking for a single record,
|
|
18
|
+
# and reads Intercom's refusal codes: `invalid_field` for a field the endpoint
|
|
19
|
+
# does not filter at all -- the rest of its row is then skipped -- `data_invalid`
|
|
20
|
+
# for an operator it refuses on that field, `invalid_value` for a value shape it
|
|
21
|
+
# refuses. Read-only: a search changes nothing, and no payload is printed or
|
|
22
|
+
# written, only field names, operators and error codes.
|
|
23
|
+
#
|
|
24
|
+
# What it writes with `--out` is *evidence*, deliberately not the table itself:
|
|
25
|
+
# `search_fields.yml` carries the prose that says why a column stays refused,
|
|
26
|
+
# and a generated file would drop it. Merge the measurements into the table by
|
|
27
|
+
# hand, which is also where deciding to expose a newly discovered field belongs.
|
|
28
|
+
|
|
29
|
+
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
|
30
|
+
|
|
31
|
+
require 'forest_admin_datasource_intercom'
|
|
32
|
+
require 'optparse'
|
|
33
|
+
|
|
34
|
+
module ProbeSearchFields
|
|
35
|
+
SearchFields = ForestAdminDatasourceIntercom::Query::SearchFields
|
|
36
|
+
|
|
37
|
+
# Whether this file is being run as the command rather than loaded by a
|
|
38
|
+
# spec. The basenames and not the paths: RubyGems installs an executable
|
|
39
|
+
# behind a stub of its own and `load`s this file from it, so `$PROGRAM_NAME`
|
|
40
|
+
# is the stub while `__FILE__` is this file -- comparing the two would make
|
|
41
|
+
# the shipped command exit without doing anything, which is the one thing
|
|
42
|
+
# shipping it was for. A spec loading it runs under `rspec`, whose basename
|
|
43
|
+
# is not this one.
|
|
44
|
+
def self.invoked_as_command?(program_name)
|
|
45
|
+
File.basename(program_name.to_s) == File.basename(__FILE__)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A cell is probed with the value shapes its field plausibly takes, most
|
|
49
|
+
# likely first: a wrong shape is refused with `data_invalid` just like an
|
|
50
|
+
# unsupported operator, so a single attempt would report an operator as
|
|
51
|
+
# refused when only the value was wrong.
|
|
52
|
+
VALUES = {
|
|
53
|
+
'date' => ['1', 1, '2026-01-01'],
|
|
54
|
+
'number' => [0, '0'],
|
|
55
|
+
'boolean' => [true, 'true'],
|
|
56
|
+
'string' => ['forest-probe', 0],
|
|
57
|
+
'text' => ['forest-probe']
|
|
58
|
+
}.freeze
|
|
59
|
+
|
|
60
|
+
LIST_OPERATORS = %w[IN NIN].freeze
|
|
61
|
+
|
|
62
|
+
# What Intercom answers a search it refuses: `invalid_field`, `data_invalid`
|
|
63
|
+
# and `invalid_value` all travel on a 400.
|
|
64
|
+
REFUSAL_STATUSES = [400, 422].freeze
|
|
65
|
+
|
|
66
|
+
# Raised when an answer measures nothing, so the run stops before writing.
|
|
67
|
+
class Inconclusive < StandardError; end
|
|
68
|
+
|
|
69
|
+
# A candidate carries no declared type -- discovering it is the point -- so
|
|
70
|
+
# one is guessed from its name, and the fallbacks above cover a wrong guess.
|
|
71
|
+
def self.guess_type(field)
|
|
72
|
+
case field
|
|
73
|
+
when /(_at|_since|_until)\z/ then 'date'
|
|
74
|
+
when /\Acount_|_count\z|\Atime_to_|_time\z/ then 'number'
|
|
75
|
+
when /\A(open|read|is_|has_|.*_participated)/ then 'boolean'
|
|
76
|
+
else 'string'
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
class Probe
|
|
81
|
+
def initialize(client, endpoint)
|
|
82
|
+
@client = client
|
|
83
|
+
@endpoint = endpoint
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Every operator of the alphabet on every field of the row: what the table
|
|
87
|
+
# already declares, plus the candidates. Nothing is assumed from the
|
|
88
|
+
# declared operators -- an operator missing from the table is exactly what
|
|
89
|
+
# this is here to find.
|
|
90
|
+
def run(field, type)
|
|
91
|
+
results = {}
|
|
92
|
+
|
|
93
|
+
SearchFields::KNOWN_OPERATORS.each do |operator|
|
|
94
|
+
outcome = probe(field, operator, type)
|
|
95
|
+
return { unfilterable: outcome } if outcome[:code] == 'invalid_field'
|
|
96
|
+
|
|
97
|
+
results[operator] = outcome
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
{ operators: results }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def probe(field, operator, type)
|
|
106
|
+
last = nil
|
|
107
|
+
|
|
108
|
+
VALUES.fetch(type, VALUES['string']).each do |value|
|
|
109
|
+
outcome = attempt(field, operator, operator_value(operator, value))
|
|
110
|
+
# `invalid_field` is about the field, not about what was sent with it:
|
|
111
|
+
# another value shape cannot make a field the endpoint does not filter
|
|
112
|
+
# appear, so the row ends here rather than paying a request per shape.
|
|
113
|
+
return outcome if outcome[:ok] || outcome[:code] == 'invalid_field'
|
|
114
|
+
|
|
115
|
+
last = outcome
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
last
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Only an answer about the search itself is a measurement. A 401, a 429
|
|
122
|
+
# that outlasted the retries, a 5xx or a timeout says nothing about the
|
|
123
|
+
# field, and recorded as a refusal it would be wrong evidence.
|
|
124
|
+
def attempt(field, operator, value)
|
|
125
|
+
@client.search_page(@endpoint.path, query: { 'field' => field, 'operator' => operator, 'value' => value },
|
|
126
|
+
per_page: 1, list_key: list_key)
|
|
127
|
+
{ ok: true }
|
|
128
|
+
rescue ForestAdminDatasourceIntercom::APIError => e
|
|
129
|
+
raise Inconclusive, inconclusive_message(field, operator, e) unless REFUSAL_STATUSES.include?(e.status)
|
|
130
|
+
|
|
131
|
+
{ ok: false, code: error_code(e), status: e.status }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def inconclusive_message(field, operator, error)
|
|
135
|
+
"#{@endpoint.path} answered #{field} #{operator} with #{error.status ? "HTTP #{error.status}" : "no status"}, " \
|
|
136
|
+
'which says nothing about the field. Nothing was written; check the token and the region, then run ' \
|
|
137
|
+
'the probe again.'
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def operator_value(operator, value)
|
|
141
|
+
LIST_OPERATORS.include?(operator) ? [value] : value
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# `/tickets/search` answers under `tickets` and `/conversations/search`
|
|
145
|
+
# under `conversations`; a body read under the wrong key raises before the
|
|
146
|
+
# outcome is classified.
|
|
147
|
+
def list_key = @endpoint.name
|
|
148
|
+
|
|
149
|
+
def error_code(error)
|
|
150
|
+
errors = error.body.is_a?(Hash) ? error.body['errors'] : nil
|
|
151
|
+
first = Array(errors).first
|
|
152
|
+
|
|
153
|
+
(first.is_a?(Hash) ? first['code'] : nil) || "http_#{error.status}"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
class Report
|
|
158
|
+
def initialize(endpoint)
|
|
159
|
+
@endpoint = endpoint
|
|
160
|
+
@rows = {}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def record(field, column, result)
|
|
164
|
+
@rows[field] = { 'column' => column, 'result' => result }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# What the run says about the committed table, which is the only reason to
|
|
168
|
+
# run it: an operator the table promises and Intercom refuses is a filter
|
|
169
|
+
# the interface offers and the read cannot honour.
|
|
170
|
+
def print_diff
|
|
171
|
+
@rows.each do |field, row|
|
|
172
|
+
column = row['column']
|
|
173
|
+
result = row['result']
|
|
174
|
+
|
|
175
|
+
if result[:unfilterable]
|
|
176
|
+
declared = column ? " <- table declares it on column '#{column}'" : ''
|
|
177
|
+
puts " #{field.ljust(42)} NOT FILTERABLE (#{result[:unfilterable][:code]})#{declared}"
|
|
178
|
+
next
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
print_operators(field, column, result[:operators])
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def to_yaml_document
|
|
186
|
+
{ 'endpoint' => @endpoint.name, 'path' => @endpoint.path, 'measured_at' => Time.now.utc.strftime('%Y-%m-%d'),
|
|
187
|
+
'fields' => @rows.to_h { |field, row| [field, measured_row(row)] } }.to_yaml
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
private
|
|
191
|
+
|
|
192
|
+
def print_operators(field, column, outcomes)
|
|
193
|
+
supported = outcomes.select { |_, outcome| outcome[:ok] }.keys
|
|
194
|
+
declared = column ? Array(@endpoint.field(column)&.operators) : []
|
|
195
|
+
puts " #{field.ljust(42)} #{supported.empty? ? "(no operator answered)" : supported.join(" ")}"
|
|
196
|
+
|
|
197
|
+
report_drift(declared - supported, supported - declared)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def report_drift(promised, discovered)
|
|
201
|
+
puts " ! the table promises #{promised.join(", ")} here, Intercom refuses it" unless promised.empty?
|
|
202
|
+
puts " + Intercom also accepts #{discovered.join(", ")}" unless discovered.empty?
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def measured_row(row)
|
|
206
|
+
result = row['result']
|
|
207
|
+
return { 'filterable' => false, 'code' => result[:unfilterable][:code] } if result[:unfilterable]
|
|
208
|
+
|
|
209
|
+
{ 'filterable' => true,
|
|
210
|
+
'operators' => result[:operators].select { |_, outcome| outcome[:ok] }.keys,
|
|
211
|
+
'refused' => result[:operators].reject { |_, outcome| outcome[:ok] }
|
|
212
|
+
.transform_values { |outcome| outcome[:code] } }
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
class CLI
|
|
217
|
+
def self.call(argv)
|
|
218
|
+
new(options(argv)).run
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def self.options(argv)
|
|
222
|
+
options = { endpoints: SearchFields.endpoints, region: 'us', token: ENV.fetch('INTERCOM_ACCESS_TOKEN', nil) }
|
|
223
|
+
|
|
224
|
+
OptionParser.new do |parser|
|
|
225
|
+
parser.banner = 'Usage: INTERCOM_ACCESS_TOKEN=... forest_admin_intercom_probe [options]'
|
|
226
|
+
parser.on('--endpoint NAME', "one of #{SearchFields.endpoints.join(", ")}") { |v| options[:endpoints] = [v] }
|
|
227
|
+
parser.on('--region NAME', 'us (default), eu or au') { |v| options[:region] = v }
|
|
228
|
+
parser.on('--token TOKEN', 'defaults to $INTERCOM_ACCESS_TOKEN') { |v| options[:token] = v }
|
|
229
|
+
parser.on('--out PATH', 'write the measurements as YAML evidence') { |v| options[:out] = v }
|
|
230
|
+
end.parse!(argv)
|
|
231
|
+
|
|
232
|
+
options
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def initialize(options)
|
|
236
|
+
@options = options
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def run
|
|
240
|
+
abort('No token: set INTERCOM_ACCESS_TOKEN or pass --token.') if @options[:token].to_s.empty?
|
|
241
|
+
|
|
242
|
+
documents = @options[:endpoints].map { |name| probe_endpoint(SearchFields.fetch(name)) }
|
|
243
|
+
write(documents) if @options[:out]
|
|
244
|
+
0
|
|
245
|
+
rescue Inconclusive => e
|
|
246
|
+
abort("Probe stopped: #{e.message}")
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
private
|
|
250
|
+
|
|
251
|
+
def probe_endpoint(endpoint)
|
|
252
|
+
puts "\n#{endpoint.path} -- #{endpoint.fields.size} declared field(s), #{endpoint.candidates.size} candidate(s)"
|
|
253
|
+
probe = Probe.new(client, endpoint)
|
|
254
|
+
report = Report.new(endpoint)
|
|
255
|
+
|
|
256
|
+
targets(endpoint).each { |field, column, type| report.record(field, column, probe.run(field, type)) }
|
|
257
|
+
report.print_diff
|
|
258
|
+
report.to_yaml_document
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# The declared fields first, with the type the table gives them; then the
|
|
262
|
+
# candidates, whose type is guessed. A candidate already covered by a
|
|
263
|
+
# declared field is not probed twice.
|
|
264
|
+
def targets(endpoint)
|
|
265
|
+
declared = endpoint.fields.values.map { |field| [field.field, field.column, field.type] }
|
|
266
|
+
candidates = (endpoint.candidates - declared.map(&:first)).map { |field| [field, nil, guess_type(field)] }
|
|
267
|
+
|
|
268
|
+
declared + candidates
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def guess_type(field) = ProbeSearchFields.guess_type(field)
|
|
272
|
+
|
|
273
|
+
def client
|
|
274
|
+
@client ||= ForestAdminDatasourceIntercom::Client.new(
|
|
275
|
+
ForestAdminDatasourceIntercom::Configuration.new(access_token: @options[:token], region: @options[:region])
|
|
276
|
+
)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def write(documents)
|
|
280
|
+
File.write(@options[:out], documents.join("\n"))
|
|
281
|
+
puts "\nWritten to #{@options[:out]}. Merge the measurements into search_fields.yml by hand: " \
|
|
282
|
+
'the table carries the prose a generated file would drop.'
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
exit(ProbeSearchFields::CLI.call(ARGV)) if ProbeSearchFields.invoked_as_command?($PROGRAM_NAME)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
lib = File.expand_path('lib', __dir__)
|
|
2
|
+
$LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib)
|
|
3
|
+
|
|
4
|
+
require_relative 'lib/forest_admin_datasource_intercom/version'
|
|
5
|
+
|
|
6
|
+
Gem::Specification.new do |spec|
|
|
7
|
+
spec.name = 'forest_admin_datasource_intercom'
|
|
8
|
+
spec.version = ForestAdminDatasourceIntercom::VERSION
|
|
9
|
+
spec.authors = ['Forest Admin']
|
|
10
|
+
spec.email = ['contact@forestadmin.com']
|
|
11
|
+
spec.homepage = 'https://www.forestadmin.com'
|
|
12
|
+
spec.summary = 'Intercom datasource for Forest Admin Ruby agent.'
|
|
13
|
+
spec.description = 'Surface Intercom conversations, tickets, contacts and companies as Forest Admin collections.'
|
|
14
|
+
spec.license = 'Apache-2.0'
|
|
15
|
+
spec.required_ruby_version = '>= 3.0.0'
|
|
16
|
+
|
|
17
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
18
|
+
spec.metadata['source_code_uri'] = 'https://github.com/ForestAdmin/agent-ruby'
|
|
19
|
+
spec.metadata['changelog_uri'] = 'https://github.com/ForestAdmin/agent-ruby/blob/main/CHANGELOG.md'
|
|
20
|
+
spec.metadata['rubygems_mfa_required'] = 'false'
|
|
21
|
+
|
|
22
|
+
spec.files = Dir.chdir(__dir__) do
|
|
23
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
24
|
+
(File.expand_path(f) == __FILE__) ||
|
|
25
|
+
f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile])
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
spec.bindir = 'exe'
|
|
29
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
30
|
+
spec.require_paths = ['lib']
|
|
31
|
+
|
|
32
|
+
spec.add_dependency 'activesupport', '>= 6.1'
|
|
33
|
+
spec.add_dependency 'faraday', '~> 2.0'
|
|
34
|
+
spec.add_dependency 'faraday-net_http_persistent', '~> 2.0'
|
|
35
|
+
spec.add_dependency 'faraday-retry', '~> 2.0'
|
|
36
|
+
spec.add_dependency 'forest_admin_datasource_toolkit', ">= #{ForestAdminDatasourceIntercom::VERSION}"
|
|
37
|
+
spec.add_dependency 'zeitwerk', '~> 2.3'
|
|
38
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
module ForestAdminDatasourceIntercom
|
|
2
|
+
# TTL store for workspace reference reads (teammates, teams, ticket types, ticket states), re-read
|
|
3
|
+
# on every page otherwise. Records are never cached here; see `Client#with_read_scope` for per-page dedup.
|
|
4
|
+
class Cache
|
|
5
|
+
Entry = Struct.new(:value, :expires_at)
|
|
6
|
+
|
|
7
|
+
# Monotonic rather than wall clock: this measures an elapsed span, and a
|
|
8
|
+
# host whose clock steps -- an NTP correction, a suspend -- would otherwise
|
|
9
|
+
# expire every entry at once or hold them all far past the window.
|
|
10
|
+
def initialize(ttl:, now: nil)
|
|
11
|
+
@ttl = ttl.to_f
|
|
12
|
+
@now = now || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
@entries = {}
|
|
15
|
+
# Bumped by `clear`, and carried by every read that is about to fetch, so
|
|
16
|
+
# a response already in flight when the store was dropped cannot land in
|
|
17
|
+
# it afterwards. See `fetch`.
|
|
18
|
+
@generation = 0
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def enabled? = @ttl.positive?
|
|
22
|
+
|
|
23
|
+
# The block runs outside the mutex (it is an HTTP request): two concurrent misses both read, which is
|
|
24
|
+
# cheaper than serializing every thread. A raising block writes nothing, and the generation is read
|
|
25
|
+
# before the block so a response in flight across `clear` is not stored, nor one `store` declines.
|
|
26
|
+
def fetch(key, store: -> { true })
|
|
27
|
+
return yield unless enabled?
|
|
28
|
+
|
|
29
|
+
hit = read(key)
|
|
30
|
+
return hit.value if hit
|
|
31
|
+
|
|
32
|
+
generation = @mutex.synchronize { @generation }
|
|
33
|
+
value = yield
|
|
34
|
+
write(key, value, generation) if store.call
|
|
35
|
+
value
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Drops everything held, without waiting the window out. Nothing inside this
|
|
39
|
+
# package calls it: the window is what expires an entry, and no read here
|
|
40
|
+
# writes. It is reachable through `Configuration#reference_cache` for the
|
|
41
|
+
# one case the window cannot serve -- a customizer that has just written a
|
|
42
|
+
# team or a ticket type into the workspace and wants the next page to show
|
|
43
|
+
# it. Documented in the README beside the ttl.
|
|
44
|
+
def clear
|
|
45
|
+
@mutex.synchronize do
|
|
46
|
+
@entries.clear
|
|
47
|
+
@generation += 1
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def read(key)
|
|
54
|
+
@mutex.synchronize do
|
|
55
|
+
entry = @entries[key]
|
|
56
|
+
next nil if entry.nil?
|
|
57
|
+
|
|
58
|
+
if entry.expires_at <= @now.call
|
|
59
|
+
@entries.delete(key)
|
|
60
|
+
next nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
entry
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Expired entries are dropped on the way past rather than by a sweeper: the
|
|
68
|
+
# keys are a handful of endpoints, so the walk is short and there is no
|
|
69
|
+
# thread to own.
|
|
70
|
+
#
|
|
71
|
+
# A write from before a `clear` is dropped rather than stored: the store it
|
|
72
|
+
# was going to land in no longer exists.
|
|
73
|
+
def write(key, value, generation)
|
|
74
|
+
now = @now.call
|
|
75
|
+
|
|
76
|
+
@mutex.synchronize do
|
|
77
|
+
next if generation != @generation
|
|
78
|
+
|
|
79
|
+
@entries.delete_if { |_, entry| entry.expires_at <= now }
|
|
80
|
+
@entries[key] = Entry.new(value, now + @ttl)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|