nsync 0.0.2

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.
@@ -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