petra_core 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +9 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +83 -0
  5. data/.ruby-version +1 -0
  6. data/.travis.yml +5 -0
  7. data/CODE_OF_CONDUCT.md +74 -0
  8. data/Gemfile +13 -0
  9. data/Gemfile.lock +74 -0
  10. data/MIT-LICENSE +20 -0
  11. data/README.md +726 -0
  12. data/Rakefile +8 -0
  13. data/bin/console +8 -0
  14. data/bin/setup +8 -0
  15. data/examples/continuation_error.rb +125 -0
  16. data/examples/dining_philosophers.rb +138 -0
  17. data/examples/showcase.rb +54 -0
  18. data/lib/petra/components/entries/attribute_change.rb +29 -0
  19. data/lib/petra/components/entries/attribute_change_veto.rb +37 -0
  20. data/lib/petra/components/entries/attribute_read.rb +20 -0
  21. data/lib/petra/components/entries/object_destruction.rb +22 -0
  22. data/lib/petra/components/entries/object_initialization.rb +19 -0
  23. data/lib/petra/components/entries/object_persistence.rb +26 -0
  24. data/lib/petra/components/entries/read_integrity_override.rb +42 -0
  25. data/lib/petra/components/entry_set.rb +87 -0
  26. data/lib/petra/components/log_entry.rb +342 -0
  27. data/lib/petra/components/proxy_cache.rb +209 -0
  28. data/lib/petra/components/section.rb +543 -0
  29. data/lib/petra/components/transaction.rb +405 -0
  30. data/lib/petra/components/transaction_manager.rb +214 -0
  31. data/lib/petra/configuration/base.rb +132 -0
  32. data/lib/petra/configuration/class_configurator.rb +309 -0
  33. data/lib/petra/configuration/configurator.rb +67 -0
  34. data/lib/petra/core_ext.rb +27 -0
  35. data/lib/petra/exceptions.rb +181 -0
  36. data/lib/petra/persistence_adapters/adapter.rb +154 -0
  37. data/lib/petra/persistence_adapters/file_adapter.rb +239 -0
  38. data/lib/petra/proxies/abstract_proxy.rb +149 -0
  39. data/lib/petra/proxies/enumerable_proxy.rb +44 -0
  40. data/lib/petra/proxies/handlers/attribute_read_handler.rb +45 -0
  41. data/lib/petra/proxies/handlers/missing_method_handler.rb +47 -0
  42. data/lib/petra/proxies/method_handlers.rb +213 -0
  43. data/lib/petra/proxies/module_proxy.rb +12 -0
  44. data/lib/petra/proxies/object_proxy.rb +310 -0
  45. data/lib/petra/util/debug.rb +45 -0
  46. data/lib/petra/util/extended_attribute_accessors.rb +51 -0
  47. data/lib/petra/util/field_accessors.rb +35 -0
  48. data/lib/petra/util/registrable.rb +48 -0
  49. data/lib/petra/util/test_helpers.rb +9 -0
  50. data/lib/petra/version.rb +5 -0
  51. data/lib/petra.rb +100 -0
  52. data/lib/tasks/petra_tasks.rake +5 -0
  53. data/petra.gemspec +36 -0
  54. metadata +208 -0
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Petra
4
+
5
+ #----------------------------------------------------------------
6
+ # Exception Base classes
7
+ #----------------------------------------------------------------
8
+
9
+ # Generic error class wrapping all custom petra exceptions
10
+ class PetraError < StandardError
11
+ end
12
+
13
+ #
14
+ # Error class which accepts an options hash and sets its key/value pairs
15
+ # as instance variables. Inherited classes therefore only have to specify the
16
+ # corresponding attribute readers
17
+ #
18
+ class ExtendedError < PetraError
19
+ def initialize(**options)
20
+ options.each do |k, v|
21
+ instance_variable_set("@#{k}", v)
22
+ end
23
+ end
24
+ end
25
+
26
+ # Used whenever a configuration value differs from what petra expects it to be
27
+ class ConfigurationError < PetraError
28
+ end
29
+
30
+ # Used for generic errors during transaction persistence
31
+ class PersistenceError < PetraError
32
+ end
33
+
34
+ # Thrown when a transaction should be persisted, but is locked by another instance
35
+ class TransactionLocked < PetraError
36
+ end
37
+
38
+ class HandlerException < ExtendedError
39
+
40
+ def retry!
41
+ fail Petra::Retry
42
+ end
43
+
44
+ #
45
+ # Resets the currently active transaction
46
+ # This will stop the transaction execution, so make sure that you wrap
47
+ # important code which has to be executed afterwards in an `ensure`
48
+ #
49
+ def reset_transaction!
50
+ @reset = true
51
+ Petra.transaction_manager.reset_transaction
52
+ end
53
+
54
+ #
55
+ # Requests a section rollback on the currently active transaction
56
+ # This will stop the transaction execution, so make sure that you wrap
57
+ # important code which has to be executed afterwards in an `ensure`
58
+ #
59
+ def rollback_transaction!
60
+ @rollback = true
61
+ Petra.transaction_manager.rollback_transaction
62
+ end
63
+
64
+ alias rollback! rollback_transaction!
65
+ alias reset! reset_transaction!
66
+
67
+ def continue!
68
+ fail Petra::ContinuationError, 'The transaction processing cannot be resumed.' unless continuable?
69
+ @continuation.call
70
+ end
71
+
72
+ def continuable?
73
+ false
74
+ end
75
+
76
+ protected
77
+
78
+ def continuation?
79
+ !!@continuation
80
+ end
81
+ end
82
+
83
+ class ValueComparisonError < HandlerException
84
+ attr_reader :object # The affected proxy
85
+ attr_reader :attribute # The affected attribute
86
+ attr_reader :external_value # The new external attribute value
87
+
88
+ #
89
+ # Tells the current transaction to ignore further errors of this kind
90
+ # until the attribute value is changed again externally.
91
+ #
92
+ # @param [Boolean] update_value
93
+ # If set to +true+, the read set entry for this attribute is updated with the
94
+ # new external value. This means that the new value will be visible inside of
95
+ # the transaction until it changes again.
96
+ #
97
+ # Otherwise, the exception is completely ignored and will have no impact
98
+ # on the values displayed inside the transaction.
99
+ #
100
+ def ignore!(update_value: false)
101
+ Petra.current_transaction.current_section.log_read_integrity_override(object,
102
+ attribute: attribute,
103
+ external_value: external_value,
104
+ update_value: update_value)
105
+ end
106
+
107
+ def continuable?
108
+ !@reset && !@rollback
109
+ end
110
+ end
111
+
112
+ # Thrown when a read attribute changed its value externally
113
+ # If we read AND changed the attribute, a ReadWriteIntegrityError is raised instead
114
+ class ReadIntegrityError < ValueComparisonError
115
+ attr_reader :last_read_value # The value we last read for this attribute (before it was changed)
116
+ end
117
+
118
+ # Thrown when an attribute that we previously read AND changed
119
+ # was also changed externally.
120
+ class WriteClashError < ValueComparisonError
121
+ attr_reader :our_value
122
+
123
+ #
124
+ # Tells the transaction to ignore all changes previously done to the current
125
+ # attribute in the transaction.
126
+ #
127
+ def undo_changes!
128
+ Petra.current_transaction.current_section.log_attribute_change_veto(object,
129
+ attribute: attribute,
130
+ external_value: external_value)
131
+ end
132
+
133
+ alias their_value external_value
134
+ alias use_ours! ignore!
135
+ alias use_theirs! undo_changes!
136
+ end
137
+
138
+ #----------------------------------------------------------------
139
+ # Transaction Flow Error Classes
140
+ #----------------------------------------------------------------
141
+
142
+ # Used internally when a lock could not be acquired (non-suspending locking)
143
+ class LockError < HandlerException
144
+ attr_reader :lock_type
145
+ attr_reader :lock_name
146
+
147
+ def initialize(lock_type: 'general', lock_name: 'general', processed: false)
148
+ @lock_type = lock_type
149
+ @lock_name = lock_name
150
+ @processed = processed
151
+ end
152
+
153
+ def processed?
154
+ @processed
155
+ end
156
+ end
157
+
158
+ class ControlFlowException < PetraError
159
+ end
160
+
161
+ # This error is thrown only to tell the transaction manager to
162
+ # abort the current transaction's execution.
163
+ # This is necessary e.g. after successfully committing a transaction
164
+ class AbortTransaction < ControlFlowException
165
+ end
166
+
167
+ # An error class which is never passed on out of petra.
168
+ # It is used to cause a rollback for the currently active petra transaction
169
+ class Rollback < ControlFlowException
170
+ end
171
+
172
+ # See +Rollback+, this error class is used to trigger a complete
173
+ # reset on the currently active petra transaction
174
+ # TODO: Nested transactions anyone?
175
+ class Reset < ControlFlowException
176
+ end
177
+
178
+ class Retry < ControlFlowException
179
+ end
180
+
181
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'petra/util/registrable'
4
+
5
+ module Petra
6
+ module PersistenceAdapters
7
+ # rubocop:disable Lint/UnusedMethodArgument
8
+
9
+ #
10
+ # This class acts as an interface and specifies the methods that
11
+ # a transaction adapter has to implement.
12
+ #
13
+ class Adapter
14
+ include Petra::Util::Registrable
15
+ acts_as_register :adapter
16
+
17
+ class << self
18
+ alias [] registered_adapter
19
+ end
20
+
21
+ #
22
+ # @abstract
23
+ #
24
+ # Persists the transaction steps which happened after
25
+ # the last changes were persisted.
26
+ #
27
+ def persist!
28
+ not_implemented
29
+ end
30
+
31
+ #
32
+ # Adds the given log entry to the queue to be persisted next.
33
+ # Fails if the queue already contains the log entry.
34
+ #
35
+ # @param [Petra::Components::LogEntry] log_entry
36
+ #
37
+ def enqueue(log_entry)
38
+ if queue.include?(log_entry)
39
+ fail Petra::PersistenceError, 'A log entry can only be added to a persistence queue once'
40
+ end
41
+ queue << log_entry
42
+ end
43
+
44
+ #
45
+ # @abstract
46
+ #
47
+ # @return [Array<String>] the identifiers of all transactions which are
48
+ # currently persisted (>= one section finished, but not committed)
49
+ #
50
+ def transaction_identifiers
51
+ not_implemented
52
+ end
53
+
54
+ #
55
+ # @abstract
56
+ #
57
+ # @param [Petra::Components::Transaction] _transaction
58
+ #
59
+ # @return [Array<String>] the names of all savepoints which were previously persisted
60
+ # for the given transaction
61
+ #
62
+ def savepoints(_transaction)
63
+ not_implemented
64
+ end
65
+
66
+ #
67
+ # @abstract
68
+ #
69
+ # @param [Petra::Components::Section] _section
70
+ #
71
+ # @return [Array<Petra::Components::LogEntry>] All log entries which were previously
72
+ # persisted for the given section
73
+ #
74
+ def log_entries(_section)
75
+ not_implemented
76
+ end
77
+
78
+ #
79
+ # Resets the given transaction, meaning that all persisted information is removed
80
+ #
81
+ def reset_transaction(_transaction)
82
+ not_implemented
83
+ end
84
+
85
+ #
86
+ # @abstract
87
+ #
88
+ # Executes the given block after acquiring a global lock
89
+ #
90
+ # The actual implementation must ensure that an acquired lock is released in case of
91
+ # an exception!
92
+ #
93
+ # @param [Boolean] suspend
94
+ # If set to +false+, the method will not suspend if the global lock could not be
95
+ # acquired. Instead, a Petra::LockError is thrown
96
+ #
97
+ # @raise [Petra::LockError] see +suspend+
98
+ #
99
+ def with_global_lock(suspend: true)
100
+ not_implemented
101
+ end
102
+
103
+ #
104
+ # @abstract
105
+ #
106
+ # Executes the given block after acquiring a transaction based lock,
107
+ # meaning that other processes which execute something in the same transaction's context
108
+ # have to wait / abort
109
+ #
110
+ # The actual implementation must ensure that an acquired lock is released in case of
111
+ # an exception!
112
+ #
113
+ # @param [Boolean] suspend
114
+ # If set to +false+, the method will not suspend if the transaction lock could not be
115
+ # acquired. Instead, a Petra::LockError is thrown
116
+ #
117
+ # @raise [Petra::LockError] see +suspend+
118
+ #
119
+ def with_transaction_lock(_identifier, suspend: true)
120
+ not_implemented
121
+ end
122
+
123
+ #
124
+ # @abstract
125
+ #
126
+ # Executes the given block after acquiring the lock for the given proxy (object)
127
+ #
128
+ # The actual implementation must ensure that an acquired lock is released in case of
129
+ # an exception!
130
+ #
131
+ # @param [Petra::Proxies::ObjectProxy] _proxy
132
+ #
133
+ # @param [Boolean] suspend
134
+ # See #with_global_lock
135
+ #
136
+ # @raise [Petra::LockError]
137
+ #
138
+ def with_object_lock(_proxy, suspend: true)
139
+ not_implemented
140
+ end
141
+
142
+ protected
143
+
144
+ def queue
145
+ @queue ||= []
146
+ end
147
+
148
+ def clear_queue!
149
+ @queue = []
150
+ end
151
+ end
152
+ # rubocop:enable Lint/UnusedMethodArgument
153
+ end
154
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'petra/persistence_adapters/adapter'
4
+ require 'yaml'
5
+
6
+ module Petra
7
+ module PersistenceAdapters
8
+ class FileAdapter < Adapter
9
+
10
+ #----------------------------------------------------------------
11
+ # Configuration
12
+ #----------------------------------------------------------------
13
+
14
+ # TODO: change this to use e.g. the field accessors
15
+ class << self
16
+ def storage_directory
17
+ ::Pathname.new(@storage_directory || '/tmp/petra')
18
+ end
19
+
20
+ attr_writer :storage_directory
21
+ end
22
+
23
+ def persist!
24
+ return true if queue.empty?
25
+
26
+ # rubocop:disable Style/WhileUntilDo
27
+ # We currently only allow entries for one transaction in the queue
28
+ with_transaction_lock(queue.first.transaction_identifier) do
29
+ while (entry = queue.shift) do
30
+ identifier = create_entry_file(entry)
31
+ entry.mark_as_persisted!(identifier)
32
+ end
33
+ end
34
+ # rubocop:enable Style/WhileUntilDo
35
+ end
36
+
37
+ def transaction_identifiers
38
+ # Wait until no other transaction is doing stuff that might lead to inconsistent data
39
+ with_global_lock do
40
+ ensure_directory_existence('transactions')
41
+ storage_file_name('transactions').children.select(&:directory?).map(&:basename).map(&:to_s)
42
+ end
43
+ end
44
+
45
+ def savepoints(transaction)
46
+ with_transaction_lock(transaction.identifier) do
47
+ return [] unless File.exist? storage_file_name('transactions', transaction.identifier)
48
+ storage_file_name('transactions', transaction.identifier).children.select(&:directory?).map do |f|
49
+ ::YAML.load_file(f.join('information.yml').to_s)[:savepoint]
50
+ end
51
+ end
52
+ end
53
+
54
+ def log_entries(section)
55
+ with_transaction_lock(section.transaction.identifier) do
56
+ section_dir = storage_file_name(*section_dirname(section))
57
+
58
+ # If the given section has never been persisted before, we don't have to
59
+ # search further for log entries
60
+ return [] unless section_dir.exist?
61
+
62
+ section_dir.children.select { |f| f.extname == '.entry' }.map do |f|
63
+ entry_hash = ::YAML.load_file(f.to_s)
64
+ Petra::Components::LogEntry.from_hash(section, entry_hash)
65
+ end
66
+ end
67
+ end
68
+
69
+ #
70
+ # Removes everything that was persisted while executing the given transaction
71
+ #
72
+ def reset_transaction(transaction)
73
+ with_transaction_lock(transaction) do
74
+ if storage_file_name('transactions', transaction.identifier).exist?
75
+ FileUtils.rm_r(storage_file_name('transactions', transaction.identifier))
76
+ end
77
+ end
78
+ end
79
+
80
+ def with_global_lock(**options, &block)
81
+ with_file_lock('global.persistence', **options, &block)
82
+ rescue Petra::LockError => e
83
+ raise e if e.processed?
84
+ exception = Petra::LockError.new(lock_type: 'global', lock_name: 'global.persistence', processed: true)
85
+ raise exception, 'The global lock could not be acquired.'
86
+ end
87
+
88
+ def with_transaction_lock(transaction, **options, &block)
89
+ identifier = transaction.is_a?(Petra::Components::Transaction) ? transaction.identifier : transaction
90
+ with_file_lock("transaction.#{identifier}", **options, &block)
91
+ rescue Petra::LockError => e
92
+ raise e if e.processed?
93
+ exception = Petra::LockError.new(lock_type: 'transaction', lock_name: identifier, processed: true)
94
+ raise exception, "The transaction lock '#{identifier}' could not be acquired."
95
+ end
96
+
97
+ def with_object_lock(proxy, **options, &block)
98
+ key = proxy.__object_key.gsub(/[^a-zA-Z0-9]/, '-')
99
+ with_file_lock("proxy.#{key}", **options, &block)
100
+ rescue Petra::LockError => e
101
+ raise e if e.processed?
102
+ exception = Petra::LockError.new(lock_type: 'object', lock_name: proxy.__object_key, processed: true)
103
+ raise exception, "The object lock '#{proxy.__object_id}' could not be acquired."
104
+ end
105
+
106
+ private
107
+
108
+ #
109
+ # The Ruby version of `mkdir -p`
110
+ #
111
+ # @param [*Array] path
112
+ # The path to the directory in a format #storage_file_name understands
113
+ #
114
+ def ensure_directory_existence(*path)
115
+ FileUtils.mkdir_p(storage_file_name(*path))
116
+ end
117
+
118
+ #
119
+ # Executes the given block after acquiring a lock on the given filename
120
+ # If the lock is already held by this process, but not with the same file handle,
121
+ # the function will not try to acquire it again.
122
+ #
123
+ # @param [String] filename
124
+ #
125
+ # @param [Boolean] suspend
126
+ # If set to +false+, a LockError is thrown if the lock could not be acquired.
127
+ #
128
+ # @raise [Petra::LockError] If +suspend+ is set to +false+ and the lock could not be acquired
129
+ #
130
+ # TODO: sanitize file names
131
+ #
132
+ def with_file_lock(filename, suspend: true)
133
+ Thread.current[:__petra_file_locks] ||= []
134
+ lock_held = Thread.current[:__petra_file_locks].include?(lock_file_name(filename).to_s)
135
+
136
+ return yield if lock_held
137
+
138
+ File.open(lock_file_name(filename), File::RDWR | File::CREAT, 0o644) do |f|
139
+ if suspend
140
+ f.flock(File::LOCK_EX)
141
+ else
142
+ unless f.flock(File::LOCK_EX | File::LOCK_NB)
143
+ Petra.logger.debug "#{Thread.current.name}: Could not acquire '#{filename}'", :red
144
+ fail Petra::LockError
145
+ end
146
+ end
147
+
148
+ Petra.logger.debug "#{Thread.current.name}: Acquired Lock: #{filename}", :purple
149
+
150
+ Thread.current[:__petra_file_locks] << lock_file_name(filename).to_s
151
+
152
+ begin
153
+ yield
154
+ ensure
155
+ # Should be done automatically when the file handle is closed, but who knows
156
+ f.flock(File::LOCK_UN)
157
+ Petra.logger.debug "#{Thread.current.name}: Released Lock: #{filename}", :cyan
158
+ Thread.current[:__petra_file_locks].delete(lock_file_name(filename).to_s)
159
+ end
160
+ end
161
+ end
162
+
163
+ #
164
+ # Builds the path to a given file based on the configured storage directory
165
+ #
166
+ # @example STORAGE_DIR/oompa/loompa
167
+ # storage_file_name('oompa', 'loompa')
168
+ #
169
+ def storage_file_name(*parts)
170
+ self.class.storage_directory.join(*parts)
171
+ end
172
+
173
+ #
174
+ # @param [String] filename
175
+ #
176
+ # @return [String] the path to a lockfile with the given name
177
+ #
178
+ def lock_file_name(filename)
179
+ # Make sure the locks directory actually exists
180
+ ensure_directory_existence('locks')
181
+ storage_file_name('locks', "petra.#{filename}.lock")
182
+ end
183
+
184
+ #
185
+ # Opens a file within the storage directory and yields its handle
186
+ #
187
+ def with_storage_file(*parts, mode: 'r', perm: 0o644, &block)
188
+ File.open(storage_file_name(*parts), mode, perm, &block)
189
+ end
190
+
191
+ #
192
+ # Creates a directory for the given section.
193
+ # This includes an `information.yml` file within the directory
194
+ # which contains information about the current savepoint and transaction
195
+ #
196
+ # @param [Petra::Components::Section] section
197
+ #
198
+ def add_section_directory(section)
199
+ dir = section_dirname(section)
200
+ ensure_directory_existence(*dir)
201
+
202
+ # If there is already a section directory/information file, we are done.
203
+ return if storage_file_name(*dir, 'information.yml').exist?
204
+
205
+ section_hash = {transaction_identifier: section.transaction.identifier,
206
+ savepoint: section.savepoint,
207
+ savepoint_version: section.savepoint_version}
208
+ with_storage_file(*dir, 'information.yml', mode: 'w') do |f|
209
+ ::YAML.dump(section_hash, f)
210
+ end
211
+ end
212
+
213
+ #
214
+ # Creates a file for the given LogEntry.
215
+ # It contains the necessary information to deserialize it later.
216
+ #
217
+ # These files are placed within a section directory (/transaction/section/entry)
218
+ #
219
+ # @param [Petra::Components::LogEntry] entry
220
+ #
221
+ def create_entry_file(entry)
222
+ add_section_directory(entry.section)
223
+ t = Time.now
224
+ filename = "#{t.to_i}.#{t.nsec}.entry"
225
+ with_storage_file(*section_dirname(entry.section), filename, mode: 'w') do |f|
226
+ ::YAML.dump(entry.to_h(entry_identifier: filename), f)
227
+ end
228
+ filename
229
+ end
230
+
231
+ #
232
+ # @return [Array<String>] The directory name components for the given section within STORAGE_DIR
233
+ #
234
+ def section_dirname(section)
235
+ ['transactions', section.transaction.identifier, section.savepoint_version.to_s]
236
+ end
237
+ end
238
+ end
239
+ end