litestream 0.13.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: 1e4638f801b8e05319b2390a63422edd6778944bb053324aefdbd43f98a2f15c
4
- data.tar.gz: ced2aa3f3648042bf7f2211c02b17db670d0cb8a0c86902067ab08688954cb37
3
+ metadata.gz: 006c5aaaf41fb59b8af8178c090aab72e78e8c586c83c59ddc25f25bc232ea98
4
+ data.tar.gz: a320d9128e3f5ad4c757c3463edebdbaecc7542c48008e5b1efa75dc53bdab1a
5
5
  SHA512:
6
- metadata.gz: be394032aadcf1ae817248ea8e83c1d23433f007f133aa6a0680813d5dd360895695654295bc8f3c91bcd6e8608a973d85f64cea9bae85f64ab6597b2e541f40
7
- data.tar.gz: f6e3e6681736fc683bc855ca85c8124cc003c94096406d0127932fc38815541c244a6c1a59ac88ba57d2f0a4816dac30ce25fb4deca4db5563484d99e3a20d6e
6
+ metadata.gz: f75e2d4e857f186de61a3665775d2018373509a4beeb448b11f404a5fa9b55d92da06eda2a75d09304d696b32f3e6e1d73423b851e44a77e7d31fe5bb4813cdd
7
+ data.tar.gz: abc842f2de56524cb9370eed3433c49491dc10adfe23f544b25ecd9017c8008ecbe378efe30a216340f97e6b79c9a138f8e88c03f4695c724427c6d12b22e259
data/README.md CHANGED
@@ -80,24 +80,27 @@ LITESTREAM_INSTALL_DIR=.bin
80
80
 
