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.
- data/LICENSE +674 -0
- data/README.rdoc +29 -0
- data/Rakefile +75 -0
- data/TODO +42 -0
- data/lib/app/models/offroad/group_state.rb +85 -0
- data/lib/app/models/offroad/mirror_info.rb +53 -0
- data/lib/app/models/offroad/model_state.rb +36 -0
- data/lib/app/models/offroad/received_record_state.rb +109 -0
- data/lib/app/models/offroad/sendable_record_state.rb +91 -0
- data/lib/app/models/offroad/system_state.rb +33 -0
- data/lib/cargo_streamer.rb +222 -0
- data/lib/controller_extensions.rb +74 -0
- data/lib/exceptions.rb +16 -0
- data/lib/migrate/20100512164608_create_offroad_tables.rb +72 -0
- data/lib/mirror_data.rb +354 -0
- data/lib/model_extensions.rb +377 -0
- data/lib/module_funcs.rb +94 -0
- data/lib/offroad.rb +30 -0
- data/lib/version.rb +3 -0
- data/lib/view_helper.rb +7 -0
- data/templates/offline.rb +36 -0
- data/templates/offline_database.yml +7 -0
- data/templates/offroad.yml +6 -0
- data/test/app_root/app/controllers/application_controller.rb +2 -0
- data/test/app_root/app/controllers/group_controller.rb +28 -0
- data/test/app_root/app/models/global_record.rb +10 -0
- data/test/app_root/app/models/group.rb +12 -0
- data/test/app_root/app/models/group_owned_record.rb +68 -0
- data/test/app_root/app/models/guest.rb +7 -0
- data/test/app_root/app/models/subrecord.rb +12 -0
- data/test/app_root/app/models/unmirrored_record.rb +4 -0
- data/test/app_root/app/views/group/download_down_mirror.html.erb +4 -0
- data/test/app_root/app/views/group/download_initial_down_mirror.html.erb +4 -0
- data/test/app_root/app/views/group/download_up_mirror.html.erb +6 -0
- data/test/app_root/app/views/group/upload_down_mirror.html.erb +1 -0
- data/test/app_root/app/views/group/upload_up_mirror.html.erb +1 -0
- data/test/app_root/app/views/layouts/mirror.html.erb +9 -0
- data/test/app_root/config/boot.rb +115 -0
- data/test/app_root/config/database.yml +6 -0
- data/test/app_root/config/environment.rb +15 -0
- data/test/app_root/config/environments/test.rb +17 -0
- data/test/app_root/config/offroad.yml +6 -0
- data/test/app_root/config/routes.rb +4 -0
- data/test/app_root/db/migrate/20100529235049_create_tables.rb +64 -0
- data/test/app_root/lib/common_hobo.rb +15 -0
- data/test/app_root/vendor/plugins/offroad/init.rb +2 -0
- data/test/functional/mirror_operations_test.rb +148 -0
- data/test/test_helper.rb +405 -0
- data/test/unit/app_state_tracking_test.rb +275 -0
- data/test/unit/cargo_streamer_test.rb +332 -0
- data/test/unit/global_data_test.rb +102 -0
- data/test/unit/group_controller_test.rb +152 -0
- data/test/unit/group_data_test.rb +435 -0
- data/test/unit/group_single_test.rb +136 -0
- data/test/unit/hobo_permissions_test.rb +57 -0
- data/test/unit/mirror_data_test.rb +1271 -0
- data/test/unit/mirror_info_test.rb +31 -0
- data/test/unit/module_funcs_test.rb +37 -0
- data/test/unit/pathological_model_test.rb +62 -0
- data/test/unit/test_framework_test.rb +86 -0
- data/test/unit/unmirrored_data_test.rb +14 -0
- metadata +140 -0
data/lib/mirror_data.rb
ADDED
@@ -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
|