-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathdictionary.rb
More file actions
224 lines (182 loc) · 5.05 KB
/
Copy pathdictionary.rb
File metadata and controls
224 lines (182 loc) · 5.05 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
require 'redis'
require 'octokit'
require 'base64'
require 'json'
require 'csv'
require_relative 'errors'
$redis = Redis.new(
:url => ENV['DATABASE_URL'],
:reconnect_attempts => 3,
:reconnect_delay => 1.0,
:reconnect_delay_max => 2.0,
)
class Dictionary
PATH = 'acronyms.csv'.freeze
ACRONYM = 'acronym'.freeze
DEFINITION = 'definition'.freeze
HEADERS = [ACRONYM, DEFINITION].freeze
RETRY_DELAY = 1.5.freeze
RETRY_DELAY_MAX = 10.0.freeze
LOCK_TTL = 30000.freeze
UNLOCK_SCRIPT = <<-LUA.freeze
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
LUA
attr_reader :name, :repo, :default_branch, :webhook_secret
def initialize(name, repo, access_token, webhook_secret, default_branch)
@name = name
@repo = repo
@access_token = access_token
@webhook_secret = webhook_secret
@default_branch = default_branch || 'main'
retrieve_from_github_if_needed
end
def lookup(acronym)
raise DictionaryNotLoadedError unless file_sha
entries = $redis.lrange(acronym_cache_key(acronym), 0, -1)
entries.map { |entry| JSON.parse(entry) }
end
def define(acronym, definition, author)
with_lock(retries: 1) do
array = to_a << { ACRONYM => acronym, DEFINITION => definition }
sorted = array.sort_by { |entry| [entry[ACRONYM], entry[DEFINITION]] }
csv = hashes_to_csv_string(sorted)
create_pull_request(csv, acronym, definition, author)
end
end
def refresh!
with_lock(retries: 10) do
clear_cache
retrieve_from_github
end
end
def to_a
entries = []
with_lock do
$redis.smembers(defined_keys_cache_key).each do |key|
entries += $redis.lrange(acronym_cache_key(key), 0, 1)
end
end
entries.map { |entry| JSON.parse(entry) }
end
def to_csv
hashes_to_csv_string(to_a)
end
private
def github
@github ||= Octokit::Client.new(:access_token => @access_token)
end
def file_sha
$redis.get(file_sha_cache_key)
end
def head_sha
$redis.get(head_sha_cache_key)
end
def clear_cache
$redis.del(file_sha_cache_key)
$redis.del(head_sha_cache_key)
$redis.smembers(defined_keys_cache_key).each do |key|
$redis.del(acronym_cache_key(key))
end
$redis.del(defined_keys_cache_key)
end
def retrieve_from_github_if_needed
return false if file_sha
with_lock do
retrieve_from_github
end
rescue CouldNotObtainDatabaseLock
false
end
def retrieve_from_github
file = github.contents(@repo, :path => PATH)
csv = Base64.decode64(file.content).force_encoding(Encoding::UTF_8)
without_headers = csv.split("\n")[1..-1].join("\n")
rows = CSV.parse(without_headers, headers: HEADERS)
rows.each do |row|
key = compare_string(row[ACRONYM])
value = row.to_h.slice(*HEADERS).to_json
$redis.sadd(defined_keys_cache_key, key)
$redis.lpush(acronym_cache_key(key), value)
end
$redis.set(head_sha_cache_key, github.ref(@repo, "heads/#{@default_branch}").object.sha)
$redis.set(file_sha_cache_key, file.sha)
end
def create_pull_request(content, acronym, definition, author)
message = "#{author}'s definition of #{acronym}"
timestamp = Time.now.strftime('%Y%m%dT%H%M%S')
branch = "define-#{compare_string(acronym)}-#{timestamp}"
github.create_ref(@repo, "refs/heads/#{branch}", head_sha)
github.update_contents(@repo, PATH, message, file_sha, content, :branch => branch)
github.create_pull_request(@repo, @default_branch, branch, message, definition)
end
def with_lock(retries: 0)
lock_tries = 0
begin
lock = obtain_lock
result = yield
release_lock if lock
result
rescue CouldNotObtainDatabaseLock
raise if (lock_tries += 1) > retries
sleep [RETRY_DELAY * 2**(lock_tries - 1), RETRY_DELAY_MAX].min
retry
end
end
def obtain_lock
return false if @lock
@lock = get_unique_lock_id
$redis.call([:set, cache_lock_key, @lock, :nx, :px, LOCK_TTL])
rescue
raise CouldNotObtainDatabaseLock
end
def release_lock
$redis.call([:eval, UNLOCK_SCRIPT, 1, cache_lock_key, @lock])
rescue
false
ensure
@lock = nil
end
def get_unique_lock_id
val = ''
bytes = urandom.read(20)
bytes.each_byte do |b|
val << b.to_s(32)
end
val
end
def compare_string(acronym)
acronym.gsub(/[\W_]/, '').downcase
end
def cache_key_prefix
"wtf:dictionary:#{compare_string(@name)}"
end
def cache_lock_key
"#{cache_key_prefix}:lock"
end
def file_sha_cache_key
"#{cache_key_prefix}:file_sha"
end
def head_sha_cache_key
"#{cache_key_prefix}:head_sha"
end
def defined_keys_cache_key
"#{cache_key_prefix}:defined_keys"
end
def acronym_cache_key(acronym)
"#{cache_key_prefix}:acronyms:#{compare_string(acronym)}"
end
def urandom
@urandom ||= File.new('/dev/urandom')
end
def hashes_to_csv_string(hashes)
lines = [HEADERS.to_csv]
hashes.each do |hash|
lines << [hash[ACRONYM], hash[DEFINITION]].to_csv
end
lines.join('')
end
end