81
81
  You configure the Litestream executable through the [`config/litestream.yml` file](https://litestream.io/reference/config/), which is a standard Litestream configuration file as if Litestream was running in a traditional installation.
82
82
 
83
- The gem streamlines the configuration process by providing a default configuration file for you. This configuration file will backup all SQLite databases defined in your `config/database.yml` file to one replication bucket. In order to ensure that no secrets are stored in plain-text in your repository, this configuration file leverages Litestream's support for environment variables. The default configuration file looks like this if you only have one SQLite database:
83
+ The gem streamlines the configuration process by providing a default configuration file for you. This configuration file will backup all SQLite databases defined in your `config/database.yml` file to one replication bucket. In order to ensure that no secrets are stored in plain-text in your repository, this configuration file leverages Litestream's support for environment variables. Inspect which environment variables are available by running the `bin/rails litestream:env` command.
84
+
85
+ The default configuration file looks like this if you only have one SQLite database:
84
86
 
85
87
  ```yaml
86
88
  dbs:
87
89
  - path: storage/production.sqlite3
88
90
  replicas:
89
91
  - type: s3
90
- bucket: $LITESTREAM_REPLICA_BUCKET
91
92
  path: storage/production.sqlite3
93
+ bucket: $LITESTREAM_REPLICA_BUCKET
92
94
  access-key-id: $LITESTREAM_ACCESS_KEY_ID
93
95
  secret-access-key: $LITESTREAM_SECRET_ACCESS_KEY
94
96
  ```
95
97
 
96
98
  This is the default for Amazon S3. The full range of possible replica types (e.g. other S3-compatible object storage servers) are covered in Litestream's [replica guides](https://litestream.io/guides/#replica-guides).
97
99
 
98
- The gem also provides a default initializer file at `config/initializers/litestream.rb` that allows you to configure these three environment variables referenced in the configuration file in Ruby. By providing a Ruby interface to these environment variables, you can use any method of storing secrets that you prefer. For example, the default generated file uses Rails' encrypted credentials to store your secrets:
100
+ The gem also provides a default initializer file at `config/initializers/litestream.rb` that allows you to configure various variables referenced in the configuration file in Ruby. By providing a Ruby interface to these environment variables, you can use your preferred method of storing secrets. For example, the default generated file uses Rails' encrypted credentials to store your secrets.
99
101
 
100
102
  ```ruby
103
+ # config/initializers/litestream.rb
101
104
  Rails.application.configure do
102
105
  litestream_credentials = Rails.application.credentials.litestream
103
106
 
@@ -107,7 +110,21 @@ Rails.application.configure do
107
110
  end
108
111
  ```
109
112
 
110
- However, if you need manual control over the Litestream configuration, you can manually edit the `config/litestream.yml` file. The full range of possible configurations are covered in Litestream's [configuration reference](https://litestream.io/reference/config/). NB: If you configure a longer `sync-interval`, you may need to adjust `replication_sleep` when calling `Litestream.verify!`.
113
+ Outside of configuring Litestream's replication, you may also configure various other aspects of `litestream-ruby` itself.
114
+
115
+ ```ruby
116
+ # config/initializers/litestream.rb
117
+ Rails.application.configure do
118
+ # ...
119
+
120
+ # Base controller used for Litestream dashboard
121
+ config.litestream.base_controller_class = "MyApplicationController"
122
+ # Set the location of the Litestream config
123
+ config.litestream.config_path = "config/litestream.yml"
124
+ end
125
+ ```
126
+
127
+ However, if you need manual control over the Litestream configuration, you can edit the `config/litestream.yml` file. The full range of possible configurations are covered in Litestream's [configuration reference](https://litestream.io/reference/config/).
111
128
 
112
129
  ### Replication
113
130
 
@@ -232,6 +249,7 @@ You can verify the integrity of your backed-up databases using the gem's provide
232
249
 
233
250
  ```ruby
234
251
  Litestream.verify! "storage/production.sqlite3"
252
+ Litestream.verify!(replication_sleep: 10) "storage/production.sqlite3"
235
253
  ```
236
254
 
237
255
  In order to verify that the backup for that database is both restorable and fresh, the method will add a new row to that database under the `_litestream_verification` table, which it will create if needed. It will then wait `replication_sleep` seconds (defaults to 10) to give the Litestream utility time to replicate that change to whatever storage providers you have configured. After that, it will download the latest backup from that storage provider and ensure that this verification row is present in the backup. If the verification row is _not_ present, the method will raise a `Litestream::VerificationFailure` exception. This check ensures that the restored database file:
@@ -242,6 +260,9 @@ In order to verify that the backup for that database is both restorable and fres
242
260
 
243
261
  After restoring the backup, the `Litestream.verify!` method will delete the restored database file. If you need the restored database file, use the `litestream:restore` rake task or `Litestream::Commands.restore` method instead.
244
262
 
263
+ > [!NOTE]
264
+ > If you configure Litestream's [`sync-interval`](https://litestream.io/reference/config/#replica-settings) to be longer than the default `replication_sleep` value of 10 seconds, you will need to adjust `replication_sleep` to a value larger than `sync-interval`; otherwise, `Litestream.verify!` may appear to fail where it actually simply didn't wait long enough for replication.
265
+
245
266
  ### Dashboard
246
267
 
247
268
  The gem provides a web dashboard for monitoring the status of your Litestream replication. To mount the dashboard in your Rails application, add the following to your `config/routes.rb` file:
@@ -413,6 +434,8 @@ s3 a295b16a796689f3 1 0 2036 2024-04-17T00:01:19Z
413
434
 
414
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.
415
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
+
416
439
  The `Litestream::Commands.databases` method returns an array of hashes with the "path" and "replicas" keys for each database:
417
440
 
418
441
  ```ruby
@@ -506,7 +529,7 @@ time=YYYY-MM-DDTHH:MM:SS level=INFO msg="replicating to" name=s3 type=s3 sync-in
506
529
 
507
530
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
508
531
 
509
- To install this gem onto your local machine, run `bundle exec rake install`.
532
+ To install this gem onto your local machine, run `bundle exec rake install`. To download the Litestream binaries run `bundle exec rake download`.
510
533
 
511
534
  For maintainers, to release a new version, run `bin/release $VERSION`, which will create a git tag for the version, push git commits and tags, and push all of the platform-specific `.gem` files to [rubygems.org](https://rubygems.org).
512
535
 
@@ -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
@@ -9,7 +9,7 @@ module Litestream
9
9
  now = Time.now.utc.strftime("%Y%m%d%H%M%S")
10
10
  backup = File.join(dir, "#{base}-#{now}#{ext}")
11
11
 
12
- Litestream::Commands.restore(database, async: false, **{"-o" => backup})
12
+ Litestream::Commands.restore(database, **{"-o" => backup})
13
13
 
14
14
  redirect_to root_path, notice: "Restored to <code>#{backup}</code>."
15
15
  end
@@ -1,5 +1,6 @@
1
+ require "json"
2
+ require "open3"
1
3
  require_relative "upstream"
2
- require "logfmt"
3
4
 
4
5
  module Litestream
5
6
  module Commands
@@ -21,6 +22,27 @@ module Litestream
21
22
  # raised when a litestream command fails
22
23
  CommandFailedException = Class.new(StandardError)
23
24
 
25
+ # raised when a litestream command times out
26
+ CommandTimeoutException = Class.new(CommandFailedException)
27
+
28
+ module Output
29
+ class << self
30
+ def format(data)
31
+ return "" if data.nil? || data.empty?
32
+
33
+ headers = data.first.keys.map(&:to_s)
34
+ widths = headers.map.with_index { |h, i|
35
+ [h.length, data.map { |r| r[data.first.keys[i]].to_s.length }.max].max
36
+ }
37
+
38
+ format_str = widths.map { |w| "%-#{w}s" }.join(" ")
39
+ ([headers] + data.map(&:values)).map { |row|
40
+ sprintf(format_str, *row.map(&:to_s))
41
+ }.join("\n")
42
+ end
43
+ end
44
+ end
45
+
24
46
  class << self
25
47
  def platform
26
48
  [:cpu, :os].map { |m| Gem::Platform.local.send(m) }.join("-")
@@ -30,7 +52,10 @@ module Litestream
30
52
  litestream_install_dir = ENV["LITESTREAM_INSTALL_DIR"]
31
53
  if litestream_install_dir
32
54
  if File.directory?(litestream_install_dir)
33
- 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
34
59
  exe_path = litestream_install_dir
35
60
  exe_file = File.expand_path(File.join(litestream_install_dir, "litestream"))
36
61
  else
@@ -77,50 +102,62 @@ module Litestream
77
102
  exe_file
78
103
  end
79
104
 
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.
80
108
  def replicate(async: false, **argv)
81
- execute("replicate", argv, async: async, tabled_output: false)
109
+ cmd = prepare("replicate", argv)
110
+ run_replicate(cmd, async: async)
111
+ rescue CommandFailedException
112
+ raise
113
+ rescue
114
+ raise CommandFailedException, "Failed to execute `#{cmd.join(" ")}`"
82
115
  end
83
116
 
84
- def restore(database, async: false, **argv)
117
+ def restore(database, **argv)
85
118
  raise DatabaseRequiredException, "database argument is required for restore command, e.g. litestream:restore -- --database=path/to/database.sqlite" if database.nil?
86
- argv.stringify_keys!
87
119
 
88
- execute("restore", argv, database, async: async, tabled_output: false)
120
+ execute("restore", argv, database, tabled_output: false)
89
121
  end
90
122
 
91
- def databases(async: false, **argv)
92
- execute("databases", argv, async: async, tabled_output: true)
123
+ def databases(**argv)
124
+ execute("databases", argv)
93
125
  end
94
126
 
95
- def generations(database, async: false, **argv)
127
+ def generations(database, **argv)
96
128
  raise DatabaseRequiredException, "database argument is required for generations command, e.g. litestream:generations -- --database=path/to/database.sqlite" if database.nil?
97
129
 
98
- execute("generations", argv, database, async: async, tabled_output: true)
130
+ execute("generations", argv, database)
99
131
  end
100
132
 
101
- def snapshots(database, async: false, **argv)
133
+ def snapshots(database, **argv)
102
134
  raise DatabaseRequiredException, "database argument is required for snapshots command, e.g. litestream:snapshots -- --database=path/to/database.sqlite" if database.nil?
103
135
 
104
- execute("snapshots", argv, database, async: async, tabled_output: true)
136
+ execute("snapshots", argv, database)
105
137
  end
106
138
 
107
- def wal(database, async: false, **argv)
139
+ def wal(database, **argv)
108
140
  raise DatabaseRequiredException, "database argument is required for wal command, e.g. litestream:wal -- --database=path/to/database.sqlite" if database.nil?
109
141
 
110
- execute("wal", argv, database, async: async, tabled_output: true)
142
+ execute("wal", argv, database)
111
143
  end
112
144
 
113
145
  private
114
146
 
115
- def execute(command, argv = {}, database = nil, async: false, tabled_output: false)
116
- cmd = prepare(command, argv, database)
117
- results = run(cmd, async: async, tabled_output: tabled_output)
118
-
119
- if Array === results && results.one? && results[0]["level"] == "ERROR"
120
- raise CommandFailedException, "Failed to execute `#{cmd.join(" ")}`; Reason: #{results[0]["error"]}"
147
+ def execute(command, argv = {}, database = nil, tabled_output: true)
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
121
155
  else
122
- results
156
+ :raw
123
157
  end
158
+
159
+ cmd = prepare(command, argv, database)
160
+ run(cmd, output: output, timeout: timeout)
124
161
  end
125
162
 
126
163
  def prepare(command, argv = {}, database = nil)
@@ -132,28 +169,101 @@ module Litestream
132
169
 
133
170
  args = {
134
171
  "--config" => Litestream.config_path.to_s
135
- }.merge(argv.stringify_keys).to_a.flatten.compact
136
- 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)
137
174
  puts cmd.inspect if ENV["DEBUG"]
