offroad 0.0.1

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.
Files changed (62) hide show
  1. data/LICENSE +674 -0
  2. data/README.rdoc +29 -0
  3. data/Rakefile +75 -0
  4. data/TODO +42 -0
  5. data/lib/app/models/offroad/group_state.rb +85 -0
  6. data/lib/app/models/offroad/mirror_info.rb +53 -0
  7. data/lib/app/models/offroad/model_state.rb +36 -0
  8. data/lib/app/models/offroad/received_record_state.rb +109 -0
  9. data/lib/app/models/offroad/sendable_record_state.rb +91 -0
  10. data/lib/app/models/offroad/system_state.rb +33 -0
  11. data/lib/cargo_streamer.rb +222 -0
  12. data/lib/controller_extensions.rb +74 -0
  13. data/lib/exceptions.rb +16 -0
  14. data/lib/migrate/20100512164608_create_offroad_tables.rb +72 -0
  15. data/lib/mirror_data.rb +354 -0
  16. data/lib/model_extensions.rb +377 -0
  17. data/lib/module_funcs.rb +94 -0
  18. data/lib/offroad.rb +30 -0
  19. data/lib/version.rb +3 -0
  20. data/lib/view_helper.rb +7 -0
  21. data/templates/offline.rb +36 -0
  22. data/templates/offline_database.yml +7 -0
  23. data/templates/offroad.yml +6 -0
  24. data/test/app_root/app/controllers/application_controller.rb +2 -0
  25. data/test/app_root/app/controllers/group_controller.rb +28 -0
  26. data/test/app_root/app/models/global_record.rb +10 -0
  27. data/test/app_root/app/models/group.rb +12 -0
  28. data/test/app_root/app/models/group_owned_record.rb +68 -0
  29. data/test/app_root/app/models/guest.rb +7 -0
  30. data/test/app_root/app/models/subrecord.rb +12 -0
  31. data/test/app_root/app/models/unmirrored_record.rb +4 -0
  32. data/test/app_root/app/views/group/download_down_mirror.html.erb +4 -0
  33. data/test/app_root/app/views/group/download_initial_down_mirror.html.erb +4 -0
  34. data/test/app_root/app/views/group/download_up_mirror.html.erb +6 -0
  35. data/test/app_root/app/views/group/upload_down_mirror.html.erb +1 -0
  36. data/test/app_root/app/views/group/upload_up_mirror.html.erb +1 -0
  37. data/test/app_root/app/views/layouts/mirror.html.erb +9 -0
  38. data/test/app_root/config/boot.rb +115 -0
  39. data/test/app_root/config/database.yml +6 -0
  40. data/test/app_root/config/environment.rb +15 -0
  41. data/test/app_root/config/environments/test.rb +17 -0
  42. data/test/app_root/config/offroad.yml +6 -0
  43. data/test/app_root/config/routes.rb +4 -0
  44. data/test/app_root/db/migrate/20100529235049_create_tables.rb +64 -0
  45. data/test/app_root/lib/common_hobo.rb +15 -0
  46. data/test/app_root/vendor/plugins/offroad/init.rb +2 -0
  47. data/test/functional/mirror_operations_test.rb +148 -0
  48. data/test/test_helper.rb +405 -0
  49. data/test/unit/app_state_tracking_test.rb +275 -0
  50. data/test/unit/cargo_streamer_test.rb +332 -0
  51. data/test/unit/global_data_test.rb +102 -0
  52. data/test/unit/group_controller_test.rb +152 -0
  53. data/test/unit/group_data_test.rb +435 -0
  54. data/test/unit/group_single_test.rb +136 -0
  55. data/test/unit/hobo_permissions_test.rb +57 -0
  56. data/test/unit/mirror_data_test.rb +1271 -0
  57. data/test/unit/mirror_info_test.rb +31 -0
  58. data/test/unit/module_funcs_test.rb +37 -0
  59. data/test/unit/pathological_model_test.rb +62 -0
  60. data/test/unit/test_framework_test.rb +86 -0
  61. data/test/unit/unmirrored_data_test.rb +14 -0
  62. metadata +140 -0
