nsync 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,293 @@
1
+ module Nsync
2
+ # The Nsync::Consumer is used to handle the consumption of data from an Nsync
3
+ # repo for the entire app. It reads in the differences between the current
4
+ # version of data in the database and the new data from the producer, finding
5
+ # and notifying all affected classes and objects.
6
+ #
7
+ # Basic Usage:
8
+ #
9
+ # Nsync::Config.run do |c|
10
+ # # The consumer uses a read-only, bare repository (one ending in .git)
11
+ # # This will automatically be created if it does not exist
12
+ # c.repo_path = "/local/path/to/hold/data.git"
13
+ # # The remote repository url from which to pull data
14
+ # c.repo_url = "git@examplegithost:username/data.git"
15
+ #
16
+ # # An object that implements the VersionManager interface
17
+ # # (see Nsync::GitVersionManager) for an example
18
+ # c.version_manager = MyCustomVersionManager.new
19
+ #
20
+ # # A lock file path to use for this app
21
+ # c.lock_file = "/tmp/app_name_nsync.lock"
22
+ #
23
+ # # The class mapping maps from the class names of the producer classes to
24
+ # # the class names of their associated consuming classes. A producer can
25
+ # # map to one or many consumers, and a consumer can be mapped to one or many
26
+ # # producers. Consumer classes should implement the Consumer interface.
27
+ # c.map_class "RawDataPostClass", "Post"
28
+ # c.map_class "RawDataInfo", "Info"
29
+ # end
30
+ #
31
+ # # Create a new consumer object, this will clone the repo if needed
32
+ # consumer = Nsync::Consumer.new
33
+ #
34
+ # # update this app to the latest data, pulling if necessary
35
+ # consumer.update
36
+ #
37
+ # # rollback the last change
38
+ # consumer.rollback
39
+ class Consumer
40
+ attr_accessor :repo
41
+
42
+ # There was an issue creating or accessing the repository
43
+ class CouldNotInitializeRepoError < RuntimeError; end
44
+
45
+ # Sets the repository to the repo at config.repo_path
46
+ #
47
+ # If config.repo_url is set and the directory at config.repo_path does not
48
+ # exist yet, a new bare repository will be cloned from config.repo_url
49
+ def initialize
50
+ unless get_or_create_repo
51
+ raise CouldNotInitializeRepoError
52
+ end
53
+ end
54
+
55
+ # Updates the data to the latest version
56
+ #
57
+ # If the repo has a remote origin, the latest changes will be fetched.
58
+ #
59
+ # NOTE: It is critical that the version_manager returns correct results
60
+ # as this method goes from what it says is the latest commit that was loaded in
61
+ # to HEAD.
62
+ def update
63
+ update_repo &&
64
+ apply_changes(config.version_manager.version,
65
+ repo.head.commit.id)
66
+ end
67
+
68
+ # Rolls back data to the previous loaded version
69
+ #
70
+ # NOTE: If you rollback and then update, the 'bad' commit will then be reloaded.
71
+ # This is primarily meant as a way to get back to a known good state quickly, while
72
+ # the issues are fixed in the producer.
73
+ def rollback
74
+ apply_changes(config.version_manager.version,
75
+ config.version_manager.previous_version)
76
+ end
77
+
78
+ # @return [Nsync::Config]
79
+ def config
80
+ Nsync.config
81
+ end
82
+
83
+ # Translates and applies the changes between commit id 'a' and commit id 'b' to
84
+ # the datastore. This is used internally by rollback and update. Don't use this
85
+ # unless you absolutely know what you are doing.
86
+ #
87
+ # If you must call this directly, understand that 'a' should almost always be the
88
+ # commit id of the current data that is loaded into the database. 'b' can be any
89
+ # commit in the graph, forward or backwards.
90
+ #
91
+ # @param [String] a current data version commit id
92
+ # @param [String] b new data version commit id
93
+ def apply_changes(a, b)
94
+ config.lock do
95
+ config.log.info("[NSYNC] Moving Nsync::Consumer from '#{a}' to '#{b}'")
96
+ clear_queues
97
+ diffs = nil
98
+ diffs = repo.diff(a, b)
99
+
100
+ changeset = changeset_from_diffs(diffs)
101
+
102
+ if config.ordering
103
+ config.ordering.each do |klass|
104
+ begin
105
+ klass = klass.constantize
106
+ changes = changeset[klass]
107
+ if changes
108
+ apply_changes_for_class(klass, changes)
109
+ end
110
+ rescue NameError
111
+ config.log.warn("[NSYNC] Could not find class '#{klass}' from ordering; skipping")
112
+ end
113
+ end
114
+ else
115
+ changeset.each do |klass, changes|
116
+ apply_changes_for_class(klass, changes)
117
+ end
118
+ end
119
+ run_after_finished
120
+ clear_queues
121
+ config.version_manager.version = b
122
+ end
123
+ end
124
+
125
+
126
+ # @private
127
+ class Change < Struct.new(:id, :diff)
128
+ def type
129
+ if diff.deleted_file
130
+ :deleted
131
+ elsif diff.new_file
132
+ :added
133
+ else
134
+ :modified
135
+ end
136
+ end
137
+
138
+ def data
139
+ @data ||= JSON.load(diff.b_blob.data)
140
+ rescue
141
+ {}
142
+ end
143
+ end
144
+
145
+ # Adds a callback to the list of callbacks to occur after main processing
146
+ # of the class specified by 'klass'. Can be used to handle data relations
147
+ # between objects of the same class.
148
+ #
149
+ # Example:
150
+ #
151
+ # class Post
152
+ # def nsync_update(consumer, event_type, filename, data)
153
+ # #... normal data update stuff ...
154
+ # post = self
155
+ # related_post_source_ids = data['related_post_ids']
156
+ # consumer.after_class_finished(Post, lambda {
157
+ # posts = Post.all(:conditions =>
158
+ # {:source_id => related_post_source_ids })
159
+ # post.related_posts = posts
160
+ # })
161
+ # end
162
+ # end
163
+ #
164
+ # @param [Class] klass
165
+ # @param [Proc] l
166
+ def after_class_finished(klass, l)
167
+ @after_class_finished_queues[klass] ||= []
168
+ @after_class_finished_queues[klass] << l
169
+ end
170
+
171
+ # Adds a callback to the list of callbacks to occur after main processing
172
+ # of the class that is currently being processed. This is essentially an
173
+ # alias for after_class_finished for the current class
174
+ #
175
+ # @param [Proc] l
176
+ def after_current_class_finished(l)
177
+ after_class_finished(@current_class_for_queue, l)
178
+ end
179
+
180
+
181
+ # Adds a callback to the list of callbacks to occur after all changes have
182
+ # been applied. This queue executes immediately prior to the current
183
+ # version being updated
184
+ #
185
+ # @param [Proc] l
186
+ def after_finished(l)
187
+ @after_finished_queue ||= []
188
+ @after_finished_queue << l
189
+ end
190
+
191
+ protected
192
+ def get_or_create_repo
193
+ if config.local? || File.exists?(config.repo_path)
194
+ return self.repo = Grit::Repo.new(config.repo_path)
195
+ end
196
+
197
+ config.lock do
198
+ git = Grit::Git.new(config.repo_path)
199
+ git.clone({:bare => true}, config.repo_url, config.repo_path)
200
+ self.repo = Grit::Repo.new(config.repo_path)
201
+ config.version_manager.version = git.rev_list({:reverse => true}, "master").split("\n").first
202
+ return self.repo
203
+ end
204
+ end
205
+
206
+ def update_repo
207
+ return true if config.local?
208
+ config.lock do
209
+ repo.remote_fetch('origin')
210
+ # from http://www.pragmatic-source.com/en/opensource/tips/automatic-synchronization-2-git-repositories
211
+ repo.git.reset({:soft => true}, 'FETCH_HEAD')
212
+ true
213
+ end
214
+ end
215
+
216
+ def apply_changes_for_class(klass, changes)
217
+ @current_class_for_queue = klass
218
+ if klass.respond_to?(:nsync_find)
219
+ changes.each do |change|
220
+ objects = klass.nsync_find(change.id)
221
+ if objects.empty? && change.type != :deleted
222
+ if klass.respond_to?(:nsync_add_data)
223
+ config.log.info("[NSYNC] Adding data #{diff_path(change.diff)} to #{klass}")
224
+ klass.nsync_add_data(self, change.type, diff_path(change.diff), change.data)
225
+ else
226
+ config.log.warn("[NSYNC] Class '#{klass}' has no method nsync_add_data; skipping")
227
+ end
228
+ else
229
+ objects.each do |obj|
230
+ if obj.respond_to?(:nsync_update)
231
+ obj.nsync_update(self, change.type, diff_path(change.diff),
232
+ change.data)
233
+ config.log.info("[NSYNC] Updating from #{diff_path(change.diff)} to #{obj.inspect}")
234
+ else
235
+ config.log.info("[NSYNC] Object #{obj.inspect} has no method nsync_update; skipping")
236
+ end
237
+ end
238
+ end
239
+ end
240
+ else
241
+ config.log.warn("[NSYNC] Consumer class '#{klass}' has no method nsync_find; skipping")
242
+ end
243
+ @current_class_for_queue = nil
244
+ run_after_class_finished(klass)
245
+ end
246
+
247
+ def clear_queues
248
+ @after_class_finished_queues = {}
249
+ @after_finished_queue = []
250
+ end
251
+
252
+ def run_after_class_finished(klass)
253
+ queue = @after_class_finished_queues[klass]
254
+ if queue
255
+ queue.each do |l|
256
+ l.call
257
+ end
258
+ end
259
+ end
260
+
261
+ def run_after_finished
262
+ if @after_finished_queue
263
+ @after_finished_queue.each do |l|
264
+ l.call
265
+ end
266
+ end
267
+ end
268
+
269
+ def changeset_from_diffs(diffs)
270
+ diffs.inject({}) do |h, diff|
271
+ next h if diff_path(diff) =~ /\.gitignore$/
272
+
273
+ classes, id = consumer_classes_and_id_from_path(diff_path(diff))
274
+ classes.each do |klass|
275
+ h[klass] ||= []
276
+ h[klass] << Change.new(id, diff)
277
+ end
278
+ h
279
+ end
280
+ end
281
+
282
+ def consumer_classes_and_id_from_path(path)
283
+ producer_class_name = File.dirname(path).camelize
284
+ id = File.basename(path, ".json")
285
+ [config.consumer_classes_for(producer_class_name), id]
286
+ end
287
+
288
+ def diff_path(diff)
289
+ diff.b_path || diff.a_path
290
+ end
291
+ end
292
+ end
293
+
@@ -0,0 +1,29 @@
1
+ module Nsync
2
+ class GitVersionManager
3
+ def initialize(repo_path = nil)
4
+ @repo_path = repo_path
5
+ end
6
+
7
+ def repo
8
+ # if the repo is not ready, lets just hope it works
9
+ @repo ||= Grit::Repo.new(@repo_path || Nsync.config.repo_path) rescue nil
10
+ end
11
+
12
+ def version
13
+ repo.head.commit.id if repo
14
+ end
15
+
16
+ def version=(val)
17
+ val
18
+ end
19
+
20
+ def previous_version
21
+ previous_versions[0]
22
+ end
23
+
24
+ def previous_versions
25
+ repo.commits("master", 10, 1).map(&:id) if repo
26
+ end
27
+ end
28
+ end
29
+
@@ -0,0 +1,23 @@
1
+ module Nsync
2
+ class Producer
3
+ module InstanceMethods
4
+ def nsync_write
5
+ nsync_opts = self.class.read_inheritable_attribute(:nsync_opts)
6
+ if !nsync_opts[:if] || nsync_opts[:if].call(self)
7
+ Nsync.config.producer_instance.write_file(nsync_filename, to_nsync_hash)
8
+ elsif Nsync.config.producer_instance.file_exists?(nsync_filename)
9
+ nsync_destroy
10
+ end
11
+ end
12
+
13
+ def nsync_destroy
14
+ Nsync.config.producer_instance.remove_file(nsync_filename)
15
+ end
16
+
17
+ def nsync_filename
18
+ nsync_opts = self.class.read_inheritable_attribute(:nsync_opts)
19
+ File.join(self.class.to_s.underscore, "#{send(nsync_opts[:id_key])}.json")
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,129 @@
1
+ module Nsync
2
+ # The Nsync::Producer is used to tend to the repo and allow files to be
3
+ # written out and changesets to be commited. It is a subclass of the
4
+ # Nsync::Consumer, which allows a Producer to consume itself. This gives it
5
+ # the ability to perform rollbacks and undo misstakes in that way
6
+ #
7
+ # Basic Usage:
8
+ # Nsync::Config.run do |c|
9
+ # # The producer uses a standard repository
10
+ # # This will automatically be created if it does not exist
11
+ # c.repo_path = "/local/path/to/hold/data"
12
+ # # The remote repository url will get data pushed to it
13
+ # c.repo_push_url = "git@examplegithost:username/data.git"
14
+ #
15
+ # # This must be Nsync::GitVersionManager if you want things like
16
+ # # rollback to work.
17
+ # c.version_manager = Nsync::GitVersionManager.new
18
+ #
19
+ # # A lock file path to use for this app
20
+ # c.lock_file = "/tmp/app_name_nsync.lock"
21
+ # end
22
+ #
23
+ # # make some changes that get written out to the repo
24
+ #
25
+ # @producer = Nsync::Producer.new
26
+ #
27
+ # @producer.commit("Some nice changes for you")
28
+ class Producer < Consumer
29
+ # Determines whether a file at 'filename' exists in the working tree
30
+ def file_exists?(filename)
31
+ File.exists?(File.join(config.repo_path, filename))
32
+ end
33
+
34
+ # Writes a file to the repo at 'filename' with content in json from the
35
+ # hash
36
+ #
37
+ # @param [String] filename path in the working tree to write to
38
+ # @param [Hash] hash a hash that can be converted to json to be written
39
+ def write_file(filename, hash)
40
+ config.cd do
41
+ dir = File.dirname(filename)
42
+ unless [".", "/"].include?(dir) || File.directory?(dir)
43
+ FileUtils.mkdir_p(File.join(config.repo_path, dir))
44
+ end
45
+
46
+ File.open(File.join(config.repo_path, filename), "w") do |f|
47
+ f.write( (hash.is_a?(Hash))? hash.to_json : hash )
48
+ end
49
+ repo.add(File.join(config.repo_path, filename))
50
+ config.log.info("[NSYNC] Updated file '#{filename}'")
51
+ end
52
+ true
53
+ end
54
+
55
+ # Removes a file from the repo at 'filename'
56
+ def remove_file(filename)
57
+ FileUtils.rm File.join(config.repo_path, filename)
58
+ config.log.info("[NSYNC] Removed file '#{filename}'")
59
+ end
60
+
61
+ # Commits and pushes the current changeset
62
+ def commit(message="Friendly data update")
63
+ config.lock do
64
+ config.cd do
65
+ repo.commit_all(message)
66
+ config.log.info("[NSYNC] Committed '#{message}' to repo")
67
+ end
68
+ end
69
+ push
70
+ end
71
+
72
+ # Pushes all changes to the repo_push_url
73
+ def push
74
+ if config.remote_push?
75
+ config.cd do
76
+ repo.git.push({}, config.repo_push_url, "+master")
77
+ config.log.info("[NSYNC] Pushed changes")
78
+ end
79
+ end
80
+ true
81
+ end
82
+
83
+ # Returns data to its state at HEAD~1 and sets HEAD to that
84
+ # This new HEAD state is pushed to the repo_push_url. Hooray git.
85
+ def rollback
86
+ commit_to_rollback = config.version_manager.version
87
+ commit_to_rollback_to = config.version_manager.previous_version
88
+ config.cd do
89
+ repo.git.reset({:hard => true}, commit_to_rollback_to)
90
+ apply_changes(commit_to_rollback, commit_to_rollback_to)
91
+ end
92
+ push
93
+ end
94
+
95
+ protected
96
+
97
+ def update(*args)
98
+ super
99
+ end
100
+
101
+ def apply_changes(*args)
102
+ super
103
+ end
104
+
105
+ def get_or_create_repo
106
+ if File.exists?(config.repo_path)
107
+ return self.repo = Grit::Repo.new(config.repo_path)
108
+ end
109
+
110
+ config.lock do
111
+ self.repo = Grit::Repo.init(config.repo_path)
112
+ write_file(".gitignore", "")
113
+ end
114
+ commit("Initial Commit")
115
+ end
116
+
117
+ def consumer_classes_and_id_from_path(path)
118
+ producer_class_name = File.dirname(path).camelize
119
+ id = File.basename(path, ".json")
120
+ classes = config.consumer_classes_for(producer_class_name)
121
+
122
+ # refinement to allow the producer to consume itself
123
+ if classes.empty?
124
+ classes = [producer_class_name.constantize].compact
125
+ end
126
+ [classes, id]
127
+ end
128
+ end
129
+ end
data/lib/nsync.rb ADDED
@@ -0,0 +1,30 @@
1
+ require 'rubygems'
2
+ require 'fileutils'
3
+
4
+ # just for now
5
+ gem 'activesupport', "~> 2.3.5"
6
+ require 'active_support'
7
+
8
+ gem "schleyfox-grit", ">= 2.3.0.1"
9
+ require 'grit'
10
+
11
+ #up the timeout, as these repos can get quite large
12
+ Grit::Git.git_timeout = 60 # 1 minute should do
13
+ Grit::Git.git_max_size = 100.megabytes # tweak this up for very large changesets
14
+
15
+ gem "schleyfox-lockfile", ">= 1.0.0"
16
+ require 'lockfile'
17
+
18
+ begin
19
+ require 'yajl/json_gem'
20
+ rescue LoadError
21
+ puts "Yajl not installed; falling back to json"
22
+ require 'json'
23
+ end
24
+
25
+ require 'nsync/config'
26
+ require 'nsync/consumer'
27
+ require 'nsync/git_version_manager'
28
+ require 'nsync/producer'
29
+ require 'nsync/class_methods'
30
+ require 'nsync/active_record/methods.rb'
data/nsync.gemspec ADDED
@@ -0,0 +1,80 @@
1
+ # Generated by jeweler
2
+ # DO NOT EDIT THIS FILE
3
+ # Instead, edit Jeweler::Tasks in Rakefile, and run `rake gemspec`
4
+ # -*- encoding: utf-8 -*-
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = %q{nsync}
8
+ s.version = "0.0.2"
9
+
10
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
+ s.authors = ["Ben Hughes"]
12
+ s.date = %q{2010-12-06}
13
+ s.description = %q{Nsync is designed to allow you to have a separate data
14
+ processing app with its own data processing optimized database and a consumer
15
+ app with its own database, while keeping the data as in sync as you want it.}
16
+ s.email = %q{ben@pixelmachine.org}
17
+ s.extra_rdoc_files = [
18
+ "LICENSE",
19
+ "README.md"
20
+ ]
21
+ s.files = [
22
+ "LICENSE",
23
+ "README.md",
24
+ "Rakefile",
25
+ "VERSION",
26
+ "jeweler_monkey_patch.rb",
27
+ "lib/nsync.rb",
28
+ "lib/nsync/active_record/consumer/methods.rb",
29
+ "lib/nsync/active_record/methods.rb",
30
+ "lib/nsync/active_record/producer/methods.rb",
31
+ "lib/nsync/class_methods.rb",
32
+ "lib/nsync/config.rb",
33
+ "lib/nsync/consumer.rb",
34
+ "lib/nsync/git_version_manager.rb",
35
+ "lib/nsync/producer.rb",
36
+ "lib/nsync/producer/methods.rb",
37
+ "nsync.gemspec"
38
+ ]
39
+ s.homepage = %q{http://github.com/schleyfox/nsync}
40
+ s.require_paths = ["lib"]
41
+ s.rubygems_version = %q{1.3.6}
42
+ s.summary = %q{Keep your data processors and apps in sync}
43
+ s.test_files = [
44
+ "test/active_record_test.rb",
45
+ "test/classes.rb",
46
+ "test/helper.rb",
47
+ "test/nsync_config_test.rb",
48
+ "test/nsync_consumer_test.rb",
49
+ "test/nsync_producer_test.rb",
50
+ "test/repo.rb"
51
+ ]
52
+
53
+ if s.respond_to? :specification_version then
54
+ current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
55
+ s.specification_version = 3
56
+
57
+ if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
58
+ s.add_runtime_dependency(%q<json>, [">= 0"])
59
+ s.add_runtime_dependency(%q<activesupport>, ["~> 2.3.5"])
60
+ s.add_runtime_dependency(%q<schleyfox-grit>, [">= 2.3.0.1"])
61
+ s.add_runtime_dependency(%q<schleyfox-lockfile>, [">= 1.0.0"])
62
+ s.add_development_dependency(%q<shoulda>, [">= 0"])
63
+ s.add_development_dependency(%q<mocha>, [">= 0"])
64
+ else
65
+ s.add_dependency(%q<json>, [">= 0"])
66
+ s.add_dependency(%q<activesupport>, ["~> 2.3.5"])
67
+ s.add_dependency(%q<schleyfox-grit>, [">= 2.3.0.1"])
68
+ s.add_dependency(%q<schleyfox-lockfile>, [">= 1.0.0"])
69
+ s.add_dependency(%q<shoulda>, [">= 0"])
70
+ s.add_dependency(%q<mocha>, [">= 0"])
71
+ end
72
+ else
73
+ s.add_dependency(%q<json>, [">= 0"])
74
+ s.add_dependency(%q<activesupport>, ["~> 2.3.5"])
75
+ s.add_dependency(%q<schleyfox-grit>, [">= 2.3.0.1"])
76
+ s.add_dependency(%q<schleyfox-lockfile>, [">= 1.0.0"])
77
+ s.add_dependency(%q<shoulda>, [">= 0"])
78
+ s.add_dependency(%q<mocha>, [">= 0"])
79
+ end
80
+ end