litestream 0.14.0 → 0.15.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 +4 -4
- data/README.md +2 -0
- data/app/controllers/litestream/application_controller.rb +2 -0
- data/lib/litestream/commands.rb +102 -17
- data/lib/litestream/version.rb +1 -1
- data/lib/litestream.rb +62 -21
- data/lib/puma/plugin/litestream.rb +2 -14
- metadata +9 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 006c5aaaf41fb59b8af8178c090aab72e78e8c586c83c59ddc25f25bc232ea98
|
|
4
|
+
data.tar.gz: a320d9128e3f5ad4c757c3463edebdbaecc7542c48008e5b1efa75dc53bdab1a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f75e2d4e857f186de61a3665775d2018373509a4beeb448b11f404a5fa9b55d92da06eda2a75d09304d696b32f3e6e1d73423b851e44a77e7d31fe5bb4813cdd
|
|
7
|
+
data.tar.gz: abc842f2de56524cb9370eed3433c49491dc10adfe23f544b25ecd9017c8008ecbe378efe30a216340f97e6b79c9a138f8e88c03f4695c724427c6d12b22e259
|
data/README.md
CHANGED
|
@@ -434,6 +434,8 @@ s3 a295b16a796689f3 1 0 2036 2024-04-17T00:01:19Z
|
|
|
434
434
|
|
|
435
435
|
In addition to the provided rake tasks, you can also run Litestream commands directly from Ruby. The gem provides a `Litestream::Commands` module that wraps the Litestream CLI commands. This is particularly useful for the introspection commands, as you can use the output in your Ruby code.
|
|
436
436
|
|
|
437
|
+
Commands raise `Litestream::Commands::CommandFailedException` when the Litestream binary exits non-zero, and the exception message includes stderr. Pass `json: true` to receive parsed JSON when using Litestream 0.5 or newer (Litestream 0.3 does not support the `-json` flag), and use `timeout:` to limit how many seconds a command may run.
|
|
438
|
+
|
|
437
439
|
The `Litestream::Commands.databases` method returns an array of hashes with the "path" and "replicas" keys for each database:
|
|
438
440
|
|
|
439
441
|
```ruby
|
data/lib/litestream/commands.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "open3"
|
|
1
3
|
require_relative "upstream"
|
|
2
4
|
|
|
3
5
|
module Litestream
|
|
@@ -20,6 +22,9 @@ module Litestream
|
|
|
20
22
|
# raised when a litestream command fails
|
|
21
23
|
CommandFailedException = Class.new(StandardError)
|
|
22
24
|
|
|
25
|
+
# raised when a litestream command times out
|
|
26
|
+
CommandTimeoutException = Class.new(CommandFailedException)
|
|
27
|
+
|
|
23
28
|
module Output
|
|
24
29
|
class << self
|
|
25
30
|
def format(data)
|
|
@@ -47,7 +52,10 @@ module Litestream
|
|
|
47
52
|
litestream_install_dir = ENV["LITESTREAM_INSTALL_DIR"]
|
|
48
53
|
if litestream_install_dir
|
|
49
54
|
if File.directory?(litestream_install_dir)
|
|
50
|
-
|
|
55
|
+
unless @litestream_install_dir_noted
|
|
56
|
+
warn "NOTE: using LITESTREAM_INSTALL_DIR to find litestream executable: #{litestream_install_dir}"
|
|
57
|
+
@litestream_install_dir_noted = true
|
|
58
|
+
end
|
|
51
59
|
exe_path = litestream_install_dir
|
|
52
60
|
exe_file = File.expand_path(File.join(litestream_install_dir, "litestream"))
|
|
53
61
|
else
|
|
@@ -94,12 +102,14 @@ module Litestream
|
|
|
94
102
|
exe_file
|
|
95
103
|
end
|
|
96
104
|
|
|
97
|
-
# Replicate
|
|
98
|
-
# Puma
|
|
99
|
-
#
|
|
105
|
+
# Replicate runs either as a child process or in the foreground, depending on the context.
|
|
106
|
+
# The Puma plugin starts it as a child and gets its pid back; the rake task runs it in the
|
|
107
|
+
# foreground and streams its output.
|
|
100
108
|
def replicate(async: false, **argv)
|
|
101
109
|
cmd = prepare("replicate", argv)
|
|
102
110
|
run_replicate(cmd, async: async)
|
|
111
|
+
rescue CommandFailedException
|
|
112
|
+
raise
|
|
103
113
|
rescue
|
|
104
114
|
raise CommandFailedException, "Failed to execute `#{cmd.join(" ")}`"
|
|
105
115
|
end
|
|
@@ -135,14 +145,19 @@ module Litestream
|
|
|
135
145
|
private
|
|
136
146
|
|
|
137
147
|
def execute(command, argv = {}, database = nil, tabled_output: true)
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
148
|
+
argv = argv.stringify_keys
|
|
149
|
+
timeout = argv.delete("timeout")
|
|
150
|
+
output = if argv.delete("json")
|
|
151
|
+
argv["-json"] = nil
|
|
152
|
+
:json
|
|
153
|
+
elsif tabled_output
|
|
154
|
+
:table
|
|
143
155
|
else
|
|
144
|
-
|
|
156
|
+
:raw
|
|
145
157
|
end
|
|
158
|
+
|
|
159
|
+
cmd = prepare(command, argv, database)
|
|
160
|
+
run(cmd, output: output, timeout: timeout)
|
|
146
161
|
end
|
|
147
162
|
|
|
148
163
|
def prepare(command, argv = {}, database = nil)
|
|
@@ -154,29 +169,99 @@ module Litestream
|
|
|
154
169
|
|
|
155
170
|
args = {
|
|
156
171
|
"--config" => Litestream.config_path.to_s
|
|
157
|
-
}.merge(argv.stringify_keys).to_a.flatten.compact
|
|
158
|
-
cmd = [executable, command, *args, database].compact
|
|
172
|
+
}.merge(argv.stringify_keys).to_a.flatten.compact.map(&:to_s)
|
|
173
|
+
cmd = [executable, command, *args, database].compact.map(&:to_s)
|
|
159
174
|
puts cmd.inspect if ENV["DEBUG"]
|
|
160
175
|
|
|
161
176
|
cmd
|
|
162
177
|
end
|
|
163
178
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
179
|
+
# Runs the command without a shell and returns its parsed stdout. A non-zero
|
|
180
|
+
# exit raises with stderr. With a timeout, the command runs in its own
|
|
181
|
+
# process group and is killed (TERM, then KILL) when the deadline passes.
|
|
182
|
+
def run(cmd, output:, timeout: nil)
|
|
183
|
+
stdin, stdout, stderr, wait_thread = Open3.popen3(*cmd, pgroup: true)
|
|
184
|
+
stdin.close
|
|
185
|
+
stdout_reader = Thread.new { stdout.read }
|
|
186
|
+
stderr_reader = Thread.new { stderr.read }
|
|
187
|
+
|
|
188
|
+
# The readers finish when the last process holding the pipes exits, so
|
|
189
|
+
# waiting on them covers descendants the direct child may have left behind.
|
|
190
|
+
unless wait_thread.join(timeout) && stdout_reader.join(timeout) && stderr_reader.join(timeout)
|
|
191
|
+
kill_process_group("TERM", wait_thread.pid)
|
|
192
|
+
wait_thread.join(1)
|
|
193
|
+
kill_process_group("KILL", wait_thread.pid)
|
|
194
|
+
wait_thread.join
|
|
195
|
+
[stdout_reader, stderr_reader].each(&:join)
|
|
196
|
+
raise CommandTimeoutException, "Failed to execute `#{cmd[1]}`: timed out after #{timeout} seconds"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
status = wait_thread.value
|
|
200
|
+
unless status.success?
|
|
201
|
+
raise CommandFailedException, "Failed to execute `#{cmd[1]}` (exit status #{status.exitstatus}): #{stderr_reader.value.strip[0, 500]}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
case output
|
|
205
|
+
when :json then parse_json(cmd, stdout_reader.value)
|
|
206
|
+
when :table then parse_table(stdout_reader.value)
|
|
207
|
+
else stdout_reader.value
|
|
208
|
+
end
|
|
209
|
+
ensure
|
|
210
|
+
[stdout_reader, stderr_reader].each { |reader| reader&.join }
|
|
211
|
+
[stdin, stdout, stderr].each { |io| io&.close unless io&.closed? }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# macOS answers EPERM rather than ESRCH when every process left in the
|
|
215
|
+
# group has exited but not yet been reaped.
|
|
216
|
+
def kill_process_group(signal, pid)
|
|
217
|
+
Process.kill(signal, -pid)
|
|
218
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Two opt-in restore skips (-if-db-not-exists when the output exists,
|
|
222
|
+
# -if-replica-exists with no backups) exit 0 and print one logfmt line on
|
|
223
|
+
# stdout instead of JSON. They come back as {"skipped" => true, "message" => ...}.
|
|
224
|
+
def parse_json(cmd, stdout)
|
|
225
|
+
stdout = stdout.strip
|
|
226
|
+
return if stdout.empty?
|
|
227
|
+
return JSON.parse(stdout) if stdout.start_with?("{", "[")
|
|
228
|
+
|
|
229
|
+
skipped = stdout.match(/\Atime=\S+ level=\S+ msg=(?:"([^"]*)"|(\S+))/)
|
|
230
|
+
return {"skipped" => true, "message" => skipped[1] || skipped[2]} if skipped
|
|
231
|
+
|
|
232
|
+
raise CommandFailedException, "Unexpected output from `#{cmd[1]}`: #{stdout.lines.first.to_s.strip[0, 200]}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def parse_table(stdout)
|
|
236
|
+
keys, *rows = stdout.strip.split("\n").map { _1.split(/\s+/) }
|
|
237
|
+
return [] unless keys
|
|
167
238
|
|
|
168
|
-
keys, *rows = stdout.split("\n").map { _1.split(/\s+/) }
|
|
169
239
|
rows.map { keys.zip(_1).to_h }
|
|
170
240
|
end
|
|
171
241
|
|
|
242
|
+
# Async replication returns the pid of the litestream process itself so a
|
|
243
|
+
# supervisor can signal and wait on it. Process.spawn is fork-and-exec in
|
|
244
|
+
# one call, so the pid it returns is the one that becomes litestream. The
|
|
245
|
+
# previous `exec(*cmd) if fork.nil?` also execs in a child but returns
|
|
246
|
+
# into the parent too, and the Puma plugin wrapped that in a fork of its
|
|
247
|
+
# own, so it recorded a pid that exited immediately while the daemon was
|
|
248
|
+
# reparented to init and outlived Puma.
|
|
172
249
|
def run_replicate(cmd, async:)
|
|
173
250
|
if async
|
|
174
|
-
|
|
251
|
+
Process.spawn(*cmd)
|
|
175
252
|
else
|
|
176
253
|
# When running in-process, we capture output continuously and write to stdout.
|
|
177
254
|
IO.popen(cmd, err: [:child, :out]) do |io|
|
|
178
255
|
io.each_line { |line| puts line }
|
|
179
256
|
end
|
|
257
|
+
status = $?
|
|
258
|
+
# `replicate` runs until it is signalled, so a signal is how it is
|
|
259
|
+
# meant to end. A non-zero exit is litestream failing to start or
|
|
260
|
+
# dying on its own, which is invisible today: the task exits 0 and a
|
|
261
|
+
# supervisor sees a clean stop rather than a crash to restart.
|
|
262
|
+
if status && !status.success? && !status.signaled?
|
|
263
|
+
raise CommandFailedException, "Failed to execute `replicate` (exit status #{status.exitstatus})"
|
|
264
|
+
end
|
|
180
265
|
end
|
|
181
266
|
end
|
|
182
267
|
end
|
data/lib/litestream/version.rb
CHANGED
data/lib/litestream.rb
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "etc"
|
|
3
4
|
require "sqlite3"
|
|
5
|
+
require "time"
|
|
4
6
|
|
|
5
7
|
module Litestream
|
|
6
8
|
VerificationFailure = Class.new(StandardError)
|
|
7
9
|
|
|
10
|
+
PROCESS_STATES = {
|
|
11
|
+
"D" => "uninterruptible",
|
|
12
|
+
"I" => "idle",
|
|
13
|
+
"R" => "running",
|
|
14
|
+
"S" => "sleeping",
|
|
15
|
+
"T" => "stopped",
|
|
16
|
+
"U" => "uninterruptible",
|
|
17
|
+
"Z" => "zombie"
|
|
18
|
+
}.freeze
|
|
19
|
+
private_constant :PROCESS_STATES
|
|
20
|
+
|
|
8
21
|
class << self
|
|
9
22
|
attr_writer :configuration
|
|
10
23
|
|
|
@@ -163,30 +176,58 @@ module Litestream
|
|
|
163
176
|
info
|
|
164
177
|
end
|
|
165
178
|
|
|
179
|
+
# Linux has every process under /proc, even in images that ship no `ps`
|
|
180
|
+
# (Debian slim) or BusyBox's, which lacks `-x` and `lstart` (Alpine).
|
|
166
181
|
def process_info
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
return unless exit_code.zero?
|
|
182
|
+
File.directory?("/proc/self") ? proc_process_info : ps_process_info
|
|
183
|
+
end
|
|
170
184
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
185
|
+
def proc_process_info(proc_dir = "/proc")
|
|
186
|
+
boot_time = File.foreach(File.join(proc_dir, "stat")).grep(/\Abtime /).first.split.last.to_i
|
|
187
|
+
|
|
188
|
+
Dir.glob(File.join(proc_dir, "[0-9]*")).each do |dir|
|
|
189
|
+
executable, command = File.read(File.join(dir, "cmdline")).split("\0")
|
|
190
|
+
next unless File.basename(executable.to_s) == "litestream" && command == "replicate"
|
|
191
|
+
|
|
192
|
+
# Fields are counted after the command name's closing paren, since the
|
|
193
|
+
# name itself may contain spaces and parens.
|
|
194
|
+
state, *fields = File.read(File.join(dir, "stat")).rpartition(")").last.split
|
|
195
|
+
start_ticks = fields[18].to_i
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
pid: File.basename(dir),
|
|
199
|
+
status: PROCESS_STATES[state[0]],
|
|
200
|
+
started: Time.at(boot_time + start_ticks.fdiv(Etc.sysconf(Etc::SC_CLK_TCK))).to_datetime
|
|
201
|
+
}
|
|
202
|
+
rescue SystemCallError
|
|
203
|
+
# the process exited while being read, or belongs to another user
|
|
204
|
+
next
|
|
188
205
|
end
|
|
189
|
-
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# macOS. The command name is matched on its own so that a shell or editor
|
|
210
|
+
# whose arguments mention "litestream replicate" is not mistaken for it.
|
|
211
|
+
def ps_process_info
|
|
212
|
+
output = `ps -axo pid=,stat=,lstart=,comm=`
|
|
213
|
+
return unless $?.success?
|
|
214
|
+
|
|
215
|
+
output.each_line do |line|
|
|
216
|
+
# lstart is always five words ("Mon Jul 1 11:10:58 2024"); comm is last
|
|
217
|
+
# because it may contain spaces.
|
|
218
|
+
pid, state, *lstart, command = line.strip.split(" ", 8)
|
|
219
|
+
next unless File.basename(command.to_s) == "litestream"
|
|
220
|
+
next unless `ps -o args= -p #{pid}`.match?(/litestream replicate(?: |$)/)
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
pid: pid,
|
|
224
|
+
status: PROCESS_STATES[state[0]],
|
|
225
|
+
started: Time.strptime(lstart.join(" "), "%a %b %d %H:%M:%S %Y").to_datetime
|
|
226
|
+
}
|
|
227
|
+
end
|
|
228
|
+
nil
|
|
229
|
+
rescue Errno::ENOENT
|
|
230
|
+
nil
|
|
190
231
|
end
|
|
191
232
|
end
|
|
192
233
|
end
|
|
@@ -2,17 +2,13 @@ require "puma/plugin"
|
|
|
2
2
|
|
|
3
3
|
# Copied from https://github.com/rails/solid_queue/blob/15408647f1780033dad223d3198761ea2e1e983e/lib/puma/plugin/solid_queue.rb
|
|
4
4
|
Puma::Plugin.create do
|
|
5
|
-
attr_reader :
|
|
5
|
+
attr_reader :litestream_pid, :log_writer
|
|
6
6
|
|
|
7
7
|
def start(launcher)
|
|
8
8
|
@log_writer = launcher.log_writer
|
|
9
|
-
@puma_pid = $$
|
|
10
9
|
|
|
11
10
|
launcher.events.on_booted do
|
|
12
|
-
@litestream_pid =
|
|
13
|
-
Thread.new { monitor_puma }
|
|
14
|
-
Litestream::Commands.replicate(async: true)
|
|
15
|
-
end
|
|
11
|
+
@litestream_pid = Litestream::Commands.replicate(async: true)
|
|
16
12
|
|
|
17
13
|
in_background do
|
|
18
14
|
monitor_litestream
|
|
@@ -33,10 +29,6 @@ Puma::Plugin.create do
|
|
|
33
29
|
rescue Errno::ECHILD, Errno::ESRCH
|
|
34
30
|
end
|
|
35
31
|
|
|
36
|
-
def monitor_puma
|
|
37
|
-
monitor(:puma_dead?, "Detected Puma has gone away, stopping Litestream...")
|
|
38
|
-
end
|
|
39
|
-
|
|
40
32
|
def monitor_litestream
|
|
41
33
|
monitor(:litestream_dead?, "Detected Litestream has gone away, stopping Puma...")
|
|
42
34
|
end
|
|
@@ -59,10 +51,6 @@ Puma::Plugin.create do
|
|
|
59
51
|
true
|
|
60
52
|
end
|
|
61
53
|
|
|
62
|
-
def puma_dead?
|
|
63
|
-
Process.ppid != puma_pid
|
|
64
|
-
end
|
|
65
|
-
|
|
66
54
|
def log(...)
|
|
67
55
|
log_writer.log(...)
|
|
68
56
|
end
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: litestream
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.15.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stephen Margheim
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: sqlite3
|
|
@@ -23,6 +23,7 @@ dependencies:
|
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '0'
|
|
26
|
+
force_ruby_platform: false
|
|
26
27
|
- !ruby/object:Gem::Dependency
|
|
27
28
|
name: actionpack
|
|
28
29
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -37,6 +38,7 @@ dependencies:
|
|
|
37
38
|
- - ">="
|
|
38
39
|
- !ruby/object:Gem::Version
|
|
39
40
|
version: '7.0'
|
|
41
|
+
force_ruby_platform: false
|
|
40
42
|
- !ruby/object:Gem::Dependency
|
|
41
43
|
name: actionview
|
|
42
44
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -51,6 +53,7 @@ dependencies:
|
|
|
51
53
|
- - ">="
|
|
52
54
|
- !ruby/object:Gem::Version
|
|
53
55
|
version: '7.0'
|
|
56
|
+
force_ruby_platform: false
|
|
54
57
|
- !ruby/object:Gem::Dependency
|
|
55
58
|
name: activejob
|
|
56
59
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -65,6 +68,7 @@ dependencies:
|
|
|
65
68
|
- - ">="
|
|
66
69
|
- !ruby/object:Gem::Version
|
|
67
70
|
version: '7.0'
|
|
71
|
+
force_ruby_platform: false
|
|
68
72
|
- !ruby/object:Gem::Dependency
|
|
69
73
|
name: activesupport
|
|
70
74
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -79,6 +83,7 @@ dependencies:
|
|
|
79
83
|
- - ">="
|
|
80
84
|
- !ruby/object:Gem::Version
|
|
81
85
|
version: '7.0'
|
|
86
|
+
force_ruby_platform: false
|
|
82
87
|
- !ruby/object:Gem::Dependency
|
|
83
88
|
name: railties
|
|
84
89
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -93,6 +98,7 @@ dependencies:
|
|
|
93
98
|
- - ">="
|
|
94
99
|
- !ruby/object:Gem::Version
|
|
95
100
|
version: '7.0'
|
|
101
|
+
force_ruby_platform: false
|
|
96
102
|
- !ruby/object:Gem::Dependency
|
|
97
103
|
name: rails
|
|
98
104
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -172,7 +178,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
172
178
|
- !ruby/object:Gem::Version
|
|
173
179
|
version: '0'
|
|
174
180
|
requirements: []
|
|
175
|
-
rubygems_version: 3.6.
|
|
181
|
+
rubygems_version: 3.6.7
|
|
176
182
|
specification_version: 4
|
|
177
183
|
summary: Integrate Litestream with the RubyGems infrastructure.
|
|
178
184
|
test_files: []
|