activerecord_hoarder 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +14 -0
  3. data/.rspec +2 -0
  4. data/.travis.yml +5 -0
  5. data/Gemfile +18 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +62 -0
  8. data/Rakefile +6 -0
  9. data/activerecord_hoarder.gemspec +27 -0
  10. data/bin/console +29 -0
  11. data/bin/setup +8 -0
  12. data/config/activerecord_hoarder.yml.template +6 -0
  13. data/config/dbspec.yml.template +2 -0
  14. data/config/dbspec_rspec.yml.template +2 -0
  15. data/example/example.rb +3 -0
  16. data/example/fixture.rb +6 -0
  17. data/example/schema.rb +8 -0
  18. data/lib/activerecord_hoarder/aws_s3_storage.rb +64 -0
  19. data/lib/activerecord_hoarder/batch.rb +26 -0
  20. data/lib/activerecord_hoarder/batch_archiver.rb +16 -0
  21. data/lib/activerecord_hoarder/core.rb +13 -0
  22. data/lib/activerecord_hoarder/record_collector.rb +67 -0
  23. data/lib/activerecord_hoarder/record_query.rb +85 -0
  24. data/lib/activerecord_hoarder/restore.rb +16 -0
  25. data/lib/activerecord_hoarder/serializer.rb +9 -0
  26. data/lib/activerecord_hoarder/storage.rb +27 -0
  27. data/lib/activerecord_hoarder/storage_error.rb +4 -0
  28. data/lib/activerecord_hoarder/storage_key.rb +18 -0
  29. data/lib/activerecord_hoarder/storages.rb +20 -0
  30. data/lib/activerecord_hoarder/version.rb +3 -0
  31. data/lib/activerecord_hoarder.rb +21 -0
  32. data/spec/activerecord_hoarder/aws_s3_storage_spec.rb +26 -0
  33. data/spec/activerecord_hoarder/batch_archiver_spec.rb +10 -0
  34. data/spec/activerecord_hoarder/core_spec.rb +13 -0
  35. data/spec/activerecord_hoarder/serializer_spec.rb +7 -0
  36. data/spec/activerecord_hoarder/storage_spec.rb +29 -0
  37. data/spec/activerecord_hoarder_spec.rb +168 -0
  38. data/spec/factories/examples.rb +40 -0
  39. data/spec/spec_helper.rb +15 -0
  40. data/spec/support/0_rspec_configuration.rb +14 -0
  41. data/spec/support/1_active_record_configuration.rb +1 -0
  42. data/spec/support/2_database_content.rb +14 -0
  43. data/spec/support/3_test_storage.rb +7 -0
  44. metadata +162 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 476f76a46fae44bba761080f33f0c5073733de0e