138
175
 
139
176
  cmd
140
177
  end
141
178
 
142
- def run(cmd, async: false, tabled_output: false)
143
- if async
144
- # To release the resources of the Ruby process, just fork and exit.
145
- # The forked process executes litestream and replaces itself.
146
- exec(*cmd) if fork.nil?
147
- else
148
- stdout = `#{cmd.join(" ")}`.chomp
149
- tabled_output ? text_table_to_hashes(stdout) : stdout.split("\n").map { Logfmt.parse(_1) }
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]}"
150
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]}"
151
233
  end
152
234
 
153
- def text_table_to_hashes(string)
154
- keys, *rows = string.split("\n").map { _1.split(/\s+/) }
235
+ def parse_table(stdout)
236
+ keys, *rows = stdout.strip.split("\n").map { _1.split(/\s+/) }
237
+ return [] unless keys
238
+
155
239
  rows.map { keys.zip(_1).to_h }
156
240
  end
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.
249
+ def run_replicate(cmd, async:)
250
+ if async
251
+ Process.spawn(*cmd)
252
+ else
253
+ # When running in-process, we capture output continuously and write to stdout.
254
+ IO.popen(cmd, err: [:child, :out]) do |io|
255
+ io.each_line { |line| puts line }
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
265
+ end
266
+ end
157
267
  end