@@ -0,0 +1,354 @@
1
+ module Offroad
2
+ private
3
+
4
+ class MirrorData
5
+ attr_reader :group, :mode
6
+
7
+ def initialize(group, options = {})
8
+ @group = group
9
+ @initial_mode = options.delete(:initial_mode) || false
10
+ @skip_validation = options.delete(:skip_validation) || false
11
+
12
+ raise PluginError.new("Invalid option keys") unless options.size == 0
13
+
14
+ unless Offroad::app_offline? && @initial_mode
15
+ raise PluginError.new("Need group") unless @group.is_a?(Offroad::group_base_model) && !@group.new_record?
16
+ raise DataError.new("Group must be in offline mode") unless @group.group_offline?
17
+ end
18
+
19
+ @imported_models_to_validate = []
20
+ end
21
+
22
+ def write_upwards_data(tgt = nil)
23
+ raise PluginError.new("Can only write upwards data in offline mode") unless Offroad.app_offline?
24
+ raise PluginError.new("No such thing as initial upwards data") if @initial_mode
25
+ write_data(tgt) do |cs|
26
+ add_group_specific_cargo(cs)
27
+ end
28
+ end
29
+
30
+ def write_downwards_data(tgt = nil)
31
+ raise PluginError.new("Can only write downwards data in online mode") unless Offroad.app_online?
32
+ write_data(tgt) do |cs|
33
+ add_global_cargo(cs)
34
+ if @initial_mode
35
+ add_group_specific_cargo(cs)
36
+ end
37
+ end
38
+ end
39
+
40
+ def load_upwards_data(src)
41
+ raise PluginError.new("Can only load upwards data in online mode") unless Offroad.app_online?
42
+ raise PluginError.new("No such thing as initial upwards data") if @initial_mode
43
+
44
+ read_data_from("offline", src) do |cs, mirror_info, cargo_group_state|
45
+ unless cargo_group_state.confirmed_group_data_version > @group.group_state.confirmed_group_data_version
46
+ raise OldDataError.new("File contains old up-mirror data")
47
+ end
48
+ import_group_specific_cargo(cs)
49
+ @group.group_offline = false if cargo_group_state.group_locked?
50
+ end
51
+ end
52
+
53
+ def load_downwards_data(src)
54
+ raise PluginError.new("Can only load downwards data in offline mode") unless Offroad.app_offline?
55
+
56
+ read_data_from("online", src) do |cs, mirror_info, cargo_group_state|
57
+ raise DataError.new("Unexpected initial file value") unless mirror_info.initial_file == @initial_mode
58
+
59
+ group_cargo_name = MirrorData::data_cargo_name_for_model(Offroad::group_base_model)
60
+ if mirror_info.initial_file
61
+ raise DataError.new("No group data in initial down mirror file") unless cs.has_cargo_named?(group_cargo_name)
62
+ # This is an initial mirror file, so we want it to determine the entirety of the database's new state
63
+ # However, existing data is safe if there's a mid-import error; read_data_from places us in a transaction
64
+ delete_all_existing_database_records!
65
+
66
+ import_global_cargo(cs) # Global cargo must be done first because group data might belong_to global data
67
+ import_group_specific_cargo(cs)
68
+ else
69
+ # Regular, non-initial down mirror file
70
+ unless cargo_group_state.confirmed_global_data_version > @group.group_state.confirmed_global_data_version
71
+ raise OldDataError.new("File contains old down-mirror data")
72
+ end
73
+ import_global_cargo(cs)
74
+ end
75
+
76
+ # Load information into our group state that the online app is in a better position to know about
77
+ @group = Offroad::offline_group if @initial_mode
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ def self.data_cargo_name_for_model(model)
84
+ "data_#{model.name}"
85
+ end
86
+
87
+ def self.deletion_cargo_name_for_model(model)
88
+ "deletion_#{model.name}"
89
+ end
90
+
91
+ def delete_all_existing_database_records!
92
+ # Emptying sqlite_sequence resets SQLite's autoincrement counters.
93
+ # SQLite's autoincrement is nice in that automatically picks largest ever id + 1.
94
+ # This means that after clearing sqlite_sequence and then populating database with manually-id'd rows,
95
+ # new records will be inserted with unique id's, no problem.
96
+ tables = ["sqlite_sequence"] + ActiveRecord::Base.connection.tables
97
+
98
+ tables.each do |table|
99
+ next if table.start_with?("VIRTUAL_") # Used in testing
100
+ next if table == "schema_migrations"
101
+ ActiveRecord::Base.connection.execute "DELETE FROM #{table}"
102
+ end
103
+ end
104
+
105
+ def write_data(tgt)
106
+ cs = nil
107
+ temp_sio = nil
108
+ case tgt
109
+ when CargoStreamer
110
+ cs = tgt
111
+ when nil
112
+ temp_sio = StringIO.new("", "w")
113
+ cs = CargoStreamer.new(temp_sio, "w")
114
+ else
115
+ cs = CargoStreamer.new(tgt, "w")
116
+ end
117
+
118
+ # TODO: Figure out if this transaction ensures we get a consistent read state
119
+ Offroad::group_base_model.connection.transaction do
120
+ Offroad::group_base_model.cache do
121
+ begin
122
+ mirror_info = MirrorInfo.new_from_group(@group, @initial_mode)
123
+ cs.write_cargo_section("mirror_info", [mirror_info], :human_readable => true)
124
+
125
+ group_state = @group.group_state
126
+ if Offroad::app_online?
127
+ # Let the offline app know what global data version it's being updated to
128
+ group_state.confirmed_global_data_version = SystemState::current_mirror_version
129
+ else
130
+ # Let the online app know what group data version the online mirror of this group is being updated to
131
+ group_state.confirmed_group_data_version = SystemState::current_mirror_version
132
+ end
133
+ cs.write_cargo_section("group_state", [group_state], :human_readable => true)
134
+
135
+ yield cs
136
+
137
+ SystemState::increment_mirror_version
138
+ rescue Offroad::CargoStreamerError
139
+ raise Offroad::DataError.new("Encountered data validation error while writing to cargo file")
140
+ end
141
+ end
142
+ end
143
+
144
+ return temp_sio.string if temp_sio
145
+ end
146
+
147
+ def read_data_from(expected_source_app_mode, src)
148
+ cs = case src
149
+ when CargoStreamer then src
150
+ when String then CargoStreamer.new(StringIO.new(src, "r"), "r")
151
+ else CargoStreamer.new(src, "r")
152
+ end
153
+
154
+ raise DataError.new("Invalid mirror file, no info section found") unless cs.has_cargo_named?("mirror_info")
155
+ mirror_info = cs.first_cargo_element("mirror_info")
156
+ raise DataError.new("Invalid info section type") unless mirror_info.is_a?(MirrorInfo)
157
+ unless mirror_info.app_mode.downcase == expected_source_app_mode.downcase
158
+ raise DataError.new "Mirror file was generated by app in wrong mode; was expecting #{expected_source_app_mode}"
159
+ end
160
+
161
+ raise DataError.new("Invalid mirror file, no group state found") unless cs.has_cargo_named?("group_state")
162
+ group_state = cs.first_cargo_element("group_state")
163
+ raise DataError.new("Invalid group state type") unless group_state.is_a?(GroupState)
164
+ group_state.readonly!
165
+
166
+ # FIXME: Is this transaction call helping at all?
167
+ Offroad::group_base_model.connection.transaction do
168
+ Offroad::group_base_model.cache do
169
+ yield cs, mirror_info, group_state
170
+ validate_imported_models(cs) unless @skip_validation
171
+
172
+ # Load information into our group state that the remote app is in a better position to know about
173
+ @group.group_state.update_from_remote_group_state!(group_state) if @group && @group.group_offline?
174
+ end
175
+ end
176
+
177
+ SystemState::increment_mirror_version if @initial_mode
178
+ end
179
+
180
+ def add_group_specific_cargo(cs)
181
+ Offroad::group_owned_models.each do |name, model|
182
+ add_model_cargo(cs, model)
183
+ end
184
+ Offroad::group_single_models.each do |name, model|
185
+ add_model_cargo(cs, model)
186
+ end
187
+ add_model_cargo(cs, Offroad::group_base_model)
188
+ end
189
+
190
+ def add_global_cargo(cs)
191
+ Offroad::global_data_models.each do |name, model|
192
+ add_model_cargo(cs, model)
193
+ end
194
+ end
195
+
196
+ def add_model_cargo(cs, model)
197
+ if @initial_mode
198
+ add_initial_model_cargo(cs, model)
199
+ else
200
+ add_non_initial_model_cargo(cs, model)
201
+ end
202
+ end
203
+
204
+ def add_initial_model_cargo(cs, model)
205
+ # Include the data for relevant records in this model
206
+ data_source = model
207
+ data_source = data_source.owned_by_offroad_group(@group) if model.offroad_group_data? && @group
208
+ data_source.find_in_batches(:batch_size => 100) do |batch|
209
+ cs.write_cargo_section(
210
+ MirrorData::data_cargo_name_for_model(model),
211
+ batch,
212
+ :skip_validation => @skip_validation
213
+ )
214
+
215
+ if model.offroad_group_data?
216
+ # In initial mode the remote app will create records with the same id's as the corresponding records here
217
+ # So we'll create RRSes indicating that we've already "received" the data we're about to send
218
+ # Later when the remote app sends new information on those records, we'll know which ones it means
219
+ rrs_source = Offroad::ReceivedRecordState.for_model_and_group_if_apropos(model, @group)
220
+ existing_rrs = rrs_source.all(:conditions => {:remote_record_id => batch.map(&:id)}).index_by(&:remote_record_id)
221
+ new_rrs = batch.reject{|r| existing_rrs.has_key?(r.id)}.map{|r| rrs_source.for_record(r).new(:remote_record_id => r.id)}
222
+ if new_rrs.size > 0
223
+ Offroad::ReceivedRecordState.import(new_rrs, :validate => false, :timestamps => false)
224
+ end
225
+ end
226
+ end
227
+ end
228
+
229
+ def add_non_initial_model_cargo(cs, model)
230
+ # Include the data for relevant records in this model that are newer than the remote side's known latest version
231
+ gs = @group.group_state
232
+ remote_version = nil
233
+ if model.offroad_group_data?
234
+ remote_version = gs.confirmed_group_data_version
235
+ else
236
+ remote_version = gs.confirmed_global_data_version
237
+ end
238
+ srs_source = SendableRecordState.for_model(model).with_version_greater_than(remote_version)
239
+ srs_source.for_non_deleted_records.find_in_batches(:batch_size => 100) do |srs_batch|
240
+ # TODO Might be able to optimize this to one query using a join on app model and SRS tables
241
+ record_ids = srs_batch.map { |srs| srs.local_record_id }
242
+ data_batch = model.find(:all, :conditions => {:id => record_ids})
243
+ raise PluginError.new("Invalid SRS ids") if data_batch.size != srs_batch.size
244
+ cs.write_cargo_section(
245
+ MirrorData::data_cargo_name_for_model(model),
246
+ data_batch,
247
+ :skip_validation => @skip_validation
248
+ )
249
+ end
250
+
251
+ # Also need to include information about records that have been destroyed
252
+ srs_source.for_deleted_records.find_in_batches(:batch_size => 100) do |deletion_batch|
253
+ cs.write_cargo_section(MirrorData::deletion_cargo_name_for_model(model), deletion_batch)
254
+ end
255
+ end
256
+
257
+ def import_group_specific_cargo(cs)
258
+ import_model_cargo(cs, Offroad::group_base_model)
259
+ Offroad::group_owned_models.each do |name, model|
260
+ import_model_cargo(cs, model)
261
+ end
262
+ Offroad::group_single_models.each do |name, model|
263
+ import_model_cargo(cs, model)
264
+ end
265
+ end
266
+
267
+ def import_global_cargo(cs)
268
+ Offroad::global_data_models.each do |name, model|
269
+ import_model_cargo(cs, model)
270
+ end
271
+ end
272
+
273
+ def import_model_cargo(cs, model)
274
+ @imported_models_to_validate.push model
275
+
276
+ if @initial_mode && model.offroad_group_data?
277
+ import_initial_model_cargo(cs, model)
278
+ else
279
+ import_non_initial_model_cargo(cs, model)
280
+ end
281
+ end
282
+
283
+ def import_initial_model_cargo(cs, model)
284
+ cs.each_cargo_section(MirrorData::data_cargo_name_for_model(model)) do |batch|
285
+ # Notice we are using the same primary key values as the online system, not allocating new ones
286
+ model.import batch, :validate => false, :timestamps => false
287
+ if model.offroad_group_base? && batch.size > 0
288
+ GroupState.for_group(model.first).create!
289
+ end
290
+ SendableRecordState.setup_imported(model, batch)
291
+ if model.instance_methods.include?("after_offroad_upload")
292
+ batch.each { |rec| rec.after_offroad_upload }
293
+ end
294
+ end
295
+ end
296
+
297
+ def import_non_initial_model_cargo(cs, model)
298
+ rrs_source = ReceivedRecordState.for_model_and_group_if_apropos(model, @group)
299
+
300
+ # Update/create records
301
+ cs.each_cargo_section(MirrorData::data_cargo_name_for_model(model)) do |batch|
302
+ # Update foreign key associations to use local ids instead of remote ids
303
+ model.reflect_on_all_associations(:belongs_to).each do |a|
304
+ ReceivedRecordState.redirect_to_local_ids(batch, a.primary_key_name, a.klass, @group)
305
+ end
306
+
307
+ # Delete existing records in the database; that way we can just do INSERTs, don't have to worry about UPDATEs
308
+ # TODO: Is this necessary? Perhaps ar-extensions can deal with a mix of new and updated records...
309
+ model.delete rrs_source.all(:conditions => {:remote_record_id => batch.map(&:id)} ).map(&:local_record_id)
310
+
311
+ # Update the primary keys to use local ids, then insert the records
312
+ ReceivedRecordState.redirect_to_local_ids(batch, model.primary_key, model, @group)
313
+ model.import batch, :validate => false, :timestamps => false
314
+
315
+ if model.instance_methods.include?("after_offroad_upload")
316
+ batch.each { |rec| rec.after_offroad_upload }
317
+ end
318
+ end
319
+
320
+ # Delete records here which were destroyed there (except for group_base records, that would cause trouble)
321
+ return if model == Offroad::group_base_model
322
+ cs.each_cargo_section(MirrorData::deletion_cargo_name_for_model(model)) do |batch|
323
+ # If there's a callback, we need to load the local records before deleting them
324
+ local_recs = []
325
+ if model.instance_methods.include?("after_offroad_destroy")
326
+ local_recs = model.all(:conditions => {:id => batch.map(&:local_record_id)})
327
+ end
328
+
329
+ # Each deletion batch is made up of SendableRecordStates from the remote system
330
+ dying_rrs_batch = rrs_source.all(:conditions => {:remote_record_id => batch.map(&:local_record_id)})
331
+ model.delete dying_rrs_batch.map(&:local_record_id)
332
+ ReceivedRecordState.delete dying_rrs_batch.map(&:id)
333
+ local_recs.each { |rec| rec.after_offroad_destroy }
334
+ end
335
+ end
336
+
337
+ def validate_imported_models(cs)
338
+ while @imported_models_to_validate.size > 0
339
+ model = @imported_models_to_validate.pop
340
+ rrs_source = Offroad::ReceivedRecordState.for_model_and_group_if_apropos(model, @group)
341
+
342
+ cs.each_cargo_section(MirrorData::data_cargo_name_for_model(model)) do |cargo_batch|
343
+ if @initial_mode
344
+ local_batch = model.all(:conditions => {:id => cargo_batch.map(&:id)})
345
+ else
346
+ local_rrs_batch = rrs_source.all(:conditions => {:remote_record_id => cargo_batch.map(&:id)})
347
+ local_batch = model.all(:conditions => {:id => local_rrs_batch.map(&:local_record_id)})
348
+ end
349
+ raise Offroad::DataError.new("Invalid record found in mirror data") unless local_batch.all?(&:valid?)
350
+ end
351
+ end
352
+ end
353
+ end
354
+ end