io-complyance-unify-sdk 3.0.0

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 (41) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +26 -0
  3. data/README.md +595 -0
  4. data/lib/complyance/circuit_breaker.rb +99 -0
  5. data/lib/complyance/persistent_queue_manager.rb +474 -0
  6. data/lib/complyance/retry_strategy.rb +198 -0
  7. data/lib/complyance_sdk/config/retry_config.rb +127 -0
  8. data/lib/complyance_sdk/config/sdk_config.rb +212 -0
  9. data/lib/complyance_sdk/exceptions/circuit_breaker_open_error.rb +14 -0
  10. data/lib/complyance_sdk/exceptions/sdk_exception.rb +93 -0
  11. data/lib/complyance_sdk/generators/config_generator.rb +67 -0
  12. data/lib/complyance_sdk/generators/install_generator.rb +22 -0
  13. data/lib/complyance_sdk/generators/templates/complyance_initializer.rb +36 -0
  14. data/lib/complyance_sdk/http/authentication_middleware.rb +43 -0
  15. data/lib/complyance_sdk/http/client.rb +223 -0
  16. data/lib/complyance_sdk/http/logging_middleware.rb +153 -0
  17. data/lib/complyance_sdk/jobs/base_job.rb +63 -0
  18. data/lib/complyance_sdk/jobs/process_document_job.rb +92 -0
  19. data/lib/complyance_sdk/jobs/sidekiq_job.rb +165 -0
  20. data/lib/complyance_sdk/middleware/rack_middleware.rb +39 -0
  21. data/lib/complyance_sdk/models/country.rb +205 -0
  22. data/lib/complyance_sdk/models/country_policy_registry.rb +159 -0
  23. data/lib/complyance_sdk/models/document_type.rb +52 -0
  24. data/lib/complyance_sdk/models/environment.rb +144 -0
  25. data/lib/complyance_sdk/models/logical_doc_type.rb +228 -0
  26. data/lib/complyance_sdk/models/mode.rb +47 -0
  27. data/lib/complyance_sdk/models/operation.rb +47 -0
  28. data/lib/complyance_sdk/models/policy_result.rb +145 -0
  29. data/lib/complyance_sdk/models/purpose.rb +52 -0
  30. data/lib/complyance_sdk/models/source.rb +104 -0
  31. data/lib/complyance_sdk/models/source_ref.rb +130 -0
  32. data/lib/complyance_sdk/models/unify_request.rb +208 -0
  33. data/lib/complyance_sdk/models/unify_response.rb +198 -0
  34. data/lib/complyance_sdk/queue/persistent_queue_manager.rb +609 -0
  35. data/lib/complyance_sdk/railtie.rb +29 -0
  36. data/lib/complyance_sdk/retry/circuit_breaker.rb +159 -0
  37. data/lib/complyance_sdk/retry/retry_manager.rb +108 -0
  38. data/lib/complyance_sdk/retry/retry_strategy.rb +225 -0
  39. data/lib/complyance_sdk/version.rb +5 -0
  40. data/lib/complyance_sdk.rb +935 -0
  41. metadata +322 -0
