perobs 3.0.1 → 4.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (75) hide show
  1. checksums.yaml +5 -5
  2. data/README.md +19 -18
  3. data/lib/perobs.rb +2 -0
  4. data/lib/perobs/Array.rb +68 -21
  5. data/lib/perobs/BTree.rb +110 -54
  6. data/lib/perobs/BTreeBlob.rb +14 -13
  7. data/lib/perobs/BTreeDB.rb +11 -10
  8. data/lib/perobs/BTreeNode.rb +551 -197
  9. data/lib/perobs/BTreeNodeCache.rb +10 -8
  10. data/lib/perobs/BTreeNodeLink.rb +11 -1
  11. data/lib/perobs/BigArray.rb +285 -0
  12. data/lib/perobs/BigArrayNode.rb +1002 -0
  13. data/lib/perobs/BigHash.rb +246 -0
  14. data/lib/perobs/BigTree.rb +197 -0
  15. data/lib/perobs/BigTreeNode.rb +873 -0
  16. data/lib/perobs/Cache.rb +47 -22
  17. data/lib/perobs/ClassMap.rb +2 -2
  18. data/lib/perobs/ConsoleProgressMeter.rb +61 -0
  19. data/lib/perobs/DataBase.rb +4 -3
  20. data/lib/perobs/DynamoDB.rb +62 -20
  21. data/lib/perobs/EquiBlobsFile.rb +174 -59
  22. data/lib/perobs/FNV_Hash_1a_64.rb +54 -0
  23. data/lib/perobs/FlatFile.rb +536 -242
  24. data/lib/perobs/FlatFileBlobHeader.rb +120 -84
  25. data/lib/perobs/FlatFileDB.rb +58 -27
  26. data/lib/perobs/FuzzyStringMatcher.rb +175 -0
  27. data/lib/perobs/Hash.rb +129 -35
  28. data/lib/perobs/IDList.rb +144 -0
  29. data/lib/perobs/IDListPage.rb +107 -0
  30. data/lib/perobs/IDListPageFile.rb +180 -0
  31. data/lib/perobs/IDListPageRecord.rb +142 -0
  32. data/lib/perobs/LockFile.rb +3 -0
  33. data/lib/perobs/Object.rb +28 -20
  34. data/lib/perobs/ObjectBase.rb +53 -10
  35. data/lib/perobs/PersistentObjectCache.rb +142 -0
  36. data/lib/perobs/PersistentObjectCacheLine.rb +99 -0
  37. data/lib/perobs/ProgressMeter.rb +97 -0
  38. data/lib/perobs/SpaceManager.rb +273 -0
  39. data/lib/perobs/SpaceTree.rb +63 -47
  40. data/lib/perobs/SpaceTreeNode.rb +134 -115
  41. data/lib/perobs/SpaceTreeNodeLink.rb +1 -1
  42. data/lib/perobs/StackFile.rb +1 -1
  43. data/lib/perobs/Store.rb +180 -70
  44. data/lib/perobs/version.rb +1 -1
  45. data/perobs.gemspec +4 -4
  46. data/test/Array_spec.rb +48 -39
  47. data/test/BTreeDB_spec.rb +2 -2
  48. data/test/BTree_spec.rb +50 -1
  49. data/test/BigArray_spec.rb +261 -0
  50. data/test/BigHash_spec.rb +152 -0
  51. data/test/BigTreeNode_spec.rb +153 -0
  52. data/test/BigTree_spec.rb +259 -0
  53. data/test/EquiBlobsFile_spec.rb +105 -5
  54. data/test/FNV_Hash_1a_64_spec.rb +59 -0
  55. data/test/FlatFileDB_spec.rb +199 -15
  56. data/test/FuzzyStringMatcher_spec.rb +261 -0
  57. data/test/Hash_spec.rb +27 -16
  58. data/test/IDList_spec.rb +77 -0
  59. data/test/LegacyDBs/LegacyDB.rb +155 -0
  60. data/test/LegacyDBs/version_3/class_map.json +1 -0
  61. data/test/LegacyDBs/version_3/config.json +1 -0
  62. data/test/LegacyDBs/version_3/database.blobs +0 -0
  63. data/test/LegacyDBs/version_3/database_spaces.blobs +0 -0
  64. data/test/LegacyDBs/version_3/index.blobs +0 -0
  65. data/test/LegacyDBs/version_3/version +1 -0
  66. data/test/LockFile_spec.rb +9 -6
  67. data/test/Object_spec.rb +5 -5
  68. data/test/SpaceManager_spec.rb +176 -0
  69. data/test/SpaceTree_spec.rb +27 -9
  70. data/test/Store_spec.rb +353 -206
  71. data/test/perobs_spec.rb +7 -3
  72. data/test/spec_helper.rb +9 -4
  73. metadata +59 -16
  74. data/lib/perobs/SpaceTreeNodeCache.rb +0 -76
  75. data/lib/perobs/TreeDB.rb +0 -277