158
268
  end
159
269
  end
@@ -5,29 +5,41 @@
5
5
  # or some other mechanism where the values are only available at runtime.
6
6
 
7
7
  Rails.application.configure do
8
- # An example of using Rails encrypted credentials to configure Litestream.
8
+ # Configure Litestream through environment variables. Use Rails encrypted credentials for secrets.
9
9
  # litestream_credentials = Rails.application.credentials.litestream
10
10
 
11
- # Replica-specific bucket location.
12
- # This will be your bucket's URL without the `https://` prefix.
11
+ # Replica-specific bucket location. This will be your bucket's URL without the `https://` prefix.
13
12
  # For example, if you used DigitalOcean Spaces, your bucket URL could look like:
13
+ #
14
14
  # https://myapp.fra1.digitaloceanspaces.com
15
+ #
15
16
  # And so you should set your `replica_bucket` to:
17
+ #
16
18
  # myapp.fra1.digitaloceanspaces.com
17
- # Litestream supports Azure Blog Storage, Backblaze B2, DigitalOcean Spaces,
18
- # Scaleway Object Storage, Google Cloud Storage, Linode Object Storage, and
19
- # any SFTP server.
20
- # In this example, we are using Rails encrypted credentials to store the URL to
21
- # our storage provider bucket.
19
+ #
22
20
  # config.litestream.replica_bucket = litestream_credentials&.replica_bucket