@@ -0,0 +1,99 @@
1
+ require 'logger'
2
+
3
+ module Complyance
4
+ # Circuit Breaker implementation for handling service failures
5
+ class CircuitBreaker
6
+ STATE_CLOSED = 'CLOSED'
7
+ STATE_OPEN = 'OPEN'
8
+ STATE_HALF_OPEN = 'HALF_OPEN'
9
+
10
+ attr_reader :state, :failure_count, :last_failure_time
11
+
12
+ # Initialize circuit breaker
13
+ # @param config [Hash] Circuit breaker configuration
14
+ # @option config [Integer] :failure_threshold Number of failures before opening circuit
15
+ # @option config [Integer] :reset_timeout Timeout in seconds before attempting reset
16
+ def initialize(config = {})
17
+ @state = STATE_CLOSED
18
+ @failure_count = 0
19
+ @last_failure_time = 0
20
+ @failure_threshold = config[:failure_threshold] || 3
21
+ @reset_timeout = config[:reset_timeout] || 60
22
+ @logger = Logger.new(STDOUT)
23
+ @logger.level = Logger::INFO
24
+ end
25
+
26
+ # Execute a function with circuit breaker protection
27
+ # @param operation [Proc] Function to execute
28
+ # @return [Object] Result of operation
29
+ # @raise [RuntimeError] If circuit is open
30
+ def execute(&operation)
31
+ check_state
32
+
33
+ begin
34
+ result = operation.call
35
+ record_success
36
+ result
37
+ rescue => e
38
+ record_failure
39
+ raise e
40
+ end
41
+ end
42
+
43
+ # Check if circuit breaker is currently open
44
+ # @return [Boolean] True if open
45
+ def open?
46
+ @state == STATE_OPEN
47
+ end
48
+
49
+ private
50
+
51
+ # Check if circuit breaker should allow operation
52
+ # @raise [RuntimeError] If circuit is open
53
+ def check_state
54
+ if @state == STATE_OPEN
55
+ remaining_time = get_remaining_timeout
56
+ if remaining_time > 0
57
+ @logger.debug "Circuit breaker is OPEN - #{remaining_time} seconds remaining"
58
+ raise "Circuit breaker is open - #{remaining_time} seconds remaining"
59
+ end
60
+
61
+ # Reset timeout expired, move to half-open
62
+ @state = STATE_HALF_OPEN
63
+ @logger.info "Circuit breaker state changed to HALF-OPEN"
64
+ end
65
+ end
66
+
67
+ # Record a successful operation
68
+ def record_success
69
+ if @state == STATE_HALF_OPEN
70
+ @state = STATE_CLOSED
71
+ @failure_count = 0
72
+ @last_failure_time = 0
73
+ @logger.info "Circuit breaker reset - state changed to CLOSED"
74
+ end
75
+ end
76
+
77
+ # Record a failed operation
78
+ def record_failure
79
+ @failure_count += 1
80
+ @last_failure_time = Time.now.to_i
81
+
82
+ if @state == STATE_HALF_OPEN ||
83
+ (@state == STATE_CLOSED && @failure_count >= @failure_threshold)
84
+ @state = STATE_OPEN
85
+ @logger.warn "Circuit breaker opened after #{@failure_count} failures"
86
+ end
87
+ end
88
+
89
+ # Get remaining time before circuit breaker resets
90
+ # @return [Integer] Seconds remaining, 0 if timeout expired
91
+ def get_remaining_timeout
92
+ return 0 if @last_failure_time == 0
93
+
94
+ elapsed = Time.now.to_i - @last_failure_time
95
+ remaining = @reset_timeout - elapsed
96
+ [0, remaining].max
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,474 @@
1
+ require 'logger'
2
+ require 'json'
3
+ require 'fileutils'
4
+ require 'monitor'
5
+ require_relative 'circuit_breaker'
6
+ require_relative 'gets_unify_sdk'
7
+
8
+ module Complyance
9
+ # Manages persistent queue for document submissions
10
+ class PersistentQueueManager
11
+ QUEUE_DIR = 'complyance-queue'
12
+ PENDING_DIR = 'pending'
13
+ PROCESSING_DIR = 'processing'
14
+ FAILED_DIR = 'failed'
15
+ SUCCESS_DIR = 'success'
16
+
17
+ attr_reader :is_running
18
+
19
+ # Initialize queue manager
20
+ # @param api_key [String] API key for authentication
21
+ # @param local [Boolean] Whether to use local environment
22
+ # @param circuit_breaker [CircuitBreaker, nil] Optional shared circuit breaker
23
+ def initialize(api_key, local, circuit_breaker = nil)
24
+ @api_key = api_key
25
+ @local = local
26
+ @queue_base_path = queue_base_path
27
+ @logger = Logger.new(STDOUT)
28
+ @logger.level = Logger::INFO
29
+ @processing_lock = Monitor.new
30
+ @is_running = false
31
+
32
+ # Initialize circuit breaker with 3 failure threshold and 1 minute timeout
33
+ @circuit_breaker = circuit_breaker || CircuitBreaker.new(
34
+ failure_threshold: 3,
35
+ reset_timeout: 60
36
+ )
37
+
38
+ initialize_queue_directories
39
+ @logger.info "PersistentQueueManager initialized with queue directory: #{@queue_base_path}"
40
+
41
+ # Automatically start processing and retry any existing failed submissions
42
+ start_processing
43
+ retry_failed_submissions
44
+ end
45
+
46
+ # Get base path for queue directories
47
+ # @return [String] Base path
48
+ def queue_base_path
49
+ File.join(ENV['HOME'] || ENV['USERPROFILE'], QUEUE_DIR)
50
+ end
51
+
52
+ # Initialize queue directory structure
53
+ def initialize_queue_directories
54
+ [PENDING_DIR, PROCESSING_DIR, FAILED_DIR, SUCCESS_DIR].each do |dir|
55
+ path = File.join(@queue_base_path, dir)
56
+ FileUtils.mkdir_p(path) unless File.directory?(path)
57
+ end
58
+ @logger.debug "Queue directories initialized"
59
+ end
60
+
61
+ # Enqueue a submission for processing
62
+ # @param submission [PayloadSubmission] Submission to enqueue
63
+ def enqueue(submission)
64
+ begin
65
+ file_name = generate_file_name(submission)
66
+ file_path = File.join(@queue_base_path, PENDING_DIR, file_name)
67
+
68
+ # Check if file already exists (same document ID)
69
+ if File.exist?(file_path)
70
+ @logger.info "Document already exists in queue: #{file_name}. Skipping duplicate submission."
71
+ return
72
+ end
73
+
74
+ # Parse the UnifyRequest JSON string
75
+ json_payload = submission.payload
76
+ @logger.debug "Queue: Received payload with length: #{json_payload.bytesize} characters"
77
+
78
+ # Verify the payload is not empty
79
+ if json_payload.strip.empty? || json_payload == '{}'
80
+ @logger.error "🔥 QUEUE: ERROR - Received empty or invalid payload: '#{json_payload}'"
81
+ raise "Cannot enqueue empty payload"
82
+ end
83
+
84
+ # Parse the UnifyRequest JSON string to a proper JSON object
85
+ unify_request_map = JSON.parse(json_payload)
86
+
87
+ # Create submission record
88
+ record = {
89
+ payload: unify_request_map,
90
+ source_id: "#{submission.source.name}:#{submission.source.version}",
91
+ country: submission.country.to_s,
92
+ document_type: submission.document_type.to_s,
93
+ enqueued_at: Time.now.iso8601,
94
+ timestamp: (Time.now.to_f * 1000).to_i # Convert to milliseconds for consistency
95
+ }
96
+
97
+ # Write to file
98
+ File.write(file_path, JSON.pretty_generate(record))
99
+
100
+ @logger.info "Enqueued submission: #{file_name} for source: #{record[:source_id]}, country: #{record[:country]}"
101
+
102
+ # Start processing if not already running
103
+ start_processing
104
+
105
+ rescue => e
106
+ @logger.error "Failed to enqueue submission to persistent storage: #{e.message}"
107
+ raise "Failed to persist submission: #{e.message}"
108
+ end
109
+ end
110
+
111
+ # Generate unique filename for submission
112
+ # @param submission [PayloadSubmission] Submission to generate filename for
113
+ # @return [String] Generated filename
114
+ def generate_file_name(submission)
115
+ # Extract document ID from payload
116
+ document_id = extract_document_id(submission.payload)
117
+
118
+ # Generate filename using source and document ID for unique reference
119
+ source_id = "#{submission.source.name}:#{submission.source.version}".gsub(/[^\w-]/, '_')
120
+ country = submission.country.to_s
121
+
122
+ format("%s_%s_%s_%s.json",
123
+ source_id,
124
+ document_id,
125
+ country,
126
+ submission.document_type.to_s
127
+ )
128
+ end
129
+
130
+ # Extract document ID from payload JSON
131
+ # @param payload [String] JSON payload
132
+ # @return [String] Extracted document ID
133
+ def extract_document_id(payload)
134
+ data = JSON.parse(payload)
135
+ if data.dig('payload', 'invoice_data', 'invoice_number')
136
+ data['payload']['invoice_data']['invoice_number']
137
+ else
138
+ "doc_#{Time.now.to_i}"
139
+ end
140
+ rescue => e
141
+ @logger.warn "Failed to extract document ID from payload: #{e.message}"
142
+ "doc_#{Time.now.to_i}"
143
+ end
144
+
145
+ # Start processing queue items
146
+ def start_processing
147
+ unless @is_running
148
+ @is_running = true
149
+ @logger.info "Started persistent queue processing"
150
+
151
+ # Start background processing in a new thread
152
+ @processing_thread = Thread.new do
153
+ while @is_running
154
+ process_pending_submissions
155
+ sleep(0.5) # 500ms delay
156
+ end
157
+ end
158
+ end
159
+ end
160
+
161
+ # Process pending submissions immediately
162
+ def process_pending_submissions_now
163
+ @logger.info "Manually triggering processing of pending submissions"
164
+
165
+ # Check circuit breaker state before manual processing
166
+ if @circuit_breaker.open?
167
+ current_time = Time.now.to_i
168
+ time_since_last_failure = current_time - @circuit_breaker.last_failure_time
169
+
170
+ if time_since_last_failure < 60 # 1 minute
171
+ remaining_time = 60 - time_since_last_failure
172
+ @logger.info "🚫 Circuit breaker is OPEN - remaining time: #{remaining_time}s. Manual processing skipped."
173
+ return
174
+ else
175
+ @logger.info "✅ Circuit breaker timeout expired (#{time_since_last_failure}s) - proceeding with manual processing"
176
+ end
177
+ else
178
+ @logger.info "✅ Circuit breaker is CLOSED - proceeding with manual processing"
179
+ end
180
+
181
+ process_pending_submissions
182
+ end
183
+
184
+ # Stop processing queue items
185
+ def stop_processing
186
+ @is_running = false
187
+ @processing_thread&.join
188
+ @logger.info "Stopped persistent queue processing"
189
+ end
190
+
191
+ # Process pending submissions in queue
192
+ def process_pending_submissions
193
+ return unless @is_running
194
+
195
+ @processing_lock.synchronize do
196
+ begin
197
+ pending_dir = File.join(@queue_base_path, PENDING_DIR)
198
+ files = Dir[File.join(pending_dir, '*.json')]
199
+
200
+ if files.empty?
201
+ @logger.debug "No pending submissions to process"
202
+ return
203
+ end
204
+
205
+ @logger.debug "Found #{files.size} pending submissions in queue"
206
+
207
+ # Check circuit breaker state before attempting to process
208
+ if @circuit_breaker.open?
209
+ current_time = Time.now.to_i
210
+ time_since_last_failure = current_time - @circuit_breaker.last_failure_time
211
+
212
+ if time_since_last_failure < 60 # 1 minute
213
+ remaining_time = 60 - time_since_last_failure
214
+ @logger.debug "Circuit breaker is OPEN - #{remaining_time} seconds remaining. Queue has #{files.size} items waiting."
215
+ return
216
+ else
217
+ @logger.debug "Circuit breaker timeout expired - attempting to process #{files.size} queued items"
218
+ end
219
+ end
220
+
221
+ files.each do |file_path|
222
+ if File.exist?(file_path)
223
+ begin
224
+ process_submission_file(file_path)
225
+ rescue => e
226
+ @logger.error "Failed to process queued submission #{file_path}: #{e.message}"
227
+ end
228
+ end
229
+ end
230
+
231
+ rescue => e
232
+ @logger.error "Error processing pending submissions: #{e.message}"
233
+ end
234
+ end
235
+ end
236
+
237
+ # Process a single submission file
238
+ # @param file_path [String] Path to submission file
239
+ def process_submission_file(file_path)
240
+ begin
241
+ # Read submission record
242
+ record = JSON.parse(File.read(file_path))
243
+ file_name = File.basename(file_path)
244
+ processing_path = File.join(@queue_base_path, PROCESSING_DIR, file_name)
245
+
246
+ # Move to processing directory
247
+ FileUtils.mv(file_path, processing_path)
248
+
249
+ @logger.debug "Processing submission: #{file_name} for source: #{record['source_id']}"
250
+
251
+ begin
252
+ # Convert stored payload back to UnifyRequest
253
+ unify_request = UnifyRequest.new(record['payload'])
254
+
255
+ # Use the SDK's pushToUnify method with circuit breaker
256
+ response = @circuit_breaker.execute do
257
+ GETSUnifySDK.push_to_unify(unify_request)
258
+ end
259
+
260
+ # Check for success
261
+ is_success = false
262
+ if response
263
+ is_success = response.success?
264
+
265
+ # Check submission status
266
+ if response.data&.submission
267
+ submission = response.data.submission
268
+ is_success ||= submission.accepted? ||
269
+ submission.status.to_s.downcase == 'accepted'
270
+ end
271
+
272
+ # Check document status
273
+ if response.data&.document
274
+ document = response.data.document
275
+ is_success ||= document.status.to_s.downcase == 'success'
276
+ end
277
+ end
278
+
279
+ if is_success
280
+ @logger.info "Queue: SUCCESS - Removing file from queue: #{file_name}"
281
+ File.delete(processing_path)
282
+ return
283
+ else
284
+ status = response ? response.status : 'null'
285
+ @logger.warn "Queue: NON-SUCCESS - Moving to failed directory. Status: '#{status}'"
286
+ raise "API returned non-success status: #{status}"
287
+ end
288
+
289
+ rescue => e
290
+ @logger.error "Failed to send queued submission via pushToUnify: #{e.message}"
291
+
292
+ failed_path = File.join(@queue_base_path, FAILED_DIR, file_name)
293
+
294
+ if File.exist?(failed_path)
295
+ File.delete(processing_path)
296
+ else
297
+ FileUtils.mv(processing_path, failed_path)
298
+ end
299
+
300
+ raise e
301
+ end
302
+
303
+ rescue => e
304
+ @logger.warn "Failed to process submission: #{File.basename(file_path)} - Error: #{e.message}"
305
+
306
+ file_name = File.basename(file_path)
307
+ processing_path = File.join(@queue_base_path, PROCESSING_DIR, file_name)
308
+ failed_path = File.join(@queue_base_path, FAILED_DIR, file_name)
309
+
310
+ # Move to failed directory
311
+ if File.exist?(processing_path)
312
+ if File.exist?(failed_path)
313
+ File.delete(processing_path)
314
+ else
315
+ FileUtils.mv(processing_path, failed_path)
316
+ end
317
+ elsif File.exist?(file_path)
318
+ if File.exist?(failed_path)
319
+ File.delete(file_path)
320
+ else
321
+ FileUtils.mv(file_path, failed_path)
322
+ end
323
+ end
324
+ end
325
+ end
326
+
327
+ # Get current queue status
328
+ # @return [Hash] Queue status information
329
+ def queue_status
330
+ begin
331
+ pending_count = Dir[File.join(@queue_base_path, PENDING_DIR, '*.json')].size
332
+ processing_count = Dir[File.join(@queue_base_path, PROCESSING_DIR, '*.json')].size
333
+ failed_count = Dir[File.join(@queue_base_path, FAILED_DIR, '*.json')].size
334
+ success_count = Dir[File.join(@queue_base_path, SUCCESS_DIR, '*.json')].size
335
+
336
+ {
337
+ pending_count: pending_count,
338
+ processing_count: processing_count,
339
+ failed_count: failed_count,
340
+ success_count: success_count,
341
+ is_running: @is_running
342
+ }
343
+
344
+ rescue => e
345
+ @logger.error "Failed to get queue status: #{e.message}"
346
+ {
347
+ pending_count: 0,
348
+ processing_count: 0,
349
+ failed_count: 0,
350
+ success_count: 0,
351
+ is_running: false
352
+ }
353
+ end
354
+ end
355
+
356
+ # Retry failed submissions
357
+ def retry_failed_submissions
358
+ begin
359
+ failed_dir = File.join(@queue_base_path, FAILED_DIR)
360
+ files = Dir[File.join(failed_dir, '*.json')]
361
+
362
+ if files.empty?
363
+ @logger.info "No failed submissions to retry"
364
+ return
365
+ end
366
+
367
+ @logger.info "Retrying #{files.size} failed submissions"
368
+
369
+ files.each do |file_path|
370
+ file_name = File.basename(file_path)
371
+ pending_path = File.join(@queue_base_path, PENDING_DIR, file_name)
372
+ FileUtils.mv(file_path, pending_path)
373
+ @logger.debug "Moved failed submission back to pending: #{file_name}"
374
+ end
375
+
376
+ rescue => e
377
+ @logger.error "Failed to retry failed submissions: #{e.message}"
378
+ end
379
+ end
380
+
381
+ # Clean up old success files
382
+ # @param days_to_keep [Integer] Number of days to keep success files
383
+ def cleanup_old_success_files(days_to_keep)
384
+ begin
385
+ success_dir = File.join(@queue_base_path, SUCCESS_DIR)
386
+ cutoff_time = Time.now - (days_to_keep * 24 * 60 * 60)
387
+
388
+ files = Dir[File.join(success_dir, '*.json')].select do |file|
389
+ File.mtime(file) < cutoff_time
390
+ end
391
+
392
+ files.each do |file|
393
+ File.delete(file)
394
+ @logger.debug "Cleaned up old success file: #{File.basename(file)}"
395
+ end
396
+
397
+ @logger.info "Cleaned up #{files.size} old success files" unless files.empty?
398
+
399
+ rescue => e
400
+ @logger.error "Failed to cleanup old success files: #{e.message}"
401
+ end
402
+ end
403
+
404
+ # Clear all files from the queue (emergency cleanup)
405
+ def clear_all_queues
406
+ begin
407
+ @logger.info "Clearing all queue directories..."
408
+
409
+ clear_directory(PENDING_DIR)
410
+ clear_directory(PROCESSING_DIR)
411
+ clear_directory(FAILED_DIR)
412
+ clear_directory(SUCCESS_DIR)
413
+
414
+ @logger.info "All queue directories cleared successfully"
415
+
416
+ rescue => e
417
+ @logger.error "Error clearing queue directories: #{e.message}"
418
+ raise "Failed to clear queues: #{e.message}"
419
+ end
420
+ end
421
+
422
+ # Clear all files from a specific queue directory
423
+ # @param dir_name [String] Directory name to clear
424
+ private def clear_directory(dir_name)
425
+ dir = File.join(@queue_base_path, dir_name)
426
+ if File.directory?(dir)
427
+ files = Dir[File.join(dir, '*.json')]
428
+ files.each do |file|
429
+ File.delete(file)
430
+ @logger.debug "Deleted file: #{File.basename(file)}"
431
+ end
432
+ @logger.info "Cleared #{files.size} files from #{dir_name}"
433
+ end
434
+ end
435
+
436
+ # Clean up duplicate files across queue directories
437
+ def cleanup_duplicate_files
438
+ begin
439
+ @logger.info "Cleaning up duplicate files across queue directories..."
440
+
441
+ file_map = {}
442
+ [PENDING_DIR, PROCESSING_DIR, FAILED_DIR, SUCCESS_DIR].each do |dir_name|
443
+ dir = File.join(@queue_base_path, dir_name)
444
+ if File.directory?(dir)
445
+ Dir[File.join(dir, '*.json')].each do |file|
446
+ file_name = File.basename(file)
447
+ if file_map[file_name]
448
+ # File exists in multiple directories, keep the one with latest modification time
449
+ existing_time = File.mtime(file_map[file_name])
450
+ current_time = File.mtime(file)
451
+
452
+ if current_time > existing_time
453
+ File.delete(file_map[file_name])
454
+ file_map[file_name] = file
455
+ @logger.debug "Removed duplicate file (older): #{file_map[file_name]}"
456
+ else
457
+ File.delete(file)
458
+ @logger.debug "Removed duplicate file (older): #{file}"
459
+ end
460
+ else
461
+ file_map[file_name] = file
462
+ end
463
+ end
464
+ end
465
+ end
466
+
467
+ @logger.info "Duplicate file cleanup completed"
468
+
469
+ rescue => e
470
+ @logger.error "Error during duplicate file cleanup: #{e.message}"
471
+ end
472
+ end
473
+ end
474
+ end