solid_backup 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ee957011ccc278fb06096635c8b158939f283538bb5ccc25869ba07d3bb7bb5a
4
+ data.tar.gz: ea9cdbdbd9e9ae3ae0fdb4b252b9db1c412e80d3288258c8ccf4062e59a70b66
5
+ SHA512:
6
+ metadata.gz: '0802038b0aeac7c0b0bebbf1b8b6d68054b52621ef1c50c9c21f1a640866fb2f1e51ee9a3d98bc6fef80a5b63ab85c4ed9cec500e7ba86ddc24bf45fe65d3c33'
7
+ data.tar.gz: 82bec8bad5efe105b5ad7247b6f0754fda99381baafdcaff36704c249a36996c86bf0d1e65e1d4cd81a896a03513cc631a0c4540dc6b501115b5b3134f0936c5
data/MIT-LICENSE.txt ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2026 Greg Navis
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # Solid Backup
2
+
3
+ Solid Backup provides simple backups for SQLite databases in Ruby on Rails apps.
4
+
5
+ ## Installation
6
+
7
+ In order to install Solid Backup run:
8
+
9
+ 1. `bundle add solid_backup`.
10
+ 2. `bin/rails generate solid_backup:install`.
11
+
12
+ The generator will go over all production SQLite databases and ask you whether
13
+ you want to back up each one of them. By default, it uses the `API` mode. It'll
14
+ also create `storage/backups` and install the Puma plugin.
15
+
16
+ That's it! Read the following section to learn how to customize the backup
17
+ process.
18
+
19
+ ## Configuration
20
+
21
+ Solid Backup **REQUIRES** each SQLite production database to have a backup mode set,
22
+ with the goal of ensuring all databases were taken into consideration and none
23
+ will lack backups due to omission. There are three database backup modes:
24
+
25
+ - `SolidBackup::Backup::None` - don't back up.
26
+ - `SolidBackup::Backup::API` - use [the SQLite3 backup API].
27
+ - `SolidBackup::Backup::VacuumInto` - use [`VACUUM INTO`].
28
+
29
+ Modes other than `SolidBackup::Backup::None` take the following parameters:
30
+
31
+ - `destination` - a `strftime` template used to generate backup file names; the
32
+ directory under which the files are placed must already exist
33
+ and be writeable.
34
+ - `interval_in_minutes` - the number of minutes to the beginning of the next
35
+ backup after the previous one finished.
36
+
37
+ Additionally, `SolidBackup::Backup::API` takes the following parameters:
38
+
39
+ - `step` - the number of database pages to back up in one step.
40
+ - `wait` - how many seconds to wait in case the backup process run into a lock.
41
+
42
+ The following example configuration file demonstrates how to use each backup
43
+ mode in practice:
44
+
45
+ ```ruby
46
+ SolidBackup.configure do |config|
47
+ # Back up the primary database using the SQLite backup API.
48
+ config.backup "primary",
49
+ SolidBackup::Backup::API,
50
+ destination: "storage/backups/primary_%Y%m%dT%H%M%S.sqlite3",
51
+ interval_in_minutes: 15, # Start the next backup 15 minutes after the previous one.
52
+ step: 100, # Back up in 100-page increments.
53
+ wait: 0.1 # Wait 0.1 seconds if a lock interrupts a backup step.
54
+
55
+ # Don't back up the cache database.
56
+ config.backup "cache", SolidBackup::Backup::None
57
+
58
+ # Back up the queue database using VACUUM INTO.
59
+ config.backup "queue",
60
+ SolidBackup::Backup::VacuumInto,
61
+ destination: "storage/backups/queue_%Y%m%dT%H%M%S.sqlite3",
62
+ interval_in_minutes: 60 # Start the next backup 60 minutes after the previous one.
63
+ end
64
+ ```
65
+
66
+ ## Puma plugin
67
+
68
+ Solid Backup ships with a Puma plugin that runs the backup process in the
69
+ background on schedule. The install generator installs it automatically, but if
70
+ you need to install it manually you can add the following line to `puma.rb`:
71
+
72
+ ```ruby
73
+ plugin "solid_backup"
74
+ ```
75
+
76
+ ## Ruby and Rails Compatibility Policy
77
+
78
+ Solid Backup supports Rails versions supported by the Rails Core Team and Ruby
79
+ versions supported by all supported Rails versions.
80
+
81
+ ## Author
82
+
83
+ This gem was created and is maintained by [Greg Navis].
84
+
85
+ [Greg Navis]: https://www.gregnavis.com/
86
+ [the SQLite3 backup API]: https://sqlite.org/c3ref/backup_finish.html
87
+ [`VACUUM INTO`]: https://sqlite.org/lang_vacuum.html#vacuuminto
@@ -0,0 +1,15 @@
1
+ require "puma/plugin"
2
+ require "solid_backup"
3
+
4
+ Puma::Plugin.create do
5
+ def start(launcher)
6
+ return if SolidBackup.disabled?
7
+
8
+ in_background do
9
+ loop do
10
+ sleep(1)
11
+ SolidBackup.tick
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,38 @@
1
+ class SolidBackup::Backup::API < SolidBackup::Backup
2
+ def initialize(step:, wait:, **)
3
+ super(**)
4
+
5
+ self.step = Integer(step)
6
+ self.wait = Float(wait)
7
+ end
8
+
9
+ private
10
+
11
+ attr_accessor :step, :wait
12
+
13
+ def do_perform(backup_path)
14
+ with_connection do |connection|
15
+ source = connection.raw_connection
16
+ destination = SQLite3::Database.new(backup_path)
17
+
18
+ backup = SQLite3::Backup.new(destination, "main", source, "main")
19
+
20
+ loop do
21
+ result = backup.step(step)
22
+ case result
23
+ when SQLite3::Constants::ErrorCode::DONE
24
+ break
25
+ when SQLite3::Constants::ErrorCode::OK
26
+ next
27
+ when SQLite3::Constants::ErrorCode::BUSY, SQLite3::Constants::ErrorCode::LOCKED
28
+ sleep(wait)
29
+ else
30
+ raise "SQLite backup failed with #{result.inspect}"
31
+ end
32
+ end
33
+
34
+ backup.finish
35
+ destination.close
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,25 @@
1
+ # None is a special case: all other backup methods need a destination and
2
+ # interval, but it doesn't make sense to pass them to None. That's why it
3
+ # overrides:
4
+ #
5
+ # - tick - to avoid the countdown
6
+ # - perform - to avoid creating lock files and emitting instrumentation events
7
+ # - destination and interval_in_minutes - to allow nil values
8
+ class SolidBackup::Backup::None < SolidBackup::Backup
9
+ def initialize(name:)
10
+ super(name:, destination: nil, interval_in_minutes: 0)
11
+ end
12
+
13
+ def tick
14
+ end
15
+
16
+ def perform
17
+ end
18
+
19
+ private
20
+
21
+ attr_writer :destination, :interval_in_minutes
22
+
23
+ def do_perform(backup_path)
24
+ end
25
+ end
@@ -0,0 +1,9 @@
1
+ class SolidBackup::Backup::VacuumInto < SolidBackup::Backup
2
+ private
3
+
4
+ def do_perform(backup_path)
5
+ with_connection do |connection|
6
+ connection.exec_query("VACUUM INTO ?", "Solid Backup", [backup_path.to_s])
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,101 @@
1
+ module SolidBackup
2
+ class Backup
3
+ attr_reader :name
4
+
5
+ def initialize(name:, destination:, interval_in_minutes:)
6
+ self.name = name
7
+ self.destination = destination
8
+ self.interval_in_minutes = interval_in_minutes
9
+
10
+ reset_countdown
11
+ end
12
+
13
+ def tick
14
+ if countdown > 0
15
+ self.countdown -= 1
16
+ end
17
+
18
+ if countdown <= 0
19
+ perform
20
+ reset_countdown
21
+ end
22
+ end
23
+
24
+ def perform
25
+ ActiveSupport::Notifications.instrument("backup.solid_backup", name:) do
26
+ lock do
27
+ do_perform(Time.now.utc.strftime(destination.to_s))
28
+ end
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ attr_accessor :countdown
35
+ attr_writer :name
36
+ attr_reader :destination, :interval_in_minutes
37
+
38
+ def interval_in_minutes=(value)
39
+ if !value.is_a?(Integer) && value <= 0
40
+ raise TypeError.new(<<~ERROR)
41
+ invalid backup configuration for #{name}: interval_in_minutes is set to #{interval_in_minutes.inspect}, but should be set to a positive integer
42
+ ERROR
43
+ end
44
+
45
+ @interval_in_minutes = value
46
+ end
47
+
48
+ def destination=(value)
49
+ value = Pathname(value)
50
+ directory = value.dirname
51
+
52
+ if !directory.directory?
53
+ raise "invalid backup configuration for #{name}: destination directory #{directory} must exist"
54
+ end
55
+
56
+ test_path = directory / "solid_backup.txt"
57
+ begin
58
+ test_path.write("Solid Backup Test")
59
+ rescue Errno::EACCESS
60
+ raise "invalid backup configuration for #{name}: destination directory #{directory} must be writeable"
61
+ ensure
62
+ test_path.unlink
63
+ end
64
+
65
+ @destination = value
66
+ end
67
+
68
+ def reset_countdown
69
+ self.countdown = 60 * interval_in_minutes
70
+ end
71
+
72
+ def do_perform(backup_path)
73
+ raise "must be implemented by a subclass"
74
+ end
75
+
76
+ def connection_pool
77
+ ActiveRecord::Base.connection_handler.establish_connection(name.to_sym)
78
+ end
79
+
80
+ delegate :with_connection, to: :connection_pool
81
+
82
+ def lock
83
+ path = destination.dirname / ".solid_backup.#{name}.lock"
84
+
85
+ File.open(path, File::RDWR | File::CREAT) do |f|
86
+ if f.flock(File::LOCK_EX | File::LOCK_NB)
87
+ begin
88
+ yield
89
+ ensure
90
+ f.flock(File::LOCK_UN)
91
+ end
92
+ end
93
+ end
94
+
95
+ begin
96
+ File.unlink(path)
97
+ rescue ERRNO::ENOENT
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,50 @@
1
+ module SolidBackup
2
+ class Configuration
3
+ attr_accessor :enabled
4
+ attr_reader :algorithms
5
+
6
+ def initialize
7
+ self.enabled = nil
8
+ self.algorithms = []
9
+ end
10
+
11
+ def backup(name, algorithm_class, **algorithm_arguments)
12
+ name = name.to_s
13
+ if algorithms.find { it.name == name }
14
+ raise ArgumentError.new("#{name} has had backups configured already")
15
+ end
16
+
17
+ algorithms << algorithm_class.new(name:, **algorithm_arguments)
18
+ end
19
+
20
+ def validate!
21
+ if [true, false].exclude?(enabled)
22
+ raise TypeError.new("enabled is set to #{enabled.inspect}, but allowed values are true or false")
23
+ end
24
+
25
+ configured_database_names = algorithms.map(&:name)
26
+ all_database_names = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).select do |db_config|
27
+ db_config.adapter == "sqlite3"
28
+ end.map(&:name)
29
+
30
+ if (missing_databases = all_database_names - configured_database_names).present?
31
+ raise "SQLite databases missing from backup configuration: #{missing_databases.join(", ")}"
32
+ end
33
+ if (unknown_databases = configured_database_names - all_database_names).present?
34
+ raise "unknown SQLite databases present in backup configuration: #{unknown_databases.join(", ")}"
35
+ end
36
+ end
37
+
38
+ def enabled?
39
+ enabled
40
+ end
41
+
42
+ def disabled?
43
+ !enabled?
44
+ end
45
+
46
+ private
47
+
48
+ attr_writer :algorithms
49
+ end
50
+ end
@@ -0,0 +1,54 @@
1
+ require "rails/generators"
2
+
3
+ module SolidBackup
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ desc "Creates a Solid Backup initializer"
9
+
10
+ def copy_initializer
11
+ backups = []
12
+
13
+ ActiveRecord::Base.configurations.configs_for(env_name: "production").each do |db_config|
14
+ backups << if yes?("Back up #{db_config.name} in production?")
15
+ <<~CODE.chomp
16
+ config.backup #{db_config.name.inspect},
17
+ SolidBackup::Backup::API,
18
+ destination: "storage/backups/#{db_config.name}_%Y%m%dT%H%M%S.sqlite3",
19
+ interval_in_minutes: 60,
20
+ step: 100,
21
+ wait: 0.1
22
+ CODE
23
+ else
24
+ "config.backup #{db_config.name.inspect}, SolidBackup::Backup::None"
25
+ end
26
+ end
27
+
28
+ initializer "solid_backup.rb", <<~CODE
29
+ SolidBackup.configure do |config|
30
+ # Enable or disable Solid Backup globally.
31
+ config.enabled = Rails.env.production?
32
+
33
+ # Backup configuration for each database.
34
+ #
35
+ # IMPORTANT: all databases must be explicitly configured, even if they're not
36
+ # backed up, to avoid accidentally excluding new databases from backups.
37
+ #{backups.join("\n").indent(2)}
38
+ end
39
+ CODE
40
+
41
+ insert_into_file "config/puma.rb", <<~CODE
42
+
43
+ # Run Solid Backup in the background.
44
+ plugin :solid_backup
45
+ CODE
46
+
47
+ empty_directory "storage/backups"
48
+ create_file "storage/backups/.keep"
49
+
50
+ insert_into_file ".gitignore", "!/storage/backups/.keep\n", after: "!/storage/.keep\n"
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,13 @@
1
+ require "rails"
2
+
3
+ module SolidBackup
4
+ class Railtie < Rails::Railtie
5
+ generators do
6
+ require_relative "generators/install_generator"
7
+ end
8
+
9
+ config.after_initialize do
10
+ SolidBackup.configuration&.validate!
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,3 @@
1
+ module SolidBackup
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,28 @@
1
+ require "solid_backup/backup"
2
+ require "solid_backup/backup/none"
3
+ require "solid_backup/backup/api"
4
+ require "solid_backup/backup/vacuum_into"
5
+ require "solid_backup/configuration"
6
+ require "solid_backup/version"
7
+ require "solid_backup/railtie"
8
+
9
+ module SolidBackup
10
+ class << self
11
+ delegate :enabled?, :disabled?, to: :configuration
12
+
13
+ attr_reader :configuration
14
+
15
+ def configure
16
+ self.configuration ||= Configuration.new
17
+ yield(configuration)
18
+ end
19
+
20
+ def tick
21
+ configuration.algorithms.each(&:tick)
22
+ end
23
+
24
+ private
25
+
26
+ attr_writer :configuration
27
+ end
28
+ end
metadata ADDED
@@ -0,0 +1,139 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: solid_backup
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Greg Navis
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.2'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '8.2'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.2'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '8.2'
32
+ - !ruby/object:Gem::Dependency
33
+ name: railties
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '7.2'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '8.2'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '7.2'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '8.2'
52
+ - !ruby/object:Gem::Dependency
53
+ name: sqlite3
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '1.4'
59
+ type: :runtime
60
+ prerelease: false
61
+ version_requirements: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '1.4'
66
+ - !ruby/object:Gem::Dependency
67
+ name: rake
68
+ requirement: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - "~>"
71
+ - !ruby/object:Gem::Version
72
+ version: '13.4'
73
+ type: :development
74
+ prerelease: false
75
+ version_requirements: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - "~>"
78
+ - !ruby/object:Gem::Version
79
+ version: '13.4'
80
+ - !ruby/object:Gem::Dependency
81
+ name: standard
82
+ requirement: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - "~>"
85
+ - !ruby/object:Gem::Version
86
+ version: '1.56'
87
+ type: :development
88
+ prerelease: false
89
+ version_requirements: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - "~>"
92
+ - !ruby/object:Gem::Version
93
+ version: '1.56'
94
+ description: Solid Backup makes it easy to backup SQLite databases using VACUUM INTO
95
+ or the backup API.
96
+ email:
97
+ - contact@gregnavis.com
98
+ executables: []
99
+ extensions: []
100
+ extra_rdoc_files: []
101
+ files:
102
+ - MIT-LICENSE.txt
103
+ - README.md
104
+ - lib/puma/plugin/solid_backup.rb
105
+ - lib/solid_backup.rb
106
+ - lib/solid_backup/backup.rb
107
+ - lib/solid_backup/backup/api.rb
108
+ - lib/solid_backup/backup/none.rb
109
+ - lib/solid_backup/backup/vacuum_into.rb
110
+ - lib/solid_backup/configuration.rb
111
+ - lib/solid_backup/generators/install_generator.rb
112
+ - lib/solid_backup/railtie.rb
113
+ - lib/solid_backup/version.rb
114
+ homepage: https://github.com/gregnavis/solid_backup
115
+ licenses:
116
+ - MIT
117
+ metadata:
118
+ source_code_uri: https://github.com/gregnavis/solid_backup
119
+ bug_tracker_uri: https://github.com/gregnavis/solid_backup/issues
120
+ rubygems_mfa_required: 'true'
121
+ rdoc_options: []
122
+ require_paths:
123
+ - lib
124
+ required_ruby_version: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - ">="
127
+ - !ruby/object:Gem::Version
128
+ version: 3.1.0
129
+ required_rubygems_version: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - ">="
132
+ - !ruby/object:Gem::Version
133
+ version: '0'
134
+ requirements: []
135
+ rubygems_version: 4.0.10
136
+ specification_version: 4
137
+ summary: Solid Backup provides simple backups for SQLite databases in Ruby on Rails
138
+ apps.
139
+ test_files: []