data/lib/perobs/Object.rb CHANGED
@@ -2,7 +2,7 @@
2
2
  #
3
3
  # = Object.rb -- Persistent Ruby Object Store
4
4
  #
5
- # Copyright (c) 2015, 2016 by Chris Schlaeger <chris@taskjuggler.org>
5
+ # Copyright (c) 2015, 2016, 2017 by Chris Schlaeger <chris@taskjuggler.org>
6
6
  #
7
7
  # MIT License
8
8
  #
@@ -49,9 +49,11 @@ module PEROBS
49
49
  attr_reader :attributes
50
50
 
51
51
  # This method can be used to define instance variable for
52
- # PEROBS::Object derived classes.
52
+ # PEROBS::Object derived classes. Persistent attributes always have
53
+ # getter and setter methods defined. So it's essentially equivalent to
54
+ # attr_accessor but additionally declares an attribute as persistent.
53
55
  # @param attributes [Symbol] Name of the instance variable
54
- def po_attr(*attributes)
56
+ def attr_persist(*attributes)
55
57
  attributes.each do |attr_name|
56
58
  unless attr_name.is_a?(Symbol)
57
59
  PEROBS.log.fatal "name must be a symbol but is a " +
@@ -73,6 +75,9 @@ module PEROBS
73
75
  end
74
76
  end
75
77
 
78
+ # This is the deprecated name for the attr_persist method
79
+ alias po_attr attr_persist
80
+
76
81
  end
77
82
 
78
83
  attr_reader :attributes
@@ -108,7 +113,7 @@ module PEROBS
108
113
  # object was saved with an earlier version of the program that did not yet
109
114
  # have the instance variable. If you want to assign another PEROBS object
110
115
  # to the variable you should use the block variant to avoid unnecessary
111
- # creation of PEROBS object that later need to be collected again.
116
+ # creation of PEROBS objects that later need to be collected again.
112
117
  def attr_init(attr, val = nil, &block)
113
118
  if _all_attributes.include?(attr)
114
119
  unless instance_variable_defined?('@' + attr.to_s)
@@ -140,7 +145,7 @@ module PEROBS
140
145
 
141
146
  # Return a list of all object IDs that the attributes of this instance are
142
147
  # referencing.
143
- # @return [Array of Fixnum or Bignum] IDs of referenced objects
148
+ # @return [Array of Integer] IDs of referenced objects
144
149
  def _referenced_object_ids
145
150
  ids = []
146
151
  _all_attributes.each do |attr|
@@ -152,7 +157,7 @@ module PEROBS
152
157
 
153
158
  # This method should only be used during store repair operations. It will
154
159
  # delete all references to the given object ID.
155
- # @param id [Fixnum/Bignum] targeted object ID
160
+ # @param id [Integer] targeted object ID
156
161
  def _delete_reference_to_id(id)
157
162
  _all_attributes.each do |attr|
158
163
  ivar = ('@' + attr.to_s).to_sym
@@ -207,17 +212,7 @@ module PEROBS
207
212
  end
208
213
 
209
214
  def _set(attr, val)
