petra_core 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 (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