23
-
24
- # Replica-specific authentication key.
25
- # Litestream needs authentication credentials to access your storage provider bucket.
26
- # In this example, we are using Rails encrypted credentials to store the access key ID.
21
+ #
22
+ # Replica-specific authentication key. Litestream needs authentication credentials to access your storage provider bucket.
27
23
  # config.litestream.replica_key_id = litestream_credentials&.replica_key_id
28
-
29
- # Replica-specific secret key.
30
- # Litestream needs authentication credentials to access your storage provider bucket.
31
- # In this example, we are using Rails encrypted credentials to store the secret access key.
24
+ #
25
+ # Replica-specific secret key. Litestream needs authentication credentials to access your storage provider bucket.
32
26
  # config.litestream.replica_access_key = litestream_credentials&.replica_access_key
27
+ #
28
+ # Replica-specific region. Set the bucket’s region. Only used for AWS S3 & Backblaze B2.
29
+ # config.litestream.replica_region = "us-east-1"
30
+ #
31
+ # Replica-specific endpoint. Set the endpoint URL of the S3-compatible service. Only required for non-AWS services.
32
+ # config.litestream.replica_endpoint = "endpoint.your-objectstorage.com"
33
+
34
+ # Configure the default Litestream config path
35
+ # config.config_path = Rails.root.join("config", "litestream.yml")
36
+
37
+ # Configure the Litestream dashboard
38
+ #
39
+ # Set the default base controller class
40
+ # config.litestream.base_controller_class = "MyApplicationController"
41
+ #
42
+ # Set authentication credentials for Litestream dashboard
43
+ # config.litestream.username = litestream_credentials&.username
44
+ # config.litestream.password = litestream_credentials&.password
33
45
  end
@@ -1,3 +1,3 @@
1
1
  module Litestream
2
- VERSION = "0.13.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
 
@@ -33,8 +46,7 @@ module Litestream
33
46
  end
34
47
  end
35
48
 
36
- mattr_writer :username, :password, :queue, :replica_bucket, :replica_region, :replica_endpoint, :replica_key_id, :replica_access_key, :systemctl_command,
37
- :config_path
49
+ mattr_writer :username, :password, :queue, :replica_bucket, :replica_region, :replica_endpoint, :replica_key_id, :replica_access_key, :systemctl_command, :config_path
38
50
  mattr_accessor :base_controller_class, default: "::ApplicationController"
39
51
 
40
52
  class << self
@@ -164,30 +176,58 @@ module Litestream
164
176
  info
165
177
  end
166
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).
167
181
  def process_info
168
- litestream_replicate_ps = `ps -ax | grep litestream | grep replicate`
169
- exit_code = $?.exitstatus
170
- return unless exit_code.zero?
182
+ File.directory?("/proc/self") ? proc_process_info : ps_process_info
183
+ end
171
184
 
172
- info = {}
173
- litestream_replicate_ps.chomp.split("\n").each do |line|
174
- next unless line.include?("litestream replicate")
175
-
176
- pid, * = line.split(" ")
177
- info[:pid] = pid
178
- state, _, lstart = `ps -o "state,lstart" #{pid}`.chomp.split("\n").last.partition(/\s+/)
179
-
180
- info[:status] = case state[0]
181
- when "I" then "idle"
182
- when "R" then "running"
183
- when "S" then "sleeping"
184
- when "T" then "stopped"
185
- when "U" then "uninterruptible"
186
- when "Z" then "zombie"
187
- end
188
- 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
189
205
  end
190
- 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
191
231
  end
192
232
  end
193
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
@@ -12,83 +12,61 @@ namespace :litestream do
12
12
 
13
13
  desc 'Monitor and continuously replicate SQLite databases defined in your config file, for example `rake litestream:replicate -- -exec "foreman start"`'
14
14
  task replicate: :environment do
15
- options = {}
16
- if (separator_index = ARGV.index("--"))
17
- ARGV.slice(separator_index + 1, ARGV.length)
18
- .map { |pair| pair.split("=") }
19
- .each { |opt| options[opt[0]] = opt[1] || nil }
20
- end
21
- options.symbolize_keys!
15
+ options = parse_argv_options
22
16
 