210
- if val.respond_to?(:is_poxreference?)
211
- # References to other PEROBS::Objects must be handled somewhat
212
- # special.
213
- if @store != val.store
214
- PEROBS.log.fatal 'The referenced object is not part of this store'
215
- end
216
- elsif val.is_a?(ObjectBase)
217
- PEROBS.log.fatal 'A PEROBS::ObjectBase object escaped! ' +
218
- 'Have you used self() instead of myself() to get the reference ' +
219
- 'of the PEROBS object that you are trying to assign here?'
220
- end
215
+ _check_assignment_value(val)
221
216
  instance_variable_set(('@' + attr.to_s).to_sym, val)
222
217
  # Let the store know that we have a modified object. If we restored the
223
218
  # object from the DB, we don't mark it as modified.
@@ -231,13 +226,26 @@ module PEROBS
231
226
  end
232
227
 
233
228
  def _all_attributes
229
+ # Collect all persistent attributes from this class and all
230
+ # super classes into a single Array.
231
+ attributes = []
232
+ klass = self.class
233
+ while klass && klass.respond_to?(:attributes)
234
+ if (attrs = klass.attributes)
235
+ attributes += attrs
236
+ end
237
+ klass = klass.superclass
238
+ end
239
+
234
240
  # PEROBS objects that don't have persistent attributes declared don't
235
241
  # really make sense.
236
- unless self.class.attributes
242
+ if attributes.empty?
237
243
  PEROBS.log.fatal "No persistent attributes have been declared for " +
238
- "class #{self.class}. Use 'po_attr' to declare them."
244
+ "class #{self.class} or any parent class. Use 'attr_persist' " +
245
+ "to declare them."
239
246
  end
240
- self.class.attributes
247
+
248
+ attributes
241
249
  end
242
250
 
243
251
  end
@@ -86,6 +86,10 @@ module PEROBS
86
86
  _referenced_object == obj
87
87
  end
88
88
 
89
+ def eql?(obj)
90
+ _referenced_object._id == obj._id
91
+ end
92
+
89
93
  # BasicObject provides a equal?() method that prevents method_missing from
90
94
  # being called. So we have to pass the call manually to the referenced
91
95
  # object.
@@ -98,6 +102,13 @@ module PEROBS
98
102
  end
99
103
  end
100
104
 
105
+ # To allow POXReference objects to be used as Hash keys we need to
106
+ # implement this function. Conveniently, we can just use the PEROBS object
107
+ # ID since that is unique.
108
+ def hash
109
+ @id
110
+ end
111
+
101
112
  # Shortcut to access the _id() method of the referenced object.
102
113
  def _id
103
114
  @id
@@ -114,6 +125,20 @@ module PEROBS
114
125
  # common to all classes of persistent objects.
115
126
  class ObjectBase
116
127
 
128
+ # This is a list of the native Ruby classes that are supported for
129
+ # instance variable assignements in addition to other PEROBS objects.
130
+ if RUBY_VERSION < '2.2'
131
+ NATIVE_CLASSES = [
132
+ NilClass, Integer, Bignum, Fixnum, Float, String, Time,
133
+ TrueClass, FalseClass
134
+ ]
135
+ else
136
+ NATIVE_CLASSES = [
137
+ NilClass, Integer, Float, String, Time,
138
+ TrueClass, FalseClass
139
+ ]
140
+ end
141
+
117
142
  attr_reader :_id, :store, :myself
118
143
 
119
144
  # New PEROBS objects must always be created by calling # Store.new().
@@ -133,7 +158,8 @@ module PEROBS
133
158
  @store = p.store
134
159
  @_id = p.id
135
160
  @store._register_in_memory(self, @_id)
136
- ObjectSpace.define_finalizer(self, ObjectBase._finalize(@store, @_id))
161
+ ObjectSpace.define_finalizer(
162
+ self, ObjectBase._finalize(@store, @_id, object_id))
137
163
  @_stash_map = nil
138
164
  # Allocate a proxy object for this object. User code should only operate
139
165
  # on this proxy, never on self.
