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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e4c2bacf97ec888fa90d5182662278f0f7bef274e46334f087c48bc0cc0eb29f
4
- data.tar.gz: 75ae8f8ba2566890c5c558d60a4ea4863ad59c449855685422e0edad995acf09
3
+ metadata.gz: 006c5aaaf41fb59b8af8178c090aab72e78e8c586c83c59ddc25f25bc232ea98
4
+ data.tar.gz: a320d9128e3f5ad4c757c3463edebdbaecc7542c48008e5b1efa75dc53bdab1a
5
5
  SHA512:
6
- metadata.gz: de32e013065e37e3705306c69d380031f0dc9f33840a11ebc7846891bccd7b68c82fe02fea90c45648fdfd67a5b1709ef26fab35c035e2a679f708002ef447df
7
- data.tar.gz: 730f7052e41485a5d8a81b324a6093fcbf7dadd5184571f4c81d60ca43f52344f7ed3a492f317c57f6205fc2b3f67f352afd123d17444f39b5ac1bfc4dd4a994
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
@@ -1,5 +1,7 @@
1
1
  module Litestream
2
2
  class ApplicationController < Litestream.base_controller_class.constantize
3
+ layout "litestream/application"
4
+
3
5
  protect_from_forgery with: :exception
4
6
 
5
7
  if Litestream.password
@@ -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
- warn "NOTE: using LITESTREAM_INSTALL_DIR to find litestream executable: #{litestream_install_dir}"
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 can be run either as a fork or in the same process, depending on the context.
98
- # Puma will start replication as a forked process, while running replication from a rake
99
- # tasks won't.
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
- cmd = prepare(command, argv, database)
139
- results = run(cmd, tabled_output: tabled_output)
140
-
141
- if Array === results && results.one? && results[0]["level"] == "ERROR"
142
- raise CommandFailedException, "Failed to execute `#{cmd.join(" ")}`; Reason: #{results[0]["error"]}"
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
- results
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
- def run(cmd, tabled_output:)
165
- stdout = `#{cmd.join(" ")}`.chomp
166
- return stdout unless tabled_output
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
- exec(*cmd) if fork.nil?
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
@@ -1,3 +1,3 @@
1
1
  module Litestream
2
- VERSION = "0.14.0"
2
+ VERSION = "0.15.0"
3
3
  end
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
- litestream_replicate_ps = `ps -ax | grep litestream | grep replicate`
168
- exit_code = $?.exitstatus
169
- return unless exit_code.zero?
182
+ File.directory?("/proc/self") ? proc_process_info : ps_process_info
183
+ end
170
184
 
171
- info = {}
172
- litestream_replicate_ps.chomp.split("\n").each do |line|
173
- next unless line.include?("litestream replicate")
174
-
175
- pid, * = line.split(" ")
176
- info[:pid] = pid
177
- state, _, lstart = `ps -o "state,lstart" #{pid}`.chomp.split("\n").last.partition(/\s+/)
178
-
179
- info[:status] = case state[0]
180
- when "I" then "idle"
181
- when "R" then "running"
182
- when "S" then "sleeping"
183
- when "T" then "stopped"
184
- when "U" then "uninterruptible"
185
- when "Z" then "zombie"
186
- end
187
- info[:started] = DateTime.strptime(lstart.strip, "%a %b %d %H:%M:%S %Y")
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
- info
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 :puma_pid, :litestream_pid, :log_writer
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 = fork do
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.14.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: 2025-06-13 00:00:00.000000000 Z
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.3
181
+ rubygems_version: 3.6.7
176
182
  specification_version: 4
177
183
  summary: Integrate Litestream with the RubyGems infrastructure.
178
184
  test_files: []