23
- Litestream::Commands.replicate(async: true, **options)
17
+ Litestream::Commands.replicate(**options)
24
18
  end
25
19
 
26
20
  desc "Restore a SQLite database from a Litestream replica, for example `rake litestream:restore -- -database=storage/production.sqlite3`"
27
21
  task restore: :environment do
28
- options = {}
29
- if (separator_index = ARGV.index("--"))
30
- ARGV.slice(separator_index + 1, ARGV.length)
31
- .map { |pair| pair.split("=") }
32
- .each { |opt| options[opt[0]] = opt[1] || nil }
33
- end
34
- database = options.delete("--database") || options.delete("-database")
35
- options.symbolize_keys!
22
+ options = parse_argv_options
23
+ database = options.delete(:"--database") || options.delete(:"-database")
36
24
 
37
- Litestream::Commands.restore(database, async: true, **options)
25
+ puts Litestream::Commands.restore(database, **options)
38
26
  end
39
27
 
40
28
  desc "List all databases and associated replicas in the config file, for example `rake litestream:databases -- -no-expand-env`"
41
29
  task databases: :environment do
42
- options = {}
43
- if (separator_index = ARGV.index("--"))
44
- ARGV.slice(separator_index + 1, ARGV.length)
45
- .map { |pair| pair.split("=") }
46
- .each { |opt| options[opt[0]] = opt[1] || nil }
47
- end
48
- options.symbolize_keys!
30
+ options = parse_argv_options
49
31
 
50
- Litestream::Commands.databases(async: true, **options)
32
+ puts Litestream::Commands::Output.format(Litestream::Commands.databases(**options))
51
33
  end
52
34
 
53
35
  desc "List all generations for a database or replica, for example `rake litestream:generations -- -database=storage/production.sqlite3`"
54
36
  task generations: :environment do
55
- options = {}
56
- if (separator_index = ARGV.index("--"))
57
- ARGV.slice(separator_index + 1, ARGV.length)
58
- .map { |pair| pair.split("=") }
59
- .each { |opt| options[opt[0]] = opt[1] || nil }
60
- end
61
- database = options.delete("--database") || options.delete("-database")
62
- options.symbolize_keys!
37
+ options = parse_argv_options
38
+ database = options.delete(:"--database") || options.delete(:"-database")
63
39
 
64
- Litestream::Commands.generations(database, async: true, **options)
40
+ puts Litestream::Commands::Output.format(Litestream::Commands.generations(database, **options))
65
41
  end
66
42
 
67
43
  desc "List all snapshots for a database or replica, for example `rake litestream:snapshots -- -database=storage/production.sqlite3`"
68
44
  task snapshots: :environment do
69
- options = {}
70
- if (separator_index = ARGV.index("--"))
71
- ARGV.slice(separator_index + 1, ARGV.length)
72
- .map { |pair| pair.split("=") }
73
- .each { |opt| options[opt[0]] = opt[1] || nil }
74
- end
75
- database = options.delete("--database") || options.delete("-database")
76
- options.symbolize_keys!
45
+ options = parse_argv_options
46
+ database = options.delete(:"--database") || options.delete(:"-database")
77
47
 
78
- Litestream::Commands.snapshots(database, async: true, **options)
48
+ puts Litestream::Commands::Output.format(Litestream::Commands.snapshots(database, **options))
79
49
  end
80
50
 
81
51
  desc "List all wal files for a database or replica, for example `rake litestream:wal -- -database=storage/production.sqlite3`"
82
52
  task wal: :environment do
53
+ options = parse_argv_options
54
+ database = options.delete(:"--database") || options.delete(:"-database")
55
+
56
+ puts Litestream::Commands::Output.format(
57
+ Litestream::Commands.wal(database, **options)
58
+ )
59
+ end
60
+
61
+ private
62
+
63
+ def parse_argv_options
83
64
  options = {}
84
65
  if (separator_index = ARGV.index("--"))
85
66
  ARGV.slice(separator_index + 1, ARGV.length)
86
67
  .map { |pair| pair.split("=") }