@@ -144,8 +170,8 @@ module PEROBS
144
170
  # is done this way to prevent the Proc object hanging on to a reference to
145
171
  # self which would prevent the object from being collected. This internal
146
172
  # method is not intended for users to call.
147
- def ObjectBase._finalize(store, id)
148
- proc { store._collect(id) }
173
+ def ObjectBase._finalize(store, id, ruby_object_id)
174
+ proc { store._collect(id, ruby_object_id) }
149
175
  end
150
176
 
151
177
  # Library internal method to transfer the Object to a new store.
@@ -158,7 +184,8 @@ module PEROBS
158
184
  # Register the object as in-memory object with the new store.
159
185
  @store._register_in_memory(self, @_id)
160
186
  # Register the finalizer for the new store.
161
- ObjectSpace.define_finalizer(self, ObjectBase._finalize(@store, @_id))
187
+ ObjectSpace.define_finalizer(
188
+ self, ObjectBase._finalize(@store, @_id, object_id))
162
189
  @myself = POXReference.new(@store, @_id)
163
190
  end
164
191
 
@@ -190,6 +217,25 @@ module PEROBS
190
217
  @store.db.put_object(db_obj, @_id)
191
218
  end
192
219
 
220
+ #
221
+ def _check_assignment_value(val)
222
+ if val.respond_to?(:is_poxreference?)
223
+ # References to other PEROBS::Objects must be handled somewhat
224
+ # special.
225
+ if @store != val.store
226
+ PEROBS.log.fatal 'The referenced object is not part of this store'
227
+ end
228
+ elsif val.is_a?(ObjectBase)
229
+ PEROBS.log.fatal 'A PEROBS::ObjectBase object escaped! ' +
230
+ 'Have you used self() instead of myself() to get the reference ' +
231
+ 'of the PEROBS object that you are trying to assign here?'
232
+ elsif !NATIVE_CLASSES.include?(val.class)
233
+ PEROBS.log.fatal "Assigning objects of class #{val.class} is not " +
234
+ "supported. Only PEROBS objects or one of the following classes " +
235
+ "are supported: #{NATIVE_CLASSES.join(', ')}"
236
+ end
237
+ end
238
+
193
239
  # Read an raw object with the specified ID from the backing store and
194
240
  # instantiate a new object of the specific type.
195
241
  def ObjectBase.read(store, id)
@@ -207,19 +253,16 @@ module PEROBS
207
253
  end
208
254
 
209
255
  # Restore the object state from the storage back-end.
210
- # @param level [Fixnum] the transaction nesting level
256
+ # @param level [Integer] the transaction nesting level
211
257
  def _restore(level)
212
258
  # Find the most recently stored state of this object. This could be on
213
259
  # any previous stash level or in the regular object DB. If the object
214
- # was created during the transaction, there is not previous state to
260
+ # was created during the transaction, there is no previous state to
215
261
  # restore to.
216
262
  data = nil
217
263
  if @_stash_map
218
264
  (level - 1).downto(0) do |lvl|
219
- if @_stash_map[lvl]
220
- data = @_stash_map[lvl]
221
- break
222
- end
265
+ break if (data = @_stash_map[lvl])
223
266
  end
224
267
  end
225
268
  if data