4
+ data.tar.gz: 5040b1ce780046408e8ab065d18452e9ff6803b1
5
+ SHA512:
6
+ metadata.gz: 195f314b03bf2f5f0e2f82da4a9c409b03ca4896d39e1bb055a8d3090d956b5ad929a69e0c1ab9015f6c5726233318062f364e2c392ccc0b58fcf662f7740370
7
+ data.tar.gz: ebd4a813bb3de31d7e61f962b4ff1b5098f71857dda2d016782c91227d22725de4c31dc45c99579de60b5e2f7f374cbde7fb2c6556f6fd6e5adf4bdc7abca75d
data/.gitignore ADDED
@@ -0,0 +1,14 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+
11
+ # rspec failure tracking
12
+ .rspec_status
13
+ config/*.yml
14
+ *.sqlite3
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
data/.travis.yml ADDED
@@ -0,0 +1,5 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.4.1
5
+ before_install: gem install bundler -v 1.15.4
data/Gemfile ADDED
@@ -0,0 +1,18 @@
1
+ source "https://rubygems.org"
2
+
3
+ repo_name = "gem_batch_archiving"
4
+ git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
5
+
6
+ gem 'aws-sdk-s3', '~> 1'
7
+
8
+ group :test do
9
+ gem 'factory_girl_rails'
10
+ gem 'sqlite3'
11
+ gem 'timecop'
12
+ end
13
+
14
+ group :development do
15
+ gem 'sqlite3'
16
+ end
17
+
18
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2017 ACCESS MARKETING & COMMUNICATION LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # Activerecord Hoarder
2
+
3
+ hoard records
4
+
5
+ ## 1 Use
6
+
7
+ ### 1.1 make model a hoarder
8
+ ```
9
+ class ExampleModel < ActiveRecord::Base
10
+ acts_as_hoarder
11
+ end
12
+ ```
13
+
14
+ ### 1.2 hoarding records
15
+ from console:
16
+ ```
17
+ ExampleModel.hoard
18
+ ```
19
+ will create S3 entries with keys: `<bucket_sub_dir>/<table_name = example_models>/<year>/<month>/<year>-<month>-<day>.json` and json formatted content
20
+
21
+ ### 1.3 restoring records
22
+ from console:
23
+ ```
24
+ ExampleModel.restore_date(Date.new(<Y>,<m>,<d>))
25
+ ```
26
+
27
+ ## 2 Development
28
+
29
+ ### 2.0 initial setup
30
+
31
+ Make a clone. Make a branch. Install dependencies.
32
+
33
+ ### 2.1 playing around
34
+
35
+ #### Configure database
36
+
37
+ Create config file from template (`cp config/dbspec.yml.template config/dbspec.yml`). Change database from `postgresql` to `sqlite3` and database name from `activerecord_hoarder` to `<as_desired>.sqlite3`.
38
+
39
+ #### Configure archive
40
+
41
+ Create config file from template (`cp config/activerecord_hoarder.yml.template config/activerecord_hoarder.yml`). Add your S3 credentials `access_key_id` and `secret_access_key` for target bucket `bucket`. Change `region` if necessary. If you want, change `acl` and add `bucket_sub_dir`.
42
+
43
+ #### Hop into sandbox
44
+ ```
45
+ bundler exec bin/console
46
+ ```
47
+
48
+ #### bin/example
49
+ Convenience functionality
50
+ - `require_relative "bin/example/schema"` for creating an example table `examples`
51
+ - `require_relative "bin/example/example"` for an example archivable model `Example`
52
+ - `require_relative "bin/example/fixture"` for a factory method `create_examples(count, start: 0, deleted: true)` for creating examples
53
+
54
+ ### 2.2 testing it
55
+
56
+ #### Configure test database
57
+ Create config file from template (`cp config/dbspec_rspec.yml.template config/dbspec_rspec.yml`). Modify settings if you want.
58
+
59
+ #### Run tests
60
+ ```
61
+ bundler exec rspec spec
62
+ ```
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,27 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "activerecord_hoarder/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "activerecord_hoarder"
8
+ spec.version = ActiverecordHoarder::VERSION
9
+ spec.authors = ["Matthias Engh"]
10
+ spec.email = ["matthias@wescrimmage.com"]
11
+
12
+ spec.summary = %q{hoards records}
13
+ spec.description = %q{extend active records with simple archiving mechanics}
14
+ spec.homepage = "https://github.com/Scrimmage/gem_batch_archiving"
15
+ spec.license = "MIT"
16
+
17
+ spec.files = `git ls-files -z`.split("\x0")
18
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
19
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
20
+ spec.require_paths = ["lib"]
21
+
22
+ spec.add_dependency "activerecord", [">= 4.2", "< 6.0"]
23
+
24
+ spec.add_development_dependency "bundler", "~> 1.15"
25
+ spec.add_development_dependency "rake", "~> 10.0"
26
+ spec.add_development_dependency "rspec", "~> 3.0"
27
+ end
data/bin/console ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+
5
+ # require dependencies from gemspec
6
+ require "active_record"
7
+ require "aws-sdk-s3"
8
+ require "pp"
9
+ require "yaml"
10
+
11
+ dbspec_location = "config/dbspec.yml"
12
+
13
+ if File.exist?(dbspec_location)
14
+ dbspec = YAML.load_file(dbspec_location)
15
+ ActiveRecord::Base.establish_connection(dbspec)
16
+ ActiveRecord::Base.connection
17
+ else
18
+ raise "database configuration file #{dbspec_location} not found"
19
+ end
20
+
21
+ require "activerecord_hoarder"
22
+
23
+ storage_config_path = "config/activerecord_hoarder.yml"
24
+ raise "storage configuration file #{storage_config_path}` not found" if !File.exists?(storage_config_path)
25
+ storage_options = YAML.load_file(storage_config_path)
26
+ ::ActiverecordHoarder::Storage.configure(storage: :aws_s3, storage_options: storage_options)
27
+
28
+ require "irb"
29
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,6 @@
1
+ access_key_id:
2
+ acl: private
3
+ bucket:
4
+ bucket_sub_dir:
5
+ region: us-east-1
6
+ secret_access_key:
@@ -0,0 +1,2 @@
1
+ adapter: postgresql
2
+ database: activerecord_hoarder
@@ -0,0 +1,2 @@
1
+ adapter: sqlite3
2
+ database: activerecord_hoarder_rspec
@@ -0,0 +1,3 @@
1
+ class Example < ActiveRecord::Base
2
+ acts_as_hoarder
3
+ end
@@ -0,0 +1,6 @@
1
+ def create_examples(count, start: 0, deleted: true)
2
+ (1..count).each do |n|
3
+ Example.create(content: "example: #{n}", created_at: (n+start).hours.ago, deleted_at: Time.now)
4
+ end
5
+ nil
6
+ end
data/example/schema.rb ADDED
@@ -0,0 +1,8 @@
1
+ ActiveRecord::Schema.define do
2
+ create_table "examples", force: true do |t|
3
+ t.datetime "created_at"
4
+ t.datetime "updated_at"
5
+ t.datetime "deleted_at"
6
+ t.string "content"
7
+ end
8
+ end
@@ -0,0 +1,64 @@
1
+ module ActiverecordHoarder
2
+ class AwsS3
3
+ DEFAULT_ACL = "private"
4
+ OPTION_CONTENT_ACCESS = "acl"
5
+ OPTION_SUB_DIR = "bucket_sub_dir"
6
+ OPTION_BUCKET = "bucket"
7
+ OPTION_ACCESS_KEY_ID = "access_key_id"
8
+ OPTIONS_SECRET_ACCESS_KEY = "secret_access_key"
9
+ OPTION_REGION = "region"
10
+
11
+ attr_reader :storage_options
12
+
13
+ def initialize(table_name, storage_options)
14
+ @storage_options = storage_options
15
+
16
+ if storage_options[OPTION_SUB_DIR].blank?
17
+ @key_prefix = table_name
18
+ else
19
+ @key_prefix = File.join(storage_options[OPTION_SUB_DIR], table_name)
20
+ end
21
+ end
22
+
23
+ def fetch_data(key)
24
+ full_key = key_with_prefix(key)
25
+ begin
26
+ response = s3_client.get_object(bucket: s3_bucket, key: full_key)
27
+ rescue Aws::S3::Errors::NoSuchKey => e
28
+ raise ::ActiverecordHoarder::StorageError.new("fetch_data erred with '#{e.class}':'#{e.message}'' trying to access '#{full_key}'' in bucket: '#{s3_bucket}'")
29
+ end
30
+ response.body
31
+ end
32
+
33
+ def store_data(batch)
34
+ full_key = key_with_prefix(batch.key.to_s)
35
+
36
+ s3_client.put_object(bucket: s3_bucket, body: batch.content_string, key: full_key, acl: s3_acl)
37
+ true
38
+ end
39
+
40
+ private
41
+
42
+ def key_with_prefix(key)
43
+ File.join(@key_prefix, key.to_s)
44
+ end
45
+
46
+ def s3_acl
47
+ storage_options[OPTION_CONTENT_ACCESS] || DEFAULT_ACL
48
+ end
49
+
50
+ def s3_bucket
51
+ storage_options[OPTION_BUCKET]
52
+ end
53
+
54
+ def s3_client
55
+ @s3_client ||= begin
56
+ access_key_id = storage_options[OPTION_ACCESS_KEY_ID] or raise StorageError.new("access_key_id missing")
57
+ secret_access_key = storage_options[OPTIONS_SECRET_ACCESS_KEY] or raise StorageError.new("secret_access_key missing")
58
+ credentials = Aws::Credentials.new(access_key_id, secret_access_key)
59
+ region = storage_options[OPTION_REGION] or raise StorageError.new("region missing")
60
+ Aws::S3::Client.new(credentials: credentials, region: region)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,26 @@
1
+ module ::ActiverecordHoarder
2
+ class Batch
3
+ RECORD_DATE_FIELD = "created_at"
4
+
5
+ def self.from_records(record_data)
6
+ record_data.present? ? new(record_data) : nil
7
+ end
8
+
9
+ def initialize(record_data)
10
+ @record_data = record_data
11
+ @serializer = ::ActiverecordHoarder::Serializer
12
+ end
13
+
14
+ def date
15
+ @date ||= @record_data.first[RECORD_DATE_FIELD].to_date
16
+ end
17
+
18
+ def key
19
+ @key ||= ::ActiverecordHoarder::StorageKey.from_date(date, @serializer.extension)
20
+ end
21
+
22
+ def content_string
23
+ @serializer.create_archive(@record_data)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,16 @@
1
+ class ::ActiverecordHoarder::BatchArchiver
2
+ def initialize(model_class, storage = nil)
3
+ @record_collector = ::ActiverecordHoarder::RecordCollector.new(model_class)
4
+ @archive_storage = storage || default_storage_for_records(model_class.table_name)
5
+ end
6
+
7
+ def archive_batch
8
+ @record_collector.in_batches(delete_on_success: true) do |batch|
9
+ @archive_storage.store_data(batch)
10
+ end
11
+ end
12
+
13
+ def default_storage_for_records(table_name)
14
+ ::ActiverecordHoarder::Storage.new(table_name)
15
+ end
16
+ end
@@ -0,0 +1,13 @@
1
+ module ActiverecordHoarder::Core
2
+ def self.included(base)
3
+ raise 'created_at accessor needed' if !base.column_names.include?("created_at")
4
+ raise 'deleted_at accessor needed' if !base.column_names.include?("deleted_at")
5
+ base.extend ClassMethods
6
+ end
7
+
8
+ module ClassMethods
9
+ def hoard
10
+ ::ActiverecordHoarder::BatchArchiver.new(self).archive_batch
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,67 @@
1
+ class ::ActiverecordHoarder::RecordCollector
2
+ attr_reader :relative_limit
3
+
4
+ def initialize(model_class)
5
+ @model_class = model_class
6
+ end
7
+
8
+ def in_batches(delete_on_success: false)
9
+ while collect_batch
10
+ success = yield @batch
11
+ return if !success
12
+ next if !delete_on_success
13
+ destroy_current_records!
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def collect_batch
20
+ activate_limit if batch_data_cached? && !limit_toggled?
21
+ if limit_toggled?
22
+ @batch = ensuring_new_records do
23
+ retrieve_next_batch
24
+ end
25
+ else
26
+ @batch = retrieve_first_batch
27
+ end
28
+ batch_data_cached?
29
+ end
30
+
31
+ def activate_limit
32
+ @relative_limit = [@batch.date.end_of_week + 1, archive_timeframe_upper_limit].min
33
+ end
34
+
35
+ def archive_timeframe_upper_limit
36
+ Time.now.getutc.beginning_of_day
37
+ end
38
+
39
+ def batch_data_cached?
40
+ @batch.present?
41
+ end
42
+
43
+ def destroy_current_records!
44
+ @model_class.connection.execute(@batch_query.delete(@batch.date))
45
+ end
46
+
47
+ def ensuring_new_records
48
+ record_batch = yield
49
+ @batch.date == record_batch.try(:date) ? nil : record_batch
50
+ end
51
+
52
+ def limit_toggled?
53
+ @relative_limit.present?
54
+ end
55
+
56
+ def retrieve_first_batch
57
+ @batch_query = ::ActiverecordHoarder::BatchQuery.new(archive_timeframe_upper_limit, @model_class)
58
+ batch_data = @model_class.connection.exec_query(@batch_query.fetch)
59
+ ::ActiverecordHoarder::Batch.from_records(batch_data)
60
+ end
61
+
62
+ def retrieve_next_batch
63
+ @batch_query = ::ActiverecordHoarder::BatchQuery.new(relative_limit, @model_class)
64
+ batch_data = @model_class.connection.exec_query(@batch_query.fetch)
65
+ ::ActiverecordHoarder::Batch.from_records(batch_data)
66
+ end
67
+ end
@@ -0,0 +1,85 @@
1
+ module ActiverecordHoarder
2
+ class BatchQuery
3
+ SUBQUERY_DELETED_RECORDS = <<~SQL.strip_heredoc
4
+ SELECT
5
+ %{fields}
6
+ FROM
7
+ %{table_name}
8
+ WHERE
9
+ deleted_at IS NOT NULL
10
+ SQL
11
+
12
+ SUBQUERY_NON_DELETED_RECORDS = <<~SQL.strip_heredoc
13
+ SELECT
14
+ %{fields}
15
+ FROM
16
+ %{table_name}
17
+ WHERE
18
+ deleted_at IS NULL
19
+ SQL
20
+
21
+ QUERY_TEMPLATE_FOR_DATE_DELETION = <<~SQL.strip_heredoc
22
+ DELETE FROM %{table_name} WHERE date(created_at) = '%{date}';
23
+ SQL
24
+
25
+ QUERY_TEMPLATE_FOR_RECORD_WITH_LIMIT = <<~SQL.strip_heredoc
26
+ SELECT
27
+ %{fields}
28
+ FROM
29
+ %{table_name}
30
+ WHERE
31
+ date(created_at) = (
32
+ SELECT
33
+ min(dates_with_deleted.creation_date)
34
+ FROM
35
+ (
36
+ #{SUBQUERY_DELETED_RECORDS % {
37
+ fields: "date(created_at) as creation_date",
38
+ table_name: "%{table_name}"
39
+ }}
40
+ ) as dates_with_deleted
41
+ LEFT OUTER JOIN
42
+ (
43
+ #{SUBQUERY_NON_DELETED_RECORDS % {
44
+ fields: "date(created_at) as creation_date",
45
+ table_name: "%{table_name}"
46
+ }}
47
+ ) as dates_with_non_deleted
48
+ ON
49
+ dates_with_deleted.creation_date = dates_with_non_deleted.creation_date
50
+ WHERE
51
+ dates_with_non_deleted.creation_date IS NULL
52
+ AND
53
+ created_at < '%{limit}'
54
+ )
55
+ ;
56
+ SQL
57
+
58
+ def initialize(limit, model_class)
59
+ @limit = limit
60
+ @model_class = model_class
61
+ end
62
+
63
+ def delete(date)
64
+ QUERY_TEMPLATE_FOR_DATE_DELETION % {
65
+ fields: @model_class.column_names.join(", "),
66
+ date: date,
67
+ table_name: table_name
68
+ }
69
+ end
70
+
71
+ def fetch
72
+ QUERY_TEMPLATE_FOR_RECORD_WITH_LIMIT % {
73
+ fields: @model_class.column_names.join(", "),
74
+ limit: @limit,
75
+ table_name: table_name
76
+ }
77
+ end
78
+
79
+ private
80
+
81
+ def table_name
82
+ @model_class.table_name
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,16 @@
1
+ module ActiverecordHoarder
2
+ module Restore
3
+ def self.included(base)
4
+ base.extend(ClassMethods)
5
+ end
6
+
7
+ module ClassMethods
8
+ def restore_archive_records(date)
9
+ storage = ::ActiverecordHoarder::Storage.new(self.table_name)
10
+ key = ::ActiverecordHoarder::StorageKey.from_date(date, :json)
11
+ dataIO = storage.fetch_data(key)
12
+ create(JSON.parse(dataIO.read))
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,9 @@
1
+ class ::ActiverecordHoarder::Serializer
2
+ def self.create_archive(batch_data)
3
+ batch_data.to_json
4
+ end
5
+
6
+ def self.extension
7
+ :json
8
+ end
9
+ end
@@ -0,0 +1,27 @@
1
+ class ::ActiverecordHoarder::Storage
2
+ class_attribute :storage, :storage_options
3
+
4
+ def self.configure(storage:, storage_options:)
5
+ ::ActiverecordHoarder::Storages.is_valid_storage?(storage)
6
+
7
+ self.storage_options = storage_options
8
+ self.storage = storage
9
+ self
10
+ end
11
+
12
+ private
13
+
14
+ def self.new(table_name, storage_override: nil, storage_options_override: {})
15
+ self.check_configured
16
+ storage_class = ::ActiverecordHoarder::Storages.retrieve(storage_override || storage)
17
+ storage_class.new(table_name, storage_options.merge(storage_options_override))
18
+ end
19
+
20
+ def self.check_configured
21
+ raise ::ActiverecordHoarder::StorageError.new("storage needs to be configured") unless is_configured?
22
+ end
23
+
24
+ def self.is_configured?
25
+ storage.present? && storage_options.is_a?(Hash)
26
+ end
27
+ end
@@ -0,0 +1,4 @@
1
+ module ::ActiverecordHoarder
2
+ class StorageError < StandardError
3
+ end
4
+ end
@@ -0,0 +1,18 @@
1
+ module ActiverecordHoarder
2
+ class StorageKey
3
+ def self.from_date(date, file_extension = nil)
4
+ key_parts = [date.year.to_s, date.month.to_s, date.iso8601]
5
+ new(key_parts, file_extension)
6
+ end
7
+
8
+ def initialize(key_parts, file_extension)
9
+ @key_parts = key_parts
10
+ @file_extension = file_extension
11
+ end
12
+
13
+ def to_s
14
+ key_without_extension = File.join(@key_parts)
15
+ @file_extension.present? ? key_without_extension + '.' + @file_extension.to_s : key_without_extension
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,20 @@
1
+ module ::ActiverecordHoarder
2
+ class Storages
3
+ STORAGE_DICT = {
4
+ aws_s3: ::ActiverecordHoarder::AwsS3
5
+ }
6
+
7
+ def self.check_storage(storage_key)
8
+ raise ::ActiverecordHoarder::StorageError.new("unknown storage (#{storage_key}), known keys are #{STORAGE_DICT.keys}") if !is_valid_storage?(storage_key)
9
+ end
10
+
11
+ def self.is_valid_storage?(storage_key)
12
+ STORAGE_DICT.keys.include?(storage_key)
13
+ end
14
+
15
+ def self.retrieve(storage_key)
16
+ check_storage(storage_key)
17
+ STORAGE_DICT[storage_key]
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,3 @@
1
+ module ActiverecordHoarder
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,21 @@
1
+ require 'activerecord_hoarder/aws_s3_storage'
2
+ require 'activerecord_hoarder/batch'
3
+ require 'activerecord_hoarder/batch_archiver'
4
+ require 'activerecord_hoarder/core'
5
+ require 'activerecord_hoarder/record_collector'
6
+ require 'activerecord_hoarder/record_query'
7
+ require 'activerecord_hoarder/restore'
8
+ require 'activerecord_hoarder/serializer'
9
+ require 'activerecord_hoarder/storage'
10
+ require 'activerecord_hoarder/storage_error'
11
+ require 'activerecord_hoarder/storage_key'
12
+ require 'activerecord_hoarder/storages'
13
+
14
+ module ActiverecordHoarder
15
+ def acts_as_hoarder
16
+ include ActiverecordHoarder::Core
17
+ include ActiverecordHoarder::Restore
18
+ end
19
+ end
20
+
21
+ ::ActiveRecord::Base.send :extend, ActiverecordHoarder
@@ -0,0 +1,26 @@
1
+ require 'spec_helper'
2
+
3
+ DATA_TEMPLATE = "data for key %{key}"
4
+
5
+ RSpec.describe ::ActiverecordHoarder::AwsS3 do
6
+ describe ".fetch_data" do
7
+ let(:date_data) { DATA_TEMPLATE % { key: full_key } }
8
+ let(:full_key) { File.join(table_name, key_string) }
9
+ let(:key) { double(key_string, to_s: key_string) }
10
+ let(:key_string) { "key" }
11
+ let(:storage) { ::ActiverecordHoarder::AwsS3.new(table_name, storage_options) }
12
+ let(:storage_options) { {} }
13
+ let(:table_name) { "records" }
14
+
15
+ before do
16
+ allow(storage).to receive(:s3_client).and_return(Aws::S3::Client.new(stub_responses: true))
17
+ allow_any_instance_of(Aws::S3::Client).to receive(:get_object) do |object, args|
18
+ double(body: DATA_TEMPLATE % { key: args[:key] })
19
+ end
20
+ end
21
+
22
+ it "returns the archive entry for given date" do
23
+ expect(storage.fetch_data(key)).to eq(date_data)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,10 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe ::ActiverecordHoarder::BatchArchiver do
4
+ let(:model_class) { double("Record", table_name: "records") }
5
+
6
+ it "creates instance that can archive_batch" do
7
+ allow(::ActiverecordHoarder::Storage).to receive(:new).and_return(double)
8
+ expect(described_class.new(model_class)).to respond_to(:archive_batch)
9
+ end
10
+ end
@@ -0,0 +1,13 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe 'ActiverecordHoarder::Core' do
4
+ context 'included and instantiated' do
5
+ describe 'hoard' do
6
+ it "it works, no questions asked" do
7
+ expect(::ActiverecordHoarder::Storage).to receive(:new).and_return(double)
8
+ expect_any_instance_of(::ActiverecordHoarder::BatchArchiver).to receive(:archive_batch)
9
+ expect{ ExampleHoarder.hoard }.not_to raise_error
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,7 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe ::ActiverecordHoarder::Serializer do
4
+ it "has class method for serialization" do
5
+ expect(described_class).to respond_to(:create_archive)
6
+ end
7
+ end
@@ -0,0 +1,29 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe ::ActiverecordHoarder::Storage do
4
+ let(:stub_model) { double }
5
+
6
+ before do
7
+ allow(stub_model).to receive(:table_name)
8
+ end
9
+
10
+ describe "new" do
11
+ let(:storage) { ::ActiverecordHoarder::Storage.new(stub_model) }
12
+
13
+ context "not configured" do
14
+ it "fails and complains about configuration" do
15
+ expect{ ::ActiverecordHoarder::Storage.new(stub_model) }.to raise_error(::ActiverecordHoarder::StorageError)
16
+ end
17
+ end
18
+
19
+ context "aws_s3" do
20
+ before do
21
+ ::ActiverecordHoarder::Storage.configure(storage: :aws_s3, storage_options: {})
22
+ end
23
+
24
+ it "returns an aws storage" do
25
+ expect(storage.class).to be(::ActiverecordHoarder::AwsS3)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,168 @@
1
+ require "spec_helper"
2
+
3
+ RSpec.describe ActiverecordHoarder do
4
+
5
+ around(:all) do |example|
6
+ current_zone = Time.zone
7
+ Time.zone = "America/Chicago"
8
+ Timecop.freeze((Date.today.beginning_of_week - 3).to_time(:utc).end_of_day) do
9
+ example.run
10
+ end
11
+ Time.zone = current_zone
12
+ end
13
+
14
+ it "has a version number" do
15
+ expect(ActiverecordHoarder::VERSION).not_to be nil
16
+ end
17
+
18
+ it "extends ::ActiveRecord::Base with acts_as_hoarder" do
19
+ expect(::ActiveRecord::Base.methods).to include(:acts_as_hoarder)
20
+ end
21
+
22
+ describe "acts_as_hoarder" do
23
+ context "successfully included in ActiveRecord model" do
24
+ it "extends with public class method .hoard" do
25
+ expect(ExampleHoarder.methods).to include(:hoard)
26
+ end
27
+ end
28
+ end
29
+
30
+ describe "record archiving" do
31
+ let(:archive_data) {
32
+ @archivable_records.group_by { |item|
33
+ item.created_at.getutc.to_date
34
+ } .collect { |date, group|
35
+ JSON.pretty_generate(group.collect(&:serializable_hash))
36
+ }
37
+ }
38
+ let(:storage) { double }
39
+
40
+ before :each do
41
+ allow(::ActiverecordHoarder::Storage).to receive(:new).and_return(storage)
42
+ allow(storage).to receive(:store_data).and_return(true)
43
+ end
44
+
45
+ context "with records only in current week" do
46
+ before do
47
+ @archivable_records = create_list(
48
+ :examples_in_range,
49
+ 20,
50
+ deleted: true,
51
+ start_time: Time.now.getutc.beginning_of_week,
52
+ end_time: (Date.today - 1).to_time(:utc).end_of_day
53
+ )
54
+ @non_archivable_records = create_list(
55
+ :examples_on_date,
56
+ 4,
57
+ deleted: true,
58
+ records_date: Date.today
59
+ )
60
+ ExampleHoarder.hoard
61
+ end
62
+
63
+ it "ignores current day" do
64
+ expect(ExampleHoarder.unscoped.to_a).to include(*@non_archivable_records)
65
+ end
66
+
67
+ it "archives previous days" do
68
+ expect(ExampleHoarder.unscoped.to_a).not_to include(*@archivable_records)
69
+ end
70
+ end
71
+
72
+ context "with records in multiple weeks, non-deleted records mixed in and trailing" do
73
+ before do
74
+ @archivable_records = create_list(
75
+ :examples_in_range,
76
+ 20,
77
+ deleted: true,
78
+ end_time: (1.week.ago.getutc.beginning_of_week + 2.days).end_of_day,
79
+ start_time: 1.week.ago.getutc.beginning_of_week
80
+ ) + create_list(
81
+ :examples_in_range,
82
+ 20,
83
+ deleted: true,
84
+ end_time: 1.week.ago.getutc.end_of_week,
85
+ start_time: 1.week.ago.getutc.beginning_of_week + 4.days
86
+ )
87
+ @non_archivable_records = create_list(
88
+ :examples_on_date,
89
+ 2,
90
+ deleted: true,
91
+ records_date: (1.week.ago.getutc.beginning_of_week - 1.day).to_date
92
+ ) + create_list(
93
+ :examples_on_date,
94
+ 1,
95
+ deleted: false,
96
+ records_date: (1.week.ago.getutc.beginning_of_week - 1.day).to_date
97
+ ) + create_list(
98
+ :examples_on_date,
99
+ 4,
100
+ deleted: true,
101
+ records_date: (1.week.ago.getutc.beginning_of_week + 3.days).to_date,
102
+ ) + create_list(
103
+ :examples_on_date,
104
+ 2,
105
+ deleted: false,
106
+ records_date: (1.week.ago.getutc.beginning_of_week + 3.days).to_date
107
+ )
108
+ @out_of_range_records = create_list(
109
+ :examples_in_range,
110
+ 2,
111
+ deleted: true,
112
+ end_time: Time.now,
113
+ start_time: Time.now.getutc.beginning_of_week
114
+ ) + create_list(
115
+ :examples_in_range,
116
+ 2,
117
+ deleted: false,
118
+ end_time: Time.now,
119
+ start_time: Time.now.getutc.beginning_of_week
120
+ )
121
+ ExampleHoarder.hoard
122
+ end
123
+
124
+ it "skips records from days with active records" do
125
+ expect(ExampleHoarder.unscoped.to_a).to include(*@non_archivable_records)
126
+ end
127
+
128
+ it "archives one week of fully deleted records" do
129
+ expect(ExampleHoarder.unscoped.to_a).not_to include(*@archivable_records)
130
+ end
131
+
132
+ it "stops after one week" do
133
+ expect(ExampleHoarder.unscoped.to_a).to include(*@out_of_range_records)
134
+ end
135
+ end
136
+ end
137
+
138
+ describe "workflow" do
139
+ let(:batch1) { double }
140
+ let(:collector) { ::ActiverecordHoarder::RecordCollector.new(ExampleHoarder) }
141
+ let(:storage) { double }
142
+
143
+ before do
144
+ allow(::ActiverecordHoarder::Storage).to receive(:new).and_return(storage)
145
+ allow(::ActiverecordHoarder::RecordCollector).to receive(:new).and_return(collector)
146
+ allow(collector).to receive(:collect_batch).and_return(true, false)
147
+ allow(collector).to receive(:batch_data_cached?).and_return(true)
148
+ allow_any_instance_of(::ActiverecordHoarder::BatchArchiver).to receive(:compose_key).and_return("key")
149
+ allow(collector).to receive(:destroy_current_records!)
150
+ end
151
+
152
+ after do
153
+ ExampleHoarder.hoard
154
+ end
155
+
156
+ it "fully processes one record batch before moving on to the next" do
157
+ expect(collector).to receive(:collect_batch)
158
+ expect(storage).to receive(:store_data).and_return(true)
159
+ expect(collector).to receive(:destroy_current_records!)
160
+ expect(collector).to receive(:collect_batch)
161
+ end
162
+
163
+ it "does not delete a record that wasn't successfully archived" do
164
+ expect(storage).to receive(:store_data).and_return(false)
165
+ expect(collector).not_to receive(:destroy_current_records!)
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,40 @@
1
+ def datetimes_in_range(n, step: 5, start:, stop:)
2
+ seconds_in_range = stop - start
3
+ seconds_ago = Time.now.getutc - stop
4
+ ((n*(step.hours+1.minute + 1.second)) % seconds_in_range + seconds_ago).seconds.ago
5
+ end
6
+
7
+ FactoryGirl.define do
8
+ factory :example_hoarder do
9
+ transient do
10
+ deleted false
11
+ end
12
+
13
+ after :build do |record, evaluator|
14
+ if evaluator.deleted
15
+ record.deleted_at = record.created_at
16
+ end
17
+ end
18
+ end
19
+
20
+ factory :examples_in_range, parent: :example_hoarder do
21
+ transient do
22
+ end_time Time.now
23
+ start_time 2.weeks.ago
24
+ step 5
25
+ end
26
+
27
+ sequence(:created_at, 0) { |n| datetimes_in_range(n, step: step, start: start_time, stop: end_time) }
28
+ end
29
+
30
+ factory :examples_on_date, parent: :example_hoarder do
31
+ transient do
32
+ records_date Date.today - 1
33
+ step 5
34
+ end
35
+
36
+ sequence(:created_at, 0) { |n|
37
+ created_at_value = datetimes_in_range(n, step: step, start: records_date.to_time(:utc), stop: records_date.to_time(:utc).end_of_day)
38
+ }
39
+ end
40
+ end
@@ -0,0 +1,15 @@
1
+ require "bundler/setup"
2
+ require "active_record"
3
+ require "aws-sdk-s3"
4
+
5
+ require "activerecord_hoarder"
6
+
7
+ require "factory_girl_rails"
8
+ require "pp"
9
+ require "timecop"
10
+
11
+ Dir.glob("spec/support/*.rb").each do |file| require File.expand_path(file) end
12
+
13
+ class ExampleHoarder < ActiveRecord::Base
14
+ acts_as_hoarder
15
+ end
@@ -0,0 +1,14 @@
1
+ RSpec.configure do |config|
2
+ # Enable flags like --only-failures and --next-failure
3
+ config.example_status_persistence_file_path = ".rspec_status"
4
+
5
+ # Disable RSpec exposing methods globally on `Module` and `main`
6
+ config.disable_monkey_patching!
7
+
8
+ config.expect_with :rspec do |c|
9
+ c.syntax = :expect
10
+ end
11
+
12
+ config.include FactoryGirl::Syntax::Methods
13
+ FactoryGirl.find_definitions
14
+ end
@@ -0,0 +1 @@
1
+ ActiveRecord::Base.establish_connection(YAML.load_file("config/dbspec_rspec.yml"))
@@ -0,0 +1,14 @@
1
+ ActiveRecord::Schema.define do
2
+ create_table "example_hoarders", force: true do |t|
3
+ t.datetime "created_at"
4
+ t.datetime "deleted_at"
5
+ t.datetime "updated_at"
6
+ t.string "content"
7
+ end
8
+ end
9
+
10
+ RSpec.configure do |config|
11
+ config.after(:each) do
12
+ ActiveRecord::Base.connection.execute("DELETE FROM example_hoarders;")
13
+ end
14
+ end
@@ -0,0 +1,7 @@
1
+
2
+ RSpec.configure do |config|
3
+ config.after(:each) do
4
+ ActiverecordHoarder.send(:remove_const, 'Storage')
5
+ load 'lib/activerecord_hoarder/storage.rb'
6
+ end
7
+ end
metadata ADDED
@@ -0,0 +1,162 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activerecord_hoarder
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Matthias Engh
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-11-15 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '4.2'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '6.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '4.2'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '6.0'
33
+ - !ruby/object:Gem::Dependency
34
+ name: bundler
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.15'
40
+ type: :development
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.15'
47
+ - !ruby/object:Gem::Dependency
48
+ name: rake
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '10.0'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '10.0'
61
+ - !ruby/object:Gem::Dependency
62
+ name: rspec
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.0'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3.0'
75
+ description: extend active records with simple archiving mechanics
76
+ email:
77
+ - matthias@wescrimmage.com
78
+ executables:
79
+ - console
80
+ - setup
81
+ extensions: []
82
+ extra_rdoc_files: []
83
+ files:
84
+ - ".gitignore"
85
+ - ".rspec"
86
+ - ".travis.yml"
87
+ - Gemfile
88
+ - LICENSE.txt
89
+ - README.md
90
+ - Rakefile
91
+ - activerecord_hoarder.gemspec
92
+ - bin/console
93
+ - bin/setup
94
+ - config/activerecord_hoarder.yml.template
95
+ - config/dbspec.yml.template
96
+ - config/dbspec_rspec.yml.template
97
+ - example/example.rb
98
+ - example/fixture.rb
99
+ - example/schema.rb
100
+ - lib/activerecord_hoarder.rb
101
+ - lib/activerecord_hoarder/aws_s3_storage.rb
102
+ - lib/activerecord_hoarder/batch.rb
103
+ - lib/activerecord_hoarder/batch_archiver.rb
104
+ - lib/activerecord_hoarder/core.rb
105
+ - lib/activerecord_hoarder/record_collector.rb
106
+ - lib/activerecord_hoarder/record_query.rb
107
+ - lib/activerecord_hoarder/restore.rb
108
+ - lib/activerecord_hoarder/serializer.rb
109
+ - lib/activerecord_hoarder/storage.rb
110
+ - lib/activerecord_hoarder/storage_error.rb
111
+ - lib/activerecord_hoarder/storage_key.rb
112
+ - lib/activerecord_hoarder/storages.rb
113
+ - lib/activerecord_hoarder/version.rb
114
+ - spec/activerecord_hoarder/aws_s3_storage_spec.rb
115
+ - spec/activerecord_hoarder/batch_archiver_spec.rb
116
+ - spec/activerecord_hoarder/core_spec.rb
117
+ - spec/activerecord_hoarder/serializer_spec.rb
118
+ - spec/activerecord_hoarder/storage_spec.rb
119
+ - spec/activerecord_hoarder_spec.rb
120
+ - spec/factories/examples.rb
121
+ - spec/spec_helper.rb
122
+ - spec/support/0_rspec_configuration.rb
123
+ - spec/support/1_active_record_configuration.rb
124
+ - spec/support/2_database_content.rb
125
+ - spec/support/3_test_storage.rb
126
+ homepage: https://github.com/Scrimmage/gem_batch_archiving
127
+ licenses:
128
+ - MIT
129
+ metadata: {}
130
+ post_install_message:
131
+ rdoc_options: []
132
+ require_paths:
133
+ - lib
134
+ required_ruby_version: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ required_rubygems_version: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - ">="
142
+ - !ruby/object:Gem::Version
143
+ version: '0'
144
+ requirements: []
145
+ rubyforge_project:
146
+ rubygems_version: 2.4.8
147
+ signing_key:
148
+ specification_version: 4
149
+ summary: hoards records
150
+ test_files:
151
+ - spec/activerecord_hoarder/aws_s3_storage_spec.rb
152
+ - spec/activerecord_hoarder/batch_archiver_spec.rb
153
+ - spec/activerecord_hoarder/core_spec.rb
154
+ - spec/activerecord_hoarder/serializer_spec.rb
155
+ - spec/activerecord_hoarder/storage_spec.rb
156
+ - spec/activerecord_hoarder_spec.rb
157
+ - spec/factories/examples.rb
158
+ - spec/spec_helper.rb
159
+ - spec/support/0_rspec_configuration.rb
160
+ - spec/support/1_active_record_configuration.rb
161
+ - spec/support/2_database_content.rb
162
+ - spec/support/3_test_storage.rb