87
68
  .each { |opt| options[opt[0]] = opt[1] || nil }
88
69
  end
89
- database = options.delete("--database") || options.delete("-database")
90
70
  options.symbolize_keys!
91
-
92
- Litestream::Commands.wal(database, async: true, **options)
93
71
  end
94
72
  end
metadata CHANGED
@@ -1,28 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: litestream
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.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-03 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: logfmt
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - ">="
17
- - !ruby/object:Gem::Version
18
- version: 0.0.10
19
- type: :runtime
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - ">="
24
- - !ruby/object:Gem::Version
25
- version: 0.0.10
26
12
  - !ruby/object:Gem::Dependency
27
13
  name: sqlite3
28
14
  requirement: !ruby/object:Gem::Requirement
@@ -37,6 +23,7 @@ dependencies:
37
23
  - - ">="
38
24
  - !ruby/object:Gem::Version
39
25
  version: '0'
26
+ force_ruby_platform: false
40
27
  - !ruby/object:Gem::Dependency
41
28
  name: actionpack
42
29
  requirement: !ruby/object:Gem::Requirement
@@ -51,6 +38,7 @@ dependencies:
51
38
  - - ">="
52
39
  - !ruby/object:Gem::Version
53
40
  version: '7.0'
41
+ force_ruby_platform: false
54
42
  - !ruby/object:Gem::Dependency
55
43
  name: actionview
56
44
  requirement: !ruby/object:Gem::Requirement
@@ -65,8 +53,9 @@ dependencies:
65
53
  - - ">="
66
54
  - !ruby/object:Gem::Version
67
55
  version: '7.0'
56
+ force_ruby_platform: false
68
57
  - !ruby/object:Gem::Dependency
69
- name: activesupport
58
+ name: activejob
70
59
  requirement: !ruby/object:Gem::Requirement
71
60
  requirements:
72
61
  - - ">="
@@ -79,8 +68,9 @@ dependencies:
79
68
  - - ">="
80
69
  - !ruby/object:Gem::Version
81
70
  version: '7.0'
71
+ force_ruby_platform: false
82
72
  - !ruby/object:Gem::Dependency
83
- name: activejob
73
+ name: activesupport
84
74
  requirement: !ruby/object:Gem::Requirement
85
75
  requirements:
86
76
  - - ">="
@@ -93,6 +83,7 @@ dependencies:
93
83
  - - ">="
94
84
  - !ruby/object:Gem::Version
95
85
  version: '7.0'
86
+ force_ruby_platform: false
96
87
  - !ruby/object:Gem::Dependency
97
88
  name: railties
98
89
  requirement: !ruby/object:Gem::Requirement
@@ -107,20 +98,7 @@ dependencies:
107
98
  - - ">="
108
99
  - !ruby/object:Gem::Version
109
100
  version: '7.0'
110
- - !ruby/object:Gem::Dependency
111
- name: rubyzip
112
- requirement: !ruby/object:Gem::Requirement
113
- requirements:
114
- - - ">="
115
- - !ruby/object:Gem::Version
116
- version: '0'
117
- type: :development
118
- prerelease: false
119
- version_requirements: !ruby/object:Gem::Requirement
120
- requirements:
121
- - - ">="
122
- - !ruby/object:Gem::Version
123
- version: '0'
101
+ force_ruby_platform: false
124
102
  - !ruby/object:Gem::Dependency
125
103
  name: rails
126
104
  requirement: !ruby/object:Gem::Requirement
@@ -136,7 +114,7 @@ dependencies:
136
114
  - !ruby/object:Gem::Version
137
115
  version: '0'
138
116
  - !ruby/object:Gem::Dependency
139
- name: sqlite3
117
+ name: rubyzip
140
118
  requirement: !ruby/object:Gem::Requirement
141
119
  requirements:
142
120
  - - ">="
@@ -200,7 +178,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
200
178
  - !ruby/object:Gem::Version
201
179
  version: '0'
202
180
  requirements: []
203
- rubygems_version: 3.6.3
181
+ rubygems_version: 3.6.7
204
182
  specification_version: 4
205
183
  summary: Integrate Litestream with the RubyGems infrastructure.
206
184
  test_files: []