@@ -0,0 +1,142 @@
1
+ # encoding: UTF-8
2
+ #
3
+ # = PersistentObjectCache.rb -- Persistent Ruby Object Store
4
+ #
5
+ # Copyright (c) 2016, 2017 by Chris Schlaeger <chris@taskjuggler.org>
6
+ #
7
+ # MIT License
8
+ #
9
+ # Permission is hereby granted, free of charge, to any person obtaining
10
+ # a copy of this software and associated documentation files (the
11
+ # "Software"), to deal in the Software without restriction, including
12
+ # without limitation the rights to use, copy, modify, merge, publish,
13
+ # distribute, sublicense, and/or sell copies of the Software, and to
14
+ # permit persons to whom the Software is furnished to do so, subject to
15
+ # the following conditions:
16
+ #
17
+ # The above copyright notice and this permission notice shall be
18
+ # included in all copies or substantial portions of the Software.
19
+ #
20
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
21
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
22
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
24
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
25
+ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
26
+ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
27
+
28
+ require 'perobs/PersistentObjectCacheLine'
29
+
30
+ module PEROBS
31
+
32
+ class PersistentObjectCache
33
+
34
+ # This cache class manages the presence of objects that primarily live in
35
+ # a backing store but temporarily exist in memory as well. To work with
36
+ # these objects, direct references must be only very short lived. Indirect
37
+ # references can be done via a unique ID that the object must provide. Due
38
+ # to the indirect references the Ruby garbage collector can collect these
39
+ # objects. To reduce the read and write latencies of the backing store
40
+ # this class keeps a subset of the objects in memory which prevents them
41
+ # from being collected. All references to the objects must be resolved via
42
+ # the get() method to prevent duplicate instances in memory of the same
43
+ # in-store object. The cache uses a least-recently-used (LRU) scheme to
44
+ # cache objects.
45
+ # @param size [Integer] Minimum number of objects to be cached at a time
46
+ # @param flush_delay [Integer] Determines how often non-forced flushes are
47
+ # ignored in a row before the flush is really done. If flush_delay
48
+ # is smaller than 0 non-forced flushed will always be ignored.
49
+ # @param klass [Class] The class of the objects to be cached. Objects must
50
+ # provide a uid() method that returns a unique ID for every object.
51
+ # @param collection [] The object collection the objects belong to. It
52
+ # must provide a ::load method.
53
+ def initialize(size, flush_delay, klass, collection)
54
+ @size = size
55
+ @klass = klass
56
+ @collection = collection
57
+ @flush_delay = @flush_counter = flush_delay
58
+ @flush_times = 0
59
+
60
+ clear
61
+ end
62
+
63
+ # Insert an object into the cache.
64
+ # @param object [Object] Object to cache
65
+ # @param modified [Boolean] True if the object was modified, false otherwise
66
+ def insert(object, modified = true)
67
+ unless object.is_a?(@klass)
68
+ raise ArgumentError, "You can insert only #{@klass} objects in this " +
69
+ "cache. You have tried to insert a #{object.class} instead."
70
+ end
71
+
72
+ if modified
73
+ @modified_entries[object.uid] = object
74
+ else
75
+ @unmodified_entries[object.uid % @size] = object
76
+ end
77
+
78
+ nil
79
+ end
80
+
81
+ # Retrieve a object reference from the cache.
82
+ # @param uid [Integer] uid of the object to retrieve.
83
+ # @param ref [Object] optional reference to be used by the load method
84
+ def get(uid, ref = nil)
85
+ # First check if it's a modified object.
86
+ if (object = @modified_entries[uid])
87
+ return object
88
+ end
89
+
90
+ # Then check the unmodified object list.
91
+ if (object = @unmodified_entries[uid % @size]) && object.uid == uid
92
+ return object
93
+ end
94
+
95
+ # If we don't have it in memory we need to load it.
96
+ @klass::load(@collection, uid, ref)
97
+ end
98
+
99
+ # Remove a object from the cache.
100
+ # @param uid [Integer] unique ID of object to remove.
101
+ def delete(uid)
102
+ @modified_entries.delete(uid)
103
+
104
+ index = uid % @size
105
+ if (object = @unmodified_entries[index]) && object.uid == uid
106
+ @unmodified_entries[index] = nil
107
+ end
108
+ end
109
+
110
+ # Write all excess modified objects into the backing store. If now is true
111
+ # all modified objects will be written.
112
+ # @param now [Boolean]
113
+ def flush(now = false)
114
+ if now || (@flush_delay >= 0 && (@flush_counter -= 1) <= 0)
115
+ @modified_entries.each do |id, object|
116
+ object.save
117
+ # Add the object to the unmodified object cache. We might still need
118
+ # it again soon.
119
+ @unmodified_entries[object.uid % @size] = object
120
+ end
121
+ @modified_entries = ::Hash.new
122
+ @flush_counter = @flush_delay
123
+ end
124
+ @flush_times += 1
125
+ end
126
+
127
+ # Remove all entries from the cache.
128
+ def clear
129
+ # This Array stores all unmodified entries. It has a fixed size and uses
130
+ # a % operation to compute the index from the object ID.
131
+ @unmodified_entries = ::Array.new(@size)
132
+
133
+ # This Hash stores all modified entries. It can grow and shrink as
134
+ # needed. A flush operation writes all modified objects into the backing
135
+ # store.
136
+ @modified_entries = ::Hash.new
137
+ end
138
+
139
+ end
140
+
141
+ end
142
+
@@ -0,0 +1,99 @@
1
+ # encoding: UTF-8
2
+ #
3
+ # = PersistentObjectCacheLine.rb -- Persistent Ruby Object Store
4
+ #
5
+ # Copyright (c) 2016, 2017 by Chris Schlaeger <chris@taskjuggler.org>
6
+ #
7
+ # MIT License
8
+ #
9
+ # Permission is hereby granted, free of charge, to any person obtaining
10
+ # a copy of this software and associated documentation files (the
11
+ # "Software"), to deal in the Software without restriction, including
12
+ # without limitation the rights to use, copy, modify, merge, publish,
13
+ # distribute, sublicense, and/or sell copies of the Software, and to
14
+ # permit persons to whom the Software is furnished to do so, subject to
15
+ # the following conditions:
16
+ #
17
+ # The above copyright notice and this permission notice shall be
18
+ # included in all copies or substantial portions of the Software.
19
+ #
20
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
21
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
22
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
24
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
25
+ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
26
+ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
27
+
28
+ module PEROBS
29
+
30
+ class PersistentObjectCacheLine
31
+
32
+ # Utility class to store persistent objects and their
33
+ # modified/not-modified state.
34
+ class Entry < Struct.new(:obj, :modified)
35
+ end
36
+
37
+ # This defines the minimum size of the cache line. If it is too large, the
38
+ # time to find an entry will grow too much. If it is too small the number
39
+ # of cache lines will be too large and create more store overhead. By
40
+ # running benchmarks it turned out that 8 is a pretty good compromise.
41
+ WATERMARK = 8
42
+
43
+ def initialize
44
+ @entries = []
45
+ end
46
+
47
+ def insert(object, modified)
48
+ if (index = @entries.find_index{ |e| e.obj.uid == object.uid })
49
+ # We have found and removed an existing entry for this particular
50
+ # object. If the modified flag is set, ensure that the entry has it
51
+ # set as well.
52
+ entry = @entries.delete_at(index)
53
+ entry.modified = true if modified && !entry.modified
54
+ else
55
+ # There is no existing entry for this object. Create a new one.
56
+ entry = Entry.new(object, modified)
57
+ end
58
+
59
+ # Insert the entry at the beginning of the line.
60
+ @entries.unshift(entry)
61
+ end
62
+
63
+ def get(uid)
64
+ if (index = @entries.find_index{ |e| e.obj.uid == uid })
65
+ if index > 0
66
+ # Move the entry to the front.
67
+ @entries.unshift(@entries.delete_at(index))
68
+ end
69
+ @entries.first
70
+ else
71
+ nil
72
+ end
73
+ end
74
+
75
+ # Delete the entry that matches the given UID
76
+ # @param uid [Integer]
77
+ def delete(uid)
78
+ @entries.delete_if { |e| e.obj.uid == uid }
79
+ end
80
+
81
+ # Save all modified entries and delete all but the most recently added.
82
+ def flush(now)
83
+ if now || @entries.length > WATERMARK
84
+ @entries.each do |e|
85
+ if e.modified
86
+ e.obj.save
87
+ e.modified = false
88
+ end
89
+ end
90
+
91
+ # Delete all but the first WATERMARK entry.
92
+ @entries = @entries[0..WATERMARK - 1] if @entries.length > WATERMARK
93
+ end
94
+ end
95
+
96
+ end
97
+
98
+ end
99
+