runlight 0.1.0 → 0.1.3
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 +4 -4
- data/assets/build.json +4 -4
- data/assets/dashboard.css +1 -1
- data/assets/dashboard.js +71 -22
- data/assets/locales.json +5 -5
- data/assets/pick-return.js +1 -0
- data/assets/picker.js +1 -1
- data/assets/tracker.js +1 -1
- data/lib/runlight/accounts/accounts.rb +90 -19
- data/lib/runlight/accounts/throttle.rb +123 -38
- data/lib/runlight/accounts/web.rb +50 -14
- data/lib/runlight/assistant.rb +40 -8
- data/lib/runlight/connect.rb +2 -3
- data/lib/runlight/core.rb +148 -34
- data/lib/runlight/dates.rb +31 -8
- data/lib/runlight/db/connect.rb +32 -6
- data/lib/runlight/db/db.rb +129 -9
- data/lib/runlight/db/prefixed.rb +92 -0
- data/lib/runlight/geo.rb +4 -3
- data/lib/runlight/http/fetcher.rb +31 -6
- data/lib/runlight/http/request.rb +14 -3
- data/lib/runlight/importers/csv_visits.rb +1 -1
- data/lib/runlight/importers/index.rb +10 -2
- data/lib/runlight/importers/visits.rb +17 -6
- data/lib/runlight/importers/write.rb +21 -6
- data/lib/runlight/links.rb +14 -9
- data/lib/runlight/mail/ses.rb +3 -2
- data/lib/runlight/mail/smtp.rb +2 -0
- data/lib/runlight/mail/smtp_session.rb +22 -1
- data/lib/runlight/mail/transports.rb +19 -2
- data/lib/runlight/mcp.rb +8 -1
- data/lib/runlight/metadata_address_error.rb +13 -0
- data/lib/runlight/oauth.rb +85 -18
- data/lib/runlight/rack_app.rb +64 -10
- data/lib/runlight/redirect_refused_error.rb +14 -0
- data/lib/runlight/routes.rb +306 -144
- data/lib/runlight/safefetch.rb +133 -1
- data/lib/runlight/server/agents.rb +1 -1
- data/lib/runlight/server/cli.rb +1 -1
- data/lib/runlight/server/config.rb +64 -19
- data/lib/runlight/server/standalone.rb +7 -6
- data/lib/runlight/store/sql.rb +10 -0
- data/lib/runlight/store/sql_store.rb +75 -13
- data/lib/runlight/stores.rb +19 -15
- data/lib/runlight/tables_busy_error.rb +11 -0
- data/lib/runlight/version.rb +1 -1
- data/lib/runlight.rb +4 -0
- metadata +7 -2
data/lib/runlight/dates.rb
CHANGED
|
@@ -19,8 +19,10 @@ module Runlight
|
|
|
19
19
|
COMPARE_MODES = %w[previous year custom off].freeze
|
|
20
20
|
|
|
21
21
|
MAX_BUCKETS = 1000
|
|
22
|
-
# A month of hours.
|
|
22
|
+
# A month of hours. A longer range is counted by the day instead.
|
|
23
23
|
MAX_HOURS = 744
|
|
24
|
+
# The next coarser interval, for a range too long for its own.
|
|
25
|
+
COARSER = { "hour" => "day", "day" => "week", "week" => "month" }.freeze
|
|
24
26
|
|
|
25
27
|
# Names Intl reads as another zone where TZInfo has no name, or reads it differently.
|
|
26
28
|
ALIASES = {
|
|
@@ -47,7 +49,7 @@ module Runlight
|
|
|
47
49
|
}.freeze
|
|
48
50
|
|
|
49
51
|
OFFSET = /\A([+\-]|−)([01][0-9]|2[0-3])(?::?([0-5][0-9]))?\z/
|
|
50
|
-
private_constant :MAX_BUCKETS, :MAX_HOURS, :ALIASES, :OFFSET
|
|
52
|
+
private_constant :MAX_BUCKETS, :MAX_HOURS, :COARSER, :ALIASES, :OFFSET
|
|
51
53
|
|
|
52
54
|
# A zone: a TZInfo::Timezone, or the seconds of a fixed offset; false where Intl throws a RangeError.
|
|
53
55
|
@zones = {}
|
|
@@ -284,8 +286,26 @@ module Runlight
|
|
|
284
286
|
end
|
|
285
287
|
|
|
286
288
|
interval = INTERVALS.include?(input["interval"]) ? input["interval"] : default_interval(from_date, to_date)
|
|
287
|
-
|
|
288
|
-
|
|
289
|
+
from_ms = start_of(from_date, timezone)
|
|
290
|
+
to_ms = start_of(add_days(to_date, 1), timezone)
|
|
291
|
+
# An interval too fine for the range moves up to the next one that fits, and the range says which it is.
|
|
292
|
+
# Past 1,000 months there is none.
|
|
293
|
+
while bucket_count(from_ms, to_ms, from_date, to_date, interval) > (interval == "hour" ? MAX_HOURS : MAX_BUCKETS)
|
|
294
|
+
interval = COARSER[interval]
|
|
295
|
+
return nil if interval.nil?
|
|
296
|
+
end
|
|
297
|
+
{ "from" => from_ms, "to" => to_ms, "fromDate" => from_date, "toDate" => to_date, "interval" => interval }
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# How many chart buckets a range has at an interval, the first and last counted even when partial.
|
|
301
|
+
def bucket_count(from_ms, to_ms, from_date, to_date, interval)
|
|
302
|
+
return (to_ms - from_ms).fdiv(3_600_000).ceil if interval == "hour"
|
|
303
|
+
return days_between(from_date, to_date) + 1 if interval == "day"
|
|
304
|
+
return tdiv(days_between(add_days(from_date, -weekday(from_date)), to_date), 7) + 1 if interval == "week"
|
|
305
|
+
|
|
306
|
+
fy, fm = split(from_date)
|
|
307
|
+
ty, tm = split(to_date)
|
|
308
|
+
((ty - fy) * 12) + (tm - fm) + 1
|
|
289
309
|
end
|
|
290
310
|
|
|
291
311
|
def add_years(date, years)
|
|
@@ -323,12 +343,15 @@ module Runlight
|
|
|
323
343
|
"toDate" => to_date, "interval" => range["interval"] }
|
|
324
344
|
end
|
|
325
345
|
|
|
326
|
-
# Chart buckets covering a range, each starting on a local boundary
|
|
346
|
+
# Chart buckets covering a range, each starting on a local boundary and ending at the next, so only the
|
|
347
|
+
# first and last can be partial. resolve_range picks an interval that fits; a range given here with more
|
|
348
|
+
# buckets than that, such as a longer comparison, is cut off at a boundary, never merged into its last bucket.
|
|
327
349
|
def buckets(range, timezone)
|
|
328
350
|
starts = []
|
|
351
|
+
# One start past the limit, so the last bucket kept still ends where the next would begin.
|
|
329
352
|
if range["interval"] == "hour"
|
|
330
353
|
t = range["from"]
|
|
331
|
-
while t < range["to"] && starts.length
|
|
354
|
+
while t < range["to"] && starts.length <= MAX_HOURS
|
|
332
355
|
starts << t
|
|
333
356
|
t += 3_600_000
|
|
334
357
|
end
|
|
@@ -339,7 +362,7 @@ module Runlight
|
|
|
339
362
|
elsif range["interval"] == "month"
|
|
340
363
|
date = "#{date[0, 8]}01"
|
|
341
364
|
end
|
|
342
|
-
while date <= range["toDate"] && starts.length
|
|
365
|
+
while date <= range["toDate"] && starts.length <= MAX_BUCKETS
|
|
343
366
|
starts << start_of(date, timezone)
|
|
344
367
|
date = case range["interval"]
|
|
345
368
|
when "day" then add_days(date, 1)
|
|
@@ -348,7 +371,7 @@ module Runlight
|
|
|
348
371
|
end
|
|
349
372
|
end
|
|
350
373
|
end
|
|
351
|
-
starts.each_with_index.map do |start, i|
|
|
374
|
+
starts.first(range["interval"] == "hour" ? MAX_HOURS : MAX_BUCKETS).each_with_index.map do |start, i|
|
|
352
375
|
{ "start" => [start, range["from"]].max, "end" => [starts[i + 1] || range["to"], range["to"]].min }
|
|
353
376
|
end
|
|
354
377
|
end
|
data/lib/runlight/db/connect.rb
CHANGED
|
@@ -31,9 +31,15 @@ module Runlight
|
|
|
31
31
|
def sqlite(path)
|
|
32
32
|
path = path.to_s
|
|
33
33
|
memory = path == ":memory:" || path.empty?
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
file = !memory && !path.start_with?("file:")
|
|
35
|
+
# A file in a folder that is not there yet gets the folder, as a fresh app's data/ often is, for its
|
|
36
|
+
# owner and group alone, as the file is.
|
|
37
|
+
if file && !File.directory?(File.dirname(path))
|
|
38
|
+
FileUtils.mkdir_p(File.dirname(path), mode: 0o770)
|
|
39
|
+
owner_and_group(File.dirname(path), 0o770)
|
|
40
|
+
end
|
|
41
|
+
made(path) if file && !File.exist?(path)
|
|
42
|
+
config = { adapter: "sqlite3", database: memory ? ":memory:" : path, timeout: Database::BUSY_TIMEOUT_MS,
|
|
37
43
|
# One connection for a database in memory, which every connection would otherwise make anew.
|
|
38
44
|
pool: memory ? 1 : 5 }
|
|
39
45
|
Database.new(Pools.make(config), owned: true, dialect: "sqlite")
|
|
@@ -57,10 +63,11 @@ module Runlight
|
|
|
57
63
|
Database.new(Pools.make(config.compact), owned: true, dialect: "postgres")
|
|
58
64
|
end
|
|
59
65
|
|
|
60
|
-
# MySQL 8.
|
|
66
|
+
# MySQL 8.0.20 or MariaDB 11.4 and later from a URL like mysql://user:pass@host:3306/db (or mariadb://),
|
|
61
67
|
# through the trilogy gem when it is installed, else mysql2. The session is utf8mb4 with the server's own
|
|
62
68
|
# SQL mode plus IGNORE_SPACE, as mysql2 opens it for the TypeScript driver (its connect flags ask for
|
|
63
|
-
# that, which neither Ruby driver can, so it is set as the session's mode)
|
|
69
|
+
# that, which neither Ruby driver can, so it is set as the session's mode), less NO_BACKSLASH_ESCAPES, as
|
|
70
|
+
# stores/mysql.ts drops it. Runlight's tables carry their
|
|
64
71
|
# own binary collation, so text compares and sorts by code point. statement_timeout is set per
|
|
65
72
|
# connection as stores/mysql.ts sets it; 0 turns it off.
|
|
66
73
|
def mysql(url, statement_timeout: 120_000)
|
|
@@ -71,7 +78,9 @@ module Runlight
|
|
|
71
78
|
db = Database.new(Pools.make(config.compact), owned: true, statement_timeout: statement_timeout.to_i, dialect: "mysql")
|
|
72
79
|
version, mode = db.connection_class.connection_pool.with_connection { |c| c.select_rows("SELECT VERSION(), @@GLOBAL.sql_mode")[0] }
|
|
73
80
|
db.connection_class.connection_pool.disconnect!
|
|
74
|
-
|
|
81
|
+
# Less NO_BACKSLASH_ESCAPES, since values are written with backslash escapes (Database drops it from
|
|
82
|
+
# every session too).
|
|
83
|
+
variables = { sql_mode: ((mode.to_s.split(",") - ["NO_BACKSLASH_ESCAPES"]) | ["IGNORE_SPACE"]).join(",") }
|
|
75
84
|
if statement_timeout.to_i.positive?
|
|
76
85
|
variables.merge!(version.to_s.match?(/mariadb/i) ? { max_statement_time: statement_timeout.to_i / 1000.0 } : { max_execution_time: statement_timeout.to_i })
|
|
77
86
|
end
|
|
@@ -90,6 +99,23 @@ module Runlight
|
|
|
90
99
|
end
|
|
91
100
|
end
|
|
92
101
|
|
|
102
|
+
# A database file made here is for its owner and group alone (0660), whatever the umask: the visits in it
|
|
103
|
+
# are nobody else's to read, and a scheduled job running as another user of the same group can still
|
|
104
|
+
# write to it. It is made empty, which SQLite takes as a new database, before anything opens it, so the
|
|
105
|
+
# -wal and -shm files SQLite makes beside it later take the same mode.
|
|
106
|
+
def made(path)
|
|
107
|
+
File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o660) { nil }
|
|
108
|
+
owner_and_group(path, 0o660)
|
|
109
|
+
rescue SystemCallError
|
|
110
|
+
# Made by another process meanwhile, or a folder this one may not write to, which opening reports.
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def owner_and_group(path, mode)
|
|
114
|
+
File.chmod(mode, path)
|
|
115
|
+
rescue SystemCallError, NotImplementedError
|
|
116
|
+
# Not a file this process may change, or a system with no such modes.
|
|
117
|
+
end
|
|
118
|
+
|
|
93
119
|
def quote_name(name, quote)
|
|
94
120
|
"#{quote}#{name.to_s.gsub(quote, quote * 2)}#{quote}"
|
|
95
121
|
end
|
data/lib/runlight/db/db.rb
CHANGED
|
@@ -22,10 +22,38 @@ module Runlight
|
|
|
22
22
|
# Arbitrary but fixed, so every Runlight process takes the same lock to create tables.
|
|
23
23
|
MIGRATION_LOCK = 7_331_906
|
|
24
24
|
|
|
25
|
+
# The longest a process waits for another one creating or upgrading the tables before it gives up.
|
|
26
|
+
LOCK_WAIT_MS = 30_000
|
|
27
|
+
|
|
28
|
+
@lock_wait = LOCK_WAIT_MS
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
# How long the lock for creating tables is waited for: LOCK_WAIT_MS, unless a test has set another.
|
|
32
|
+
def lock_wait_ms
|
|
33
|
+
@lock_wait
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# For tests: the lock is waited for this long instead, or LOCK_WAIT_MS again with nil.
|
|
37
|
+
def lock_wait_in_tests(ms)
|
|
38
|
+
@lock_wait = ms || LOCK_WAIT_MS
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
25
42
|
# One lock per MySQL database, so installs sharing a server do not wait on each other. Lock names are 64
|
|
26
43
|
# characters at most.
|
|
27
44
|
MYSQL_LOCK = "CONCAT('runlight:', LEFT(SHA2(COALESCE(DATABASE(), ''), 256), 48))"
|
|
28
45
|
|
|
46
|
+
# The session's sql_mode as it was, less NO_BACKSLASH_ESCAPES, wherever it sits in the list, as
|
|
47
|
+
# stores/mysql.ts sets it.
|
|
48
|
+
NO_BACKSLASH_ESCAPES_OFF = "SET SESSION sql_mode = TRIM(BOTH ',' FROM REPLACE(CONCAT(',', @@SESSION.sql_mode, ','), ',NO_BACKSLASH_ESCAPES,', ','))"
|
|
49
|
+
|
|
50
|
+
# Deleted rows, such as a day's salt once it is gone, are overwritten where that costs no extra writes, so
|
|
51
|
+
# they do not linger in the file's free space. Rails' pragmas setting takes this one only as on or off.
|
|
52
|
+
SECURE_DELETE = "PRAGMA secure_delete = FAST"
|
|
53
|
+
|
|
54
|
+
# How long a SQLite connection waits for a lock another one holds, as Connect.sqlite sets it.
|
|
55
|
+
BUSY_TIMEOUT_MS = 5000
|
|
56
|
+
|
|
29
57
|
attr_reader :connection_class
|
|
30
58
|
|
|
31
59
|
# connection_class: an ActiveRecord::Base subclass whose pool to use (ActiveRecord::Base for the app's
|
|
@@ -38,11 +66,16 @@ module Runlight
|
|
|
38
66
|
@statement_timeout = statement_timeout
|
|
39
67
|
@dialect = dialect
|
|
40
68
|
@held = 0
|
|
69
|
+
# Set on the thread inside a SQLite turn, whose BEGIN IMMEDIATE Rails does not know about.
|
|
70
|
+
@turn_key = :"runlight_turn_#{object_id}"
|
|
71
|
+
# The MySQL sessions, and the SQLite connections this database opened, already set up, by their driver
|
|
72
|
+
# connection, which a reconnect replaces.
|
|
73
|
+
@set_up = ObjectSpace::WeakMap.new
|
|
41
74
|
end
|
|
42
75
|
|
|
43
76
|
# "sqlite", "postgres", or "mysql".
|
|
44
77
|
def dialect
|
|
45
|
-
@dialect ||=
|
|
78
|
+
@dialect ||= @connection_class.connection_pool.with_connection { |c| self.class.dialect_of(c.adapter_name) }
|
|
46
79
|
end
|
|
47
80
|
|
|
48
81
|
def self.dialect_of(adapter)
|
|
@@ -80,7 +113,7 @@ module Runlight
|
|
|
80
113
|
# transaction already open (the store's, or the app's) is joined, not nested. Yields this database.
|
|
81
114
|
def transaction
|
|
82
115
|
connection do |c|
|
|
83
|
-
if c.transaction_open?
|
|
116
|
+
if c.transaction_open? || Thread.current[@turn_key]
|
|
84
117
|
yield self
|
|
85
118
|
elsif dialect == "mysql"
|
|
86
119
|
# As Postgres does by default: each statement sees what was committed before it began, and InnoDB
|
|
@@ -93,7 +126,8 @@ module Runlight
|
|
|
93
126
|
end
|
|
94
127
|
|
|
95
128
|
# Runs the block while holding a database-wide lock, so two processes starting at once do not race to
|
|
96
|
-
# create the same tables.
|
|
129
|
+
# create the same tables. Waits for the lock at most LOCK_WAIT_MS, then raises a TablesBusyError. Yields
|
|
130
|
+
# this database.
|
|
97
131
|
def exclusive(&block)
|
|
98
132
|
connection do |c|
|
|
99
133
|
@held += 1
|
|
@@ -105,6 +139,41 @@ module Runlight
|
|
|
105
139
|
end
|
|
106
140
|
end
|
|
107
141
|
|
|
142
|
+
# Runs the block as the only one reading and changing what it reads, across threads and processes, and
|
|
143
|
+
# yields this database. On Postgres and MySQL that is the database-wide lock. On SQLite it is a
|
|
144
|
+
# transaction begun IMMEDIATE, which takes the file's write lock before the block reads anything, so a
|
|
145
|
+
# second turn waits (up to the busy timeout) rather than reading what the first is about to change. A turn
|
|
146
|
+
# inside a turn, or inside a transaction already open, joins it.
|
|
147
|
+
def turn(&block)
|
|
148
|
+
return exclusive(&block) unless dialect == "sqlite"
|
|
149
|
+
|
|
150
|
+
connection do |c|
|
|
151
|
+
next yield(self) if Thread.current[@turn_key] || c.transaction_open?
|
|
152
|
+
|
|
153
|
+
c.execute("BEGIN IMMEDIATE", "Runlight")
|
|
154
|
+
Thread.current[@turn_key] = true
|
|
155
|
+
failed = nil
|
|
156
|
+
begin
|
|
157
|
+
yield self
|
|
158
|
+
rescue Exception => e
|
|
159
|
+
failed = e
|
|
160
|
+
raise
|
|
161
|
+
ensure
|
|
162
|
+
Thread.current[@turn_key] = nil
|
|
163
|
+
if failed
|
|
164
|
+
begin
|
|
165
|
+
c.execute("ROLLBACK", "Runlight")
|
|
166
|
+
rescue StandardError
|
|
167
|
+
# The error that got here matters more than one rolling back.
|
|
168
|
+
end
|
|
169
|
+
else
|
|
170
|
+
# Also when the block leaves early with return, break, or next.
|
|
171
|
+
c.execute("COMMIT", "Runlight")
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
108
177
|
def close
|
|
109
178
|
return unless @owned
|
|
110
179
|
|
|
@@ -115,7 +184,7 @@ module Runlight
|
|
|
115
184
|
# seconds and applies it to every statement; MySQL counts milliseconds and applies it to reads.
|
|
116
185
|
def limit_statements(ms)
|
|
117
186
|
connection do |c|
|
|
118
|
-
@mariadb = c
|
|
187
|
+
@mariadb = value(c, "SELECT VERSION() AS v").to_s.match?(/mariadb/i) if @mariadb.nil?
|
|
119
188
|
c.execute(@mariadb ? "SET SESSION max_statement_time = #{Json.number(ms / 1000.0)}" : "SET SESSION max_execution_time = #{ms.to_i}")
|
|
120
189
|
end
|
|
121
190
|
end
|
|
@@ -184,8 +253,35 @@ module Runlight
|
|
|
184
253
|
|
|
185
254
|
private
|
|
186
255
|
|
|
187
|
-
def connection
|
|
188
|
-
@connection_class.connection_pool.with_connection
|
|
256
|
+
def connection
|
|
257
|
+
@connection_class.connection_pool.with_connection do |c|
|
|
258
|
+
set_up(c) if dialect == "mysql" || (@owned && dialect == "sqlite")
|
|
259
|
+
yield c
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Values are escaped with backslashes, which a server set to NO_BACKSLASH_ESCAPES reads as text, so a
|
|
264
|
+
# quote could end the value early. Every MySQL session Runlight uses drops that one mode first, an app's
|
|
265
|
+
# own ActiveRecord connection's too, and again after a reconnect, which starts the session over. A SQLite
|
|
266
|
+
# file Runlight opened itself turns on secure_delete, as stores/sqlite.ts does.
|
|
267
|
+
def set_up(c)
|
|
268
|
+
raw = c.instance_variable_get(:@raw_connection)
|
|
269
|
+
return if raw && @set_up[raw]
|
|
270
|
+
|
|
271
|
+
c.execute(dialect == "sqlite" ? SECURE_DELETE : NO_BACKSLASH_ESCAPES_OFF, "Runlight")
|
|
272
|
+
raw = c.instance_variable_get(:@raw_connection)
|
|
273
|
+
wait_without_the_lock(raw) if dialect == "sqlite"
|
|
274
|
+
@set_up[raw] = true if raw
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Rails 7.2 sets SQLite's busy timeout with busy_timeout, which sleeps in C holding Ruby's global lock. A
|
|
278
|
+
# thread waiting for the file's write lock then stops the thread of the same process that holds it from
|
|
279
|
+
# ever finishing, so the wait runs out and raises "database is locked". busy_handler_timeout= (sqlite3
|
|
280
|
+
# 2.x, and what Rails 8 uses) waits the same time but lets other threads run.
|
|
281
|
+
def wait_without_the_lock(raw)
|
|
282
|
+
return unless raw.respond_to?(:busy_handler_timeout=)
|
|
283
|
+
|
|
284
|
+
raw.busy_handler_timeout = BUSY_TIMEOUT_MS
|
|
189
285
|
end
|
|
190
286
|
|
|
191
287
|
# A statement on a connection. A connection the server dropped (a restart, a failover, an idle timeout)
|
|
@@ -199,6 +295,7 @@ module Runlight
|
|
|
199
295
|
warn "Runlight: a #{dialect == "mysql" ? "MySQL" : "Postgres"} connection was lost; it reconnects on the next query. #{e.message}"
|
|
200
296
|
connection do |c|
|
|
201
297
|
c.reconnect!
|
|
298
|
+
set_up(c) if dialect == "mysql"
|
|
202
299
|
block.call(c)
|
|
203
300
|
end
|
|
204
301
|
end
|
|
@@ -233,7 +330,14 @@ module Runlight
|
|
|
233
330
|
if dialect == "postgres"
|
|
234
331
|
# Asked for again and again rather than waited on: a waiting statement would hold up an index being
|
|
235
332
|
# built CONCURRENTLY by whoever has the lock, and the two would wait on each other for good.
|
|
236
|
-
|
|
333
|
+
# Up to LOCK_WAIT_MS, so a request does not wait on a long index build for good.
|
|
334
|
+
wait = self.class.lock_wait_ms
|
|
335
|
+
until_at = monotonic_ms + wait
|
|
336
|
+
until truthy(value(c, "SELECT pg_try_advisory_lock(#{MIGRATION_LOCK}) AS ok"))
|
|
337
|
+
raise TablesBusyError, wait if monotonic_ms >= until_at
|
|
338
|
+
|
|
339
|
+
sleep 0.1
|
|
340
|
+
end
|
|
237
341
|
begin
|
|
238
342
|
return yield(self)
|
|
239
343
|
ensure
|
|
@@ -244,11 +348,16 @@ module Runlight
|
|
|
244
348
|
end
|
|
245
349
|
end
|
|
246
350
|
end
|
|
351
|
+
# Up to LOCK_WAIT_MS, so a request does not wait on a long index build for good.
|
|
352
|
+
wait = self.class.lock_wait_ms
|
|
353
|
+
until_at = monotonic_ms + wait
|
|
247
354
|
loop do
|
|
248
|
-
|
|
355
|
+
left = ((until_at - monotonic_ms) / 1000.0).ceil.clamp(1, 5)
|
|
356
|
+
ok = value(c, "SELECT GET_LOCK(#{MYSQL_LOCK}, #{left}) AS ok")
|
|
249
357
|
raise "Runlight: MySQL refused the lock for creating tables" if ok.nil?
|
|
250
358
|
break if ok.to_i == 1
|
|
251
|
-
# Not got
|
|
359
|
+
# Not got in time: another process is creating the tables. Ask again, until the wait is up.
|
|
360
|
+
raise TablesBusyError, wait if monotonic_ms >= until_at
|
|
252
361
|
end
|
|
253
362
|
begin
|
|
254
363
|
# An index on a big table takes a while to build, so the build may run past the statement timeout.
|
|
@@ -265,6 +374,17 @@ module Runlight
|
|
|
265
374
|
end
|
|
266
375
|
end
|
|
267
376
|
|
|
377
|
+
def monotonic_ms
|
|
378
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# The first column of a statement's first row, always asked of the database. select_value goes through
|
|
382
|
+
# Rails' query cache, which a Rails request turns on for every pool, so a poll for a lock would see its
|
|
383
|
+
# first answer again and again; exec_query is never cached.
|
|
384
|
+
def value(c, sql)
|
|
385
|
+
c.exec_query(sql, "Runlight").rows.first&.first
|
|
386
|
+
end
|
|
387
|
+
|
|
268
388
|
def truthy(value)
|
|
269
389
|
value == true || value == "t" || value == 1 || value == "1" || value == "true"
|
|
270
390
|
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Runlight
|
|
4
|
+
module Db
|
|
5
|
+
# A database whose tables and indexes start with a prefix of the app's choosing in place of `rl_`, such as
|
|
6
|
+
# `wp_rl_` beside a WordPress database's tables, so two apps sharing one database each keep a set of their
|
|
7
|
+
# own. Runlight's SQL names every table and index `rl_` and something, and those names alone are rewritten
|
|
8
|
+
# on the way through, in each statement and in the names a schema check compares as text. A store given a
|
|
9
|
+
# prefix puts one in front of its database, so the accounts and importers that reach the database through
|
|
10
|
+
# the store follow it too. Values bound to `?` are never rewritten.
|
|
11
|
+
class Prefixed
|
|
12
|
+
# Whatever else the database underneath has (close, limit_statements, connection_class) is passed on.
|
|
13
|
+
OPTIONAL = %i[affected turn metered?].freeze
|
|
14
|
+
private_constant :OPTIONAL
|
|
15
|
+
|
|
16
|
+
attr_reader :inner, :prefix
|
|
17
|
+
|
|
18
|
+
def initialize(inner, prefix)
|
|
19
|
+
# A name in a statement, and short enough that the longest index name stays inside MySQL's 64.
|
|
20
|
+
unless prefix.is_a?(String) && prefix.match?(/\A[A-Za-z_][A-Za-z0-9_]{0,31}\z/)
|
|
21
|
+
raise ArgumentError, "Runlight: a table prefix is up to 32 letters, digits, and underscores, and does not start with a digit"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@inner = inner
|
|
25
|
+
@prefix = prefix
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# A statement with each of Runlight's names under the prefix.
|
|
29
|
+
def sql(text)
|
|
30
|
+
text.gsub(/\brl_(?=[a-z])/, @prefix)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def dialect
|
|
34
|
+
@inner.dialect
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def all(text, params = [])
|
|
38
|
+
@inner.all(sql(text), params)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def run(text, params = [])
|
|
42
|
+
@inner.run(sql(text), params)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def affected(text, params = [])
|
|
46
|
+
@inner.affected(sql(text), params)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def metered?
|
|
50
|
+
@inner.metered?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def transaction
|
|
54
|
+
@inner.transaction { |db| yield wrap(db) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def exclusive
|
|
58
|
+
@inner.exclusive { |db| yield wrap(db) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def turn
|
|
62
|
+
@inner.turn { |db| yield wrap(db) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def close
|
|
66
|
+
@inner.close
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def respond_to?(name, include_all = false)
|
|
70
|
+
return @inner.respond_to?(name, include_all) if OPTIONAL.include?(name.to_sym)
|
|
71
|
+
|
|
72
|
+
super
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def wrap(db)
|
|
78
|
+
db.equal?(@inner) ? self : Prefixed.new(db, @prefix)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def method_missing(name, ...)
|
|
82
|
+
return super unless @inner.respond_to?(name)
|
|
83
|
+
|
|
84
|
+
@inner.public_send(name, ...)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def respond_to_missing?(name, include_all = false)
|
|
88
|
+
@inner.respond_to?(name, include_all) || super
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
data/lib/runlight/geo.rb
CHANGED
|
@@ -102,9 +102,10 @@ module Runlight
|
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
# The location of a request: the platform's headers when they name a country, else the lookup's answer
|
|
105
|
-
# for the address, else nowhere.
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
# for the address, else nowhere. The headers are believed only with trust_headers, as the address is: without
|
|
106
|
+
# a proxy in front, a client writes them itself.
|
|
107
|
+
def locate(headers, ip, lookup = nil, trust_headers = true)
|
|
108
|
+
from_headers = trust_headers ? location_from_headers(headers) : nil
|
|
108
109
|
return from_headers if !from_headers.nil? && from_headers["country"] != ""
|
|
109
110
|
|
|
110
111
|
if !lookup.nil? && ip != ""
|
|
@@ -58,6 +58,20 @@ module Runlight
|
|
|
58
58
|
|
|
59
59
|
private
|
|
60
60
|
|
|
61
|
+
# Opens the connection. A name pinned to several addresses is tried address by address, as a connection
|
|
62
|
+
# by name is, so a host that listens on one of them is reached; only the failure of the last is raised.
|
|
63
|
+
def connect(http, addresses)
|
|
64
|
+
tries = addresses.empty? ? [nil] : addresses
|
|
65
|
+
tries.each_with_index do |address, at|
|
|
66
|
+
http.ipaddr = address unless address.nil?
|
|
67
|
+
begin
|
|
68
|
+
return http.start
|
|
69
|
+
rescue SystemCallError, SocketError, Net::OpenTimeout
|
|
70
|
+
raise if at == tries.length - 1
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
61
75
|
def monotonic
|
|
62
76
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
63
77
|
end
|
|
@@ -72,16 +86,24 @@ module Runlight
|
|
|
72
86
|
left = deadline - monotonic
|
|
73
87
|
raise FetchError.new(TIMED_OUT, timed_out: true) if left <= 0
|
|
74
88
|
|
|
75
|
-
|
|
89
|
+
# A pinned request never goes through a proxy from the environment (http_proxy and the rest), which would
|
|
90
|
+
# connect wherever the name leads now rather than to the address that was checked.
|
|
91
|
+
pinned = !Array(init["resolve"]).empty?
|
|
92
|
+
http = pinned ? Net::HTTP.new(host, port, nil) : Net::HTTP.new(host, port)
|
|
76
93
|
http.use_ssl = https
|
|
77
94
|
# A pin connects to the checked address, with the name kept for the Host header and the certificate.
|
|
95
|
+
addresses = []
|
|
78
96
|
Array(init["resolve"]).each do |pin|
|
|
79
|
-
|
|
80
|
-
next unless
|
|
97
|
+
lead = "#{host}:#{port}:"
|
|
98
|
+
next unless pin.to_s.length > lead.length && pin.to_s[0, lead.length].casecmp?(lead)
|
|
81
99
|
|
|
82
|
-
|
|
100
|
+
addresses = pin.to_s[lead.length..].split(",").map { |address| address.delete_prefix("[").delete_suffix("]") }.reject(&:empty?)
|
|
101
|
+
http.ipaddr = addresses.first
|
|
83
102
|
break
|
|
84
103
|
end
|
|
104
|
+
# A pin that names another host or port would leave the name to be looked up again: refused, never
|
|
105
|
+
# fetched unpinned.
|
|
106
|
+
raise FetchError, "fetch failed" if pinned && http.ipaddr.nil?
|
|
85
107
|
http.open_timeout = [left, 15].min
|
|
86
108
|
http.read_timeout = left
|
|
87
109
|
http.write_timeout = left
|
|
@@ -105,8 +127,9 @@ module Runlight
|
|
|
105
127
|
# Past maxBytes the whole request is left by a throw, which closes the connection: a break would leave
|
|
106
128
|
# only read_body, and Net::HTTP would then read the rest of the body after all.
|
|
107
129
|
catch(:too_long) do
|
|
108
|
-
http
|
|
109
|
-
|
|
130
|
+
connect(http, addresses)
|
|
131
|
+
begin
|
|
132
|
+
http.request(request) do |answer|
|
|
110
133
|
status = answer.code.to_i
|
|
111
134
|
answer.each_capitalized_name { |name| answer_headers[name.downcase] = answer.get_fields(name) }
|
|
112
135
|
answer.read_body do |chunk|
|
|
@@ -120,6 +143,8 @@ module Runlight
|
|
|
120
143
|
received << chunk
|
|
121
144
|
end
|
|
122
145
|
end
|
|
146
|
+
ensure
|
|
147
|
+
http.finish if http.started?
|
|
123
148
|
end
|
|
124
149
|
end
|
|
125
150
|
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Timeout::Error
|
|
@@ -32,11 +32,14 @@ module Runlight
|
|
|
32
32
|
# the path's limit raises TooLarge once one byte past it has been read.
|
|
33
33
|
def self.from_rack(env, read_body: true)
|
|
34
34
|
scheme = env["rack.url_scheme"] || "http"
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
# A Host header no URL can hold (a space, a bad port, a userinfo @) is read as the server's own name, as
|
|
36
|
+
# an absent one is, so the request still has a URL; the header itself is kept as it came.
|
|
37
|
+
host = env["HTTP_HOST"].to_s
|
|
38
|
+
unless host?(scheme, host)
|
|
37
39
|
port = env["SERVER_PORT"].to_s
|
|
38
40
|
default = (scheme == "https" && port == "443") || (scheme == "http" && port == "80") || port.empty?
|
|
39
41
|
host = "#{env["SERVER_NAME"] || "localhost"}#{default ? "" : ":#{port}"}"
|
|
42
|
+
host = "localhost" unless host?(scheme, host)
|
|
40
43
|
end
|
|
41
44
|
path = "#{env["SCRIPT_NAME"]}#{env["PATH_INFO"]}"
|
|
42
45
|
path = "/#{path}" unless path.start_with?("/")
|
|
@@ -51,7 +54,9 @@ module Runlight
|
|
|
51
54
|
headers.set("content-type", type) unless type.empty?
|
|
52
55
|
length = env["CONTENT_LENGTH"].to_s
|
|
53
56
|
headers.set("content-length", length) unless length.empty?
|
|
54
|
-
|
|
57
|
+
# Rack::MethodOverride (in every Rails app) turns a form's POST with _method=DELETE into a DELETE. Runlight
|
|
58
|
+
# answers the method the browser sent, so a form on another page cannot pass for a write that needs no JSON.
|
|
59
|
+
method = (env["rack.methodoverride.original_method"] || env["REQUEST_METHOD"]).to_s.upcase
|
|
55
60
|
body = +""
|
|
56
61
|
input = env["rack.input"]
|
|
57
62
|
if read_body && !%w[GET HEAD].include?(method) && input
|
|
@@ -64,6 +69,12 @@ module Runlight
|
|
|
64
69
|
new(url, method: method, headers: headers, body: body, remote_address: env["REMOTE_ADDR"].to_s)
|
|
65
70
|
end
|
|
66
71
|
|
|
72
|
+
# Whether a Host header is a name (or [address]) and maybe a port, and nothing a URL would read as more.
|
|
73
|
+
def self.host?(scheme, host)
|
|
74
|
+
host.match?(%r{\A(?:\[[0-9A-Fa-f:.]+\]|[^\s/?#@\\\[\]:]+)(?::\d{1,5})?\z}) && !Url.parse("#{scheme}://#{host}/").nil?
|
|
75
|
+
end
|
|
76
|
+
private_class_method :host?
|
|
77
|
+
|
|
67
78
|
# The most a body may hold at this path: little for the collect endpoint, more for everything else.
|
|
68
79
|
def self.limit_for(path)
|
|
69
80
|
path.end_with?("/e") ? MAX_COLLECT_BODY : MAX_BODY
|
|
@@ -48,7 +48,7 @@ module Runlight
|
|
|
48
48
|
""
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
# A row with no visitor is its own visit, keyed by its whole content
|
|
51
|
+
# A row with no visitor is its own visit, keyed by its whole content.
|
|
52
52
|
def own_key(row)
|
|
53
53
|
entries = row.map { |k, v| [k.to_s, v] }
|
|
54
54
|
entries = entries.sort { |a, b| Js.compare(a[0], b[0]).negative? ? -1 : 1 }
|
|
@@ -18,7 +18,9 @@ module Runlight
|
|
|
18
18
|
#
|
|
19
19
|
# Returns { "cursor", "done", "total", "links", "clicks", "skipped", "failed" }, each failure
|
|
20
20
|
# { "slug", "reason", "code"?, "params"? }.
|
|
21
|
-
|
|
21
|
+
# link_domain: the domain as a site keeps it once it passes the checks a link domain added by hand does, or nil
|
|
22
|
+
# to drop it.
|
|
23
|
+
def import_step(runlight, site, source, credentials, cursor, done, link_domain = ->(_domain) {})
|
|
22
24
|
name = IMPORTERS[source]
|
|
23
25
|
raise ImportError.new("Runlight cannot import from #{source}", "import_source", { "source" => source }) if name.nil?
|
|
24
26
|
|
|
@@ -37,6 +39,12 @@ module Runlight
|
|
|
37
39
|
# A total that is not a number is as good as none.
|
|
38
40
|
total = result["total"]
|
|
39
41
|
total = nil unless total.is_a?(Integer) || (total.is_a?(Float) && total.finite?)
|
|
42
|
+
# Each domain is checked once a step, since the check may look it up in DNS.
|
|
43
|
+
checked = {}
|
|
44
|
+
domain_for = lambda do |domain|
|
|
45
|
+
checked[domain] = link_domain.call(domain) unless checked.key?(domain)
|
|
46
|
+
checked[domain]
|
|
47
|
+
end
|
|
40
48
|
step = { "cursor" => result["cursor"], "done" => done, "total" => total, "links" => 0, "clicks" => 0, "skipped" => 0, "failed" => [] }
|
|
41
49
|
result["links"].each do |item|
|
|
42
50
|
if Js.truthy?(item["known"])
|
|
@@ -44,7 +52,7 @@ module Runlight
|
|
|
44
52
|
step["skipped"] += 1
|
|
45
53
|
next
|
|
46
54
|
end
|
|
47
|
-
written = Write.write_link(runlight, site, source, item["link"], item)
|
|
55
|
+
written = Write.write_link(runlight, site, source, item["link"], item, domain_for)
|
|
48
56
|
step["done"] += 1
|
|
49
57
|
case written["status"]
|
|
50
58
|
when "created"
|