hekenga 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
+ SHA1:
3
+ metadata.gz: 1f8ad94b462c9140e11f83e4f4ad0f0a16a9ab83
4
+ data.tar.gz: 6be1fcf20d2a9f1b76c826c6624828feba9c5ad5
5
+ SHA512:
6
+ metadata.gz: 5dfa4bb023c546776514ebf1cd5818688de1937513ba6d2b847b95475052b6f408fd33f3464fcb150d28808c4b0fb7b6282824f81323391514eedafdbe5e4171
7
+ data.tar.gz: 6d9fb7bed2f662c107773e17f109374a9767f69e6c2c99546fc90e9a74b5c22fbfde767c1fb47065ec38facd31559876b57fe7937c0fcf2b2352d66c7cf2fe27
data/.gitignore ADDED
@@ -0,0 +1,9 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
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.3.3
5
+ before_install: gem install bundler -v 1.13.6
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in hekenga.gemspec
4
+ gemspec
data/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # Hekenga
2
+
3
+ An attempt at a migration framework for MongoDB that supports parallel document
4
+ processing via ActiveJob, chained jobs and error recovery.
5
+
6
+ **Note that this gem is currently in pre-alpha - assume most things have a high
7
+ chance of being broken.**
8
+
9
+ ## Installation
10
+
11
+ Add this line to your application's Gemfile:
12
+
13
+ ```ruby
14
+ gem 'hekenga'
15
+ ```
16
+
17
+ And then execute:
18
+
19
+ $ bundle
20
+
21
+ Or install it yourself as:
22
+
23
+ $ gem install hekenga
24
+
25
+ ## Usage
26
+
27
+ CLI instructions:
28
+
29
+ $ hekenga help
30
+
31
+ Migration DSL documentation TBD, for now please look at spec/
32
+
33
+ ## Development
34
+
35
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
36
+
37
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
38
+
39
+ ## Contributing
40
+
41
+ Bug reports and pull requests are welcome on GitHub at https://github.com/tzar/hekenga.
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ desc "Open a pry session preloaded with this library"
7
+ task :console do
8
+ sh "bin/console", verbose: false
9
+ end
10
+
11
+ task :default => :spec
data/bin/console ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "hekenga"
5
+ require "pry"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+ Pry.start
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,52 @@
1
+ # Stub
2
+ class MyModel
3
+ def self.where(*args)
4
+ self
5
+ end
6
+ end
7
+ # You can stack multiple tasks within one overall logical migration
8
+ Hekenga.migration do
9
+ description "Example usage"
10
+ created "2016-04-03 14:00"
11
+
12
+ # Simple tasks have an up and a down and run in one go
13
+ task "Set foo->bar by default on MyModel" do
14
+ up do
15
+ MyModel.all.set(foo: 'bar')
16
+ end
17
+ down do
18
+ MyModel.all.unset(:foo)
19
+ end
20
+ end
21
+
22
+ # Per document tasks run a block of code per document, with the ability to
23
+ # filter which documents are loaded by:
24
+ # - scope
25
+ # - arbitrary block
26
+ # Jobs can be run in parallel via ActiveJob.
27
+ # Callbacks can be disabled for the context of the job either globally via
28
+ # disable_callbacks or specifically via disable_callback, with multiple models
29
+ # optionally targetted via the `on` param.
30
+ # A setup block is also provided (this must be able to be run multiple times!)
31
+ # per_document migrations should be resumable/retryable..
32
+ # errors should never result in data loss, and should be logged to a migration
33
+ # output model
34
+ per_document "Set MyModel.zap to a random number if unset" do
35
+ scope MyModel.where(zap: nil)
36
+ parallel!
37
+ timeless!
38
+ disable_callback :reindex, on: MyModel
39
+
40
+ setup do
41
+ @max_rand = 100
42
+ end
43
+
44
+ filter do |document|
45
+ document.zap.nil?
46
+ end
47
+
48
+ up do |document|
49
+ document.zap = rand(@max_rand)
50
+ end
51
+ end
52
+ end
data/exe/hekenga ADDED
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+
5
+ if File.exists?(File.expand_path("config/environment.rb"))
6
+ require File.expand_path("config/environment.rb")
7
+ end
8
+
9
+ require "hekenga"
10
+ require "thor"
11
+
12
+ class HekengaCLI < Thor
13
+ desc "status", "Show which migrations have run and their status."
14
+ def status
15
+ Hekenga.load_all!
16
+ Hekenga.registry.sort_by {|x| x.stamp}.each do |migration|
17
+ status = case Hekenga.status(migration)
18
+ when :running
19
+ "ACTIVE"
20
+ when :failed
21
+ "FAILED"
22
+ when :complete
23
+ "COMPLT"
24
+ else
25
+ "UN-RUN"
26
+ end
27
+ puts "[#{status}] #{migration.to_key}"
28
+ end
29
+ end
30
+
31
+ desc "run_all!", "Run all migrations that have not yet run, in date order."
32
+ def run_all!
33
+ bail_if_errors
34
+ Hekenga.load_all!
35
+ Hekenga.registry.sort_by {|x| x.stamp}.each do |migration|
36
+ migration.perform!
37
+ bail_if_errors
38
+ end
39
+ end
40
+
41
+ desc "run! PATH_OR_PKEY --test", "Run a migration (optionally in test mode)."
42
+ option :test, default: false, type: :boolean
43
+ def run!(path_or_pkey)
44
+ bail_if_errors
45
+ migration = load_migration(path_or_pkey)
46
+ migration.test_mode! if options[:test]
47
+ migration.perform!
48
+ if options[:test]
49
+ if Hekenga::Failure.where(pkey: migration.to_key).any?
50
+ puts "Logs have been preserved for debugging. To reset migration state run:"
51
+ puts " hekenga clear! #{path_or_pkey}"
52
+ else
53
+ puts "Migration test run completed successfully."
54
+ clear!(path_or_pkey)
55
+ end
56
+ end
57
+ end
58
+
59
+ desc "cancel", "Cancel all active migrations."
60
+ def cancel
61
+ Hekenga::Log.where(done: false).set(cancel: true)
62
+ puts "Sent :cancel to all active hekenga jobs."
63
+ end
64
+
65
+ desc "cleanup", "Remove any failure logs."
66
+ def cleanup
67
+ Hekenga::Failure.all.delete_all
68
+ puts "Removed all failure logs."
69
+ end
70
+
71
+ desc "clear! PATH_OR_PKEY", "Clear the logs and failure for a migration. Dangerous!"
72
+ def clear!(path_or_pkey)
73
+ migration = load_migration(path_or_pkey)
74
+ puts "Clearing #{migration.to_key}.."
75
+ Hekenga::Log.where(pkey: migration.to_key).delete_all
76
+ Hekenga::Failure.where(pkey: migration.to_key).delete_all
77
+ puts "Done!"
78
+ end
79
+
80
+ desc "rollback", "Rollback a migration."
81
+ def rollback
82
+ todo "rollback"
83
+ end
84
+
85
+ desc "recover", "Attempt to resume a failed migration."
86
+ def recover
87
+ todo "recover"
88
+ end
89
+
90
+ desc "errors", "Print the errors associated with a failed migration."
91
+ def errors
92
+ todo "errors"
93
+ end
94
+
95
+ desc "skip PATH_OR_PKEY", "Skip a migration so that it won't run."
96
+ def skip(path_or_pkey)
97
+ migration = load_migration(path_or_pkey)
98
+ puts "Skipping #{migration.to_key}.."
99
+ migration.tasks.each.with_index do |task, idx|
100
+ log = Hekenga::Log.where(pkey: task.to_key, task_idx: idx).first ||
101
+ Hekenga::Log.new(migration: migration, task_idx: idx)
102
+
103
+ log.done = true
104
+ log.skip = true
105
+
106
+ log.save!
107
+ end
108
+ puts "Done!"
109
+ end
110
+
111
+ private
112
+
113
+ def todo(op)
114
+ puts "#{op.capitalize} has not yet been implemented."
115
+ exit(99)
116
+ end
117
+ def bail_if_errors
118
+ if Hekenga.any_fatal?
119
+ puts "Refusing to run migrations while there is an existing cancelled migration."
120
+ exit(1)
121
+ end
122
+ end
123
+ def load_migration(path_or_pkey)
124
+ if File.exists?(File.expand_path(path_or_pkey))
125
+ require File.expand_path(path_or_pkey)
126
+ migration = Hekenga.registry.last
127
+ else
128
+ Hekenga.load_all!
129
+ unless migration = Hekenga.find_migration(path_or_pkey)
130
+ puts "Can't find migration #{path_or_pkey}. Available migrations:"
131
+ puts Hekenga.registry.map {|x| "- #{x.to_key}"}
132
+ exit(2)
133
+ end
134
+ end
135
+ migration
136
+ end
137
+ end
138
+
139
+ HekengaCLI.start
data/hekenga.gemspec ADDED
@@ -0,0 +1,32 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'hekenga/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "hekenga"
8
+ spec.version = Hekenga::VERSION
9
+ spec.authors = ["Tapio Saarinen"]
10
+ spec.email = ["admin@bitlong.org"]
11
+
12
+ spec.summary = %q{Sophisticated migration framework for mongoid, with the ability to parallelise via ActiveJob.}
13
+ spec.homepage = "https://github.com/tzar/hekenga"
14
+
15
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
16
+ f.match(%r{^(test|spec|features)/})
17
+ end
18
+ spec.bindir = "exe"
19
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
20
+ spec.require_paths = ["lib"]
21
+
22
+ spec.add_development_dependency "bundler", "~> 1.13"
23
+ spec.add_development_dependency "rake", "~> 10.0"
24
+ spec.add_development_dependency "rspec", "~> 3.0"
25
+ spec.add_development_dependency "database_cleaner"
26
+ spec.add_development_dependency "pry"
27
+ spec.add_development_dependency "pry-byebug"
28
+
29
+ spec.add_runtime_dependency "mongoid", "~> 5"
30
+ spec.add_runtime_dependency "activejob", "~> 4"
31
+ spec.add_runtime_dependency "thor"
32
+ end
@@ -0,0 +1,4 @@
1
+ module Hekenga
2
+ class BaseError < StandardError
3
+ end
4
+ end
@@ -0,0 +1,13 @@
1
+ module Hekenga
2
+ class Config
3
+ attr_accessor :dir, :root, :report_sleep
4
+ def initialize
5
+ @report_sleep = 10
6
+ @root = Dir.pwd
7
+ @dir = ["db", "hekenga"]
8
+ end
9
+ def abs_dir
10
+ File.join(@root, *[@dir].flatten)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,14 @@
1
+ module Hekenga
2
+ class Context
3
+ def initialize(test_run)
4
+ @__test_run = test_run
5
+ end
6
+
7
+ def test?
8
+ !!@__test_run
9
+ end
10
+ def actual?
11
+ !@__test_run
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,38 @@
1
+ require 'hekenga/irreversible'
2
+ module Hekenga
3
+ class DocumentTask
4
+ attr_reader :ups, :downs, :setups, :filters
5
+ attr_accessor :parallel, :disable_rules, :scope, :timeless
6
+ attr_accessor :description, :invalid_strategy, :skip_prepare
7
+ def initialize
8
+ @ups = []
9
+ @downs = []
10
+ @disable_rules = []
11
+ @setups = []
12
+ @filters = []
13
+ @invalid_strategy = :prompt
14
+ @skip_prepare = false
15
+ end
16
+
17
+ def validate!
18
+ raise Hekenga::Invalid.new(self, :ups, "missing") unless ups.any?
19
+ end
20
+
21
+ def up!(context, document)
22
+ @ups.each do |block|
23
+ context.instance_exec(document, &block)
24
+ end
25
+ end
26
+
27
+ def down!(context, document)
28
+ raise Hekenga::Irreversible.new(self) unless reversible?
29
+ @downs.each do |block|
30
+ context.instance_eval(document, &block)
31
+ end
32
+ end
33
+
34
+ def reversible?
35
+ downs.any?
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,53 @@
1
+ require 'hekenga/document_task'
2
+ module Hekenga
3
+ class DSL
4
+ class DocumentTask < Hekenga::DSL
5
+ configures Hekenga::DocumentTask
6
+
7
+ INVALID_BEHAVIOR_STRATEGIES = [:prompt, :cancel, :stop, :continue]
8
+
9
+ def when_invalid(val)
10
+ unless INVALID_BEHAVIOR_STRATEGIES.include?(val)
11
+ raise "Invalid value #{val}. Valid values for invalid_behavior are: #{INVALID_BEHAVIOR_STRATEGIES.join(", ")}."
12
+ end
13
+ @object.invalid_strategy = val
14
+ end
15
+
16
+ def scope(scope)
17
+ @object.scope = scope
18
+ end
19
+ def parallel!
20
+ @object.parallel = true
21
+ end
22
+ def timeless!
23
+ @object.timeless = true
24
+ end
25
+ def skip_prepare!
26
+ @object.skip_prepare = true
27
+ end
28
+ def disable_callback(callback, args = {})
29
+ [args[:on]].flatten.compact.each do |model|
30
+ @object.disable_rules.push({
31
+ klass: model,
32
+ callback: callback
33
+ })
34
+ end
35
+ end
36
+ def setup(&block)
37
+ @object.setups.push block
38
+ end
39
+
40
+ def filter(&block)
41
+ @object.filters.push block
42
+ end
43
+
44
+ def up(&block)
45
+ @object.ups.push block
46
+ end
47
+
48
+ def down(&block)
49
+ @object.downs.push block
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,32 @@
1
+ require 'hekenga/migration'
2
+ require 'time'
3
+ module Hekenga
4
+ class DSL
5
+ class Migration < Hekenga::DSL
6
+ configures Hekenga::Migration
7
+
8
+ def batch_size(size)
9
+ unless size.is_a?(Fixnum) && size > 0
10
+ raise "Invalid batch size #{size.inspect}"
11
+ end
12
+ @object.batch_size = size
13
+ end
14
+ def created(stamp = nil)
15
+ @object.stamp = Time.parse(stamp)
16
+ end
17
+ def task(description = nil, &block)
18
+ @object.tasks.push Hekenga::DSL::SimpleTask.new(description, &block).object
19
+ end
20
+ def per_document(description = nil, &block)
21
+ @object.tasks.push Hekenga::DSL::DocumentTask.new(description, &block).object
22
+ end
23
+
24
+ def inspect
25
+ "<#{self.class} - #{@object.description} (#{@object.stamp.strftime("%Y-%m-%d %H:%M")})>"
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ require 'hekenga/dsl/simple_task'
32
+ require 'hekenga/dsl/document_task'
@@ -0,0 +1,14 @@
1
+ require 'hekenga/simple_task'
2
+ module Hekenga
3
+ class DSL
4
+ class SimpleTask < Hekenga::DSL
5
+ configures Hekenga::SimpleTask
6
+ def up(&block)
7
+ @object.ups.push(block)
8
+ end
9
+ def down(&block)
10
+ @object.downs.push(block)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,24 @@
1
+ module Hekenga
2
+ class DSL
3
+ attr_reader :object
4
+ def initialize(description = nil, &block)
5
+ @object = self.class.build_klass&.new
6
+ description(description) if description
7
+ instance_exec(&block)
8
+ @object.validate! if @object.respond_to?(:validate!)
9
+ end
10
+ def description(desc = nil)
11
+ @object.description = desc if @object && desc
12
+ end
13
+ def inspect
14
+ "<#{self.class} - #{self.description}>"
15
+ end
16
+ def self.configures(klass)
17
+ @build_klass = klass
18
+ end
19
+ def self.build_klass
20
+ @build_klass
21
+ end
22
+ end
23
+ end
24
+ require 'hekenga/dsl/migration'
@@ -0,0 +1,8 @@
1
+ module Hekenga
2
+ class Failure
3
+ class Cancelled < Failure
4
+ field :document_ids, type: Array
5
+ field :batch_start
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,11 @@
1
+ module Hekenga
2
+ class Failure
3
+ class Error < Failure
4
+ field :message
5
+ field :backtrace
6
+ field :document
7
+ field :batch_start
8
+ field :simple, default: false
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,9 @@
1
+ module Hekenga
2
+ class Failure
3
+ class Validation < Failure
4
+ field :doc_id, type: BSON::ObjectId
5
+ field :errs
6
+ field :document
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,11 @@
1
+ module Hekenga
2
+ class Failure
3
+ class Write < Failure
4
+ field :message
5
+ field :backtrace
6
+ field :documents
7
+ field :document_ids, type: Array
8
+ field :batch_start
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,23 @@
1
+ module Hekenga
2
+ class Failure
3
+ include Mongoid::Document
4
+ belongs_to :log, class_name: "Hekenga::Log"
5
+
6
+ # Internal tracking
7
+ field :pkey
8
+ field :task_idx
9
+
10
+ validates_presence_of [:pkey, :task_idx, :log_id]
11
+
12
+ index({pkey: 1})
13
+ index({log_id: 1})
14
+
15
+ def self.lookup(log_id, task_idx)
16
+ where(log_id: log_id, task_idx: task_idx)
17
+ end
18
+ end
19
+ end
20
+ require 'hekenga/failure/error'
21
+ require 'hekenga/failure/write'
22
+ require 'hekenga/failure/validation'
23
+ require 'hekenga/failure/cancelled'
@@ -0,0 +1,8 @@
1
+ require 'hekenga/base_error'
2
+ module Hekenga
3
+ class Invalid < Hekenga::BaseError
4
+ def initialize(instance, field, reason)
5
+ super("#{instance.class.to_s} has #{reason} #{field}")
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ require 'hekenga/base_error'
2
+ module Hekenga
3
+ class Irreversible < Hekenga::BaseError
4
+ def initialize(task)
5
+ super("#{task.inspect} is not a reversible.")
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,56 @@
1
+ require 'hekenga/failure'
2
+
3
+ module Hekenga
4
+ class Log
5
+ include Mongoid::Document
6
+ # Internal tracking
7
+ field :pkey
8
+ field :description
9
+ field :stamp
10
+ field :task_idx
11
+
12
+ validates_presence_of [:pkey, :description, :stamp, :task_idx]
13
+
14
+ # Status flags
15
+ field :done, default: false
16
+ field :error, default: false
17
+ field :cancel, default: false
18
+ field :skip, default: false
19
+
20
+ # Used by document tasks
21
+ field :total
22
+ field :processed, default: 0
23
+ field :skipped, default: 0
24
+ field :unvalid, default: 0
25
+ field :started, default: ->{ Time.now }
26
+ field :finished, type: Time
27
+
28
+ has_many :failures, class_name: "Hekenga::Failure"
29
+
30
+ index({pkey: 1, task_idx: 1}, unique: true)
31
+
32
+ def migration=(migration)
33
+ self.pkey = migration.to_key
34
+ self.description = migration.description
35
+ self.stamp = migration.stamp
36
+ end
37
+
38
+ def add_failure(attrs, klass)
39
+ self.failures.create({
40
+ pkey: self.pkey,
41
+ task_idx: self.task_idx
42
+ }.merge(attrs), klass)
43
+ end
44
+
45
+ def incr_and_return(fields)
46
+ doc = self.class.where(_id: self.id).find_one_and_update({
47
+ :$inc => fields
48
+ }, return_document: :after, projection: fields.keys.map {|x| [x, 1]}.to_h)
49
+ fields.map do |field, _|
50
+ value = doc.send(field)
51
+ send("#{field}=", value)
52
+ [field, value]
53
+ end.to_h
54
+ end
55
+ end
56
+ end