Skip to content

Commit fd95c00

Browse files
committed
Decouple validation from import
1 parent 64a1ae8 commit fd95c00

17 files changed

Lines changed: 709 additions & 348 deletions

Gemfile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ source "https://rubygems.org"
33
ruby "3.4.4"
44

55
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
6-
gem "rails", "~> 7.1.2"
6+
gem "rails", "8.0.2.1"
77

88
# Use postgresql as the database for Active Record
99

@@ -98,3 +98,7 @@ gem 'ostruct'
9898
gem "oj", "~> 3.16"
9999

100100
gem "retriable", "~> 3.1"
101+
102+
# Database and job processing
103+
gem "sqlite3", ">= 2.1"
104+
gem "solid_queue"

app/jobs/gap_detection_job.rb

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
class GapDetectionJob < ApplicationJob
2+
queue_as :gap_detection
3+
4+
def perform
5+
return unless validation_enabled?
6+
7+
Rails.logger.info "GapDetectionJob: Starting gap detection scan"
8+
9+
# Get current import range
10+
import_range = get_import_range
11+
return unless import_range
12+
13+
start_block, end_block = import_range
14+
15+
# Find validation gaps
16+
gaps = ValidationResult.validation_gaps(start_block, end_block)
17+
18+
if gaps.empty?
19+
Rails.logger.debug "GapDetectionJob: No validation gaps found in range #{start_block}..#{end_block}"
20+
return
21+
end
22+
23+
Rails.logger.info "GapDetectionJob: Found #{gaps.length} validation gaps: #{gaps.first(5).join(', ')}#{gaps.length > 5 ? '...' : ''}"
24+
25+
# Enqueue validation jobs for gaps
26+
gaps.each do |block_number|
27+
begin
28+
# Get L2 block data for this L1 block
29+
l2_blocks = get_l2_blocks_for_l1_block(block_number)
30+
31+
if l2_blocks.any?
32+
l2_block_hashes = l2_blocks.map { |block| block[:hash] }
33+
ValidationJob.perform_later(block_number, l2_block_hashes, nil)
34+
Rails.logger.debug "GapDetectionJob: Enqueued validation for block #{block_number}"
35+
else
36+
Rails.logger.warn "GapDetectionJob: No L2 blocks found for L1 block #{block_number}"
37+
end
38+
rescue => e
39+
Rails.logger.error "GapDetectionJob: Failed to enqueue validation for block #{block_number}: #{e.message}"
40+
end
41+
end
42+
43+
Rails.logger.info "GapDetectionJob: Enqueued #{gaps.length} validation jobs for gaps"
44+
end
45+
46+
private
47+
48+
def validation_enabled?
49+
ENV.fetch('VALIDATION_ENABLED', 'false').casecmp?('true')
50+
end
51+
52+
def get_import_range
53+
begin
54+
# Get the range of blocks we should have validation for
55+
# Use the current L2 blockchain state to determine what's been imported
56+
latest_l2_block = GethDriver.client.call("eth_getBlockByNumber", ["latest", false])
57+
return nil if latest_l2_block.nil?
58+
59+
latest_l2_block_number = latest_l2_block['number'].to_i(16)
60+
return nil if latest_l2_block_number == 0
61+
62+
# Get L1 attributes to find the corresponding L1 block
63+
l1_attributes = GethDriver.client.get_l1_attributes(latest_l2_block_number)
64+
current_l1_block = l1_attributes[:number]
65+
66+
# Check the last validated block
67+
last_validated = ValidationResult.last_validated_block || 0
68+
69+
# We should validate from the oldest reasonable point to current
70+
# Don't go back more than 1000 blocks to avoid overwhelming the system
71+
start_block = [last_validated - 100, current_l1_block - 1000].max
72+
73+
[start_block, current_l1_block]
74+
rescue => e
75+
Rails.logger.error "GapDetectionJob: Failed to determine import range: #{e.message}"
76+
nil
77+
end
78+
end
79+
80+
def get_l2_blocks_for_l1_block(l1_block_number)
81+
# Query Geth to find L2 blocks that were created from this L1 block
82+
# This is complex - we need to scan L2 blocks and check their L1 attributes
83+
begin
84+
l2_blocks = []
85+
86+
# Get current L2 tip to know the range to search
87+
latest_l2_block = GethDriver.client.call("eth_getBlockByNumber", ["latest", false])
88+
return [] if latest_l2_block.nil?
89+
90+
latest_l2_block_number = latest_l2_block['number'].to_i(16)
91+
92+
# Search backwards from current L2 tip to find blocks from this L1 block
93+
# This is expensive but necessary for gap filling
94+
(0..latest_l2_block_number).reverse_each do |l2_block_num|
95+
l1_attributes = GethDriver.client.get_l1_attributes(l2_block_num)
96+
97+
if l1_attributes[:number] == l1_block_number
98+
l2_block = GethDriver.client.call("eth_getBlockByNumber", ["0x#{l2_block_num.to_s(16)}", false])
99+
l2_blocks << { number: l2_block_num, hash: l2_block['hash'] }
100+
elsif l1_attributes[:number] < l1_block_number
101+
# We've gone too far back
102+
break
103+
end
104+
end
105+
106+
l2_blocks
107+
rescue => e
108+
Rails.logger.error "GapDetectionJob: Failed to get L2 blocks for L1 block #{l1_block_number}: #{e.message}"
109+
[]
110+
end
111+
end
112+
end

app/jobs/validation_job.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
class ValidationJob < ApplicationJob
2+
queue_as :validation
3+
4+
# Retry up to 3 times with fixed delays to avoid the exponentially_longer issue
5+
retry_on StandardError, wait: 5.seconds, attempts: 3
6+
7+
def perform(l1_block_number, l2_block_hashes)
8+
start_time = Time.current
9+
10+
begin
11+
# Use the unified ValidationResult model to validate and save
12+
validation_result = ValidationResult.validate_and_save(l1_block_number, l2_block_hashes)
13+
14+
elapsed_time = Time.current - start_time
15+
Rails.logger.info "ValidationJob: Block #{l1_block_number} validation completed in #{elapsed_time.round(3)}s"
16+
17+
rescue => e
18+
Rails.logger.error "ValidationJob: Validation failed for block #{l1_block_number}: #{e.message}"
19+
20+
# Validation failure will be detected by import process via database query
21+
22+
# Re-raise to trigger retry mechanism
23+
raise e
24+
end
25+
end
26+
end

app/models/application_record.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
class ApplicationRecord < ActiveRecord::Base
2+
primary_abstract_class
3+
end

app/models/validation_result.rb

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
class ValidationResult < ApplicationRecord
2+
self.primary_key = 'l1_block'
3+
4+
scope :successful, -> { where(success: true) }
5+
scope :failed, -> { where(success: false) }
6+
scope :recent, -> { order(validated_at: :desc) }
7+
scope :in_range, ->(start_block, end_block) { where(l1_block: start_block..end_block) }
8+
9+
# Class methods for validation management
10+
def self.last_validated_block
11+
maximum(:l1_block)
12+
end
13+
14+
def self.validation_gaps(start_block, end_block)
15+
# Use SQL recursive CTE to find gaps efficiently
16+
sql = <<~SQL
17+
WITH RECURSIVE expected(n) AS (
18+
SELECT ? AS n
19+
UNION ALL
20+
SELECT n + 1 FROM expected WHERE n < ?
21+
)
22+
SELECT n AS missing_block
23+
FROM expected
24+
LEFT JOIN validation_results vr ON vr.l1_block = expected.n
25+
WHERE vr.l1_block IS NULL
26+
ORDER BY n
27+
SQL
28+
29+
connection.execute(sql, [start_block, end_block]).map { |row| row['missing_block'] }
30+
end
31+
32+
def self.validation_stats(since: 1.hour.ago)
33+
results = where('validated_at >= ?', since)
34+
total = results.count
35+
passed = results.successful.count
36+
failed = results.failed.count
37+
38+
{
39+
total: total,
40+
passed: passed,
41+
failed: failed,
42+
pass_rate: total > 0 ? (passed.to_f / total * 100).round(2) : 0
43+
}
44+
end
45+
46+
def self.recent_failures(limit: 10)
47+
failed.recent.limit(limit)
48+
end
49+
50+
# Class method to perform validation and save result
51+
def self.validate_and_save(l1_block_number, l2_block_hashes)
52+
Rails.logger.info "ValidationResult: Validating L1 block #{l1_block_number}"
53+
54+
begin
55+
# Create validator and validate (validator fetches its own API data)
56+
validator = BlockValidator.new
57+
block_result = validator.validate_l1_block(l1_block_number, l2_block_hashes)
58+
59+
# Store validation result using create_or_find_by for concurrency safety
60+
validation_result = create_or_find_by(l1_block: l1_block_number) do |vr|
61+
vr.success = block_result.success
62+
vr.error_details = block_result.errors.to_json
63+
vr.validation_stats = block_result.stats.to_json
64+
vr.validated_at = Time.current
65+
end
66+
67+
# Log the result
68+
validation_result.log_summary
69+
70+
validation_result
71+
rescue => e
72+
Rails.logger.error "ValidationResult: Exception validating block #{l1_block_number}: #{e.message}"
73+
74+
# Record the validation error
75+
error_result = create_or_find_by(l1_block: l1_block_number) do |vr|
76+
vr.success = false
77+
vr.error_details = [e.message].to_json
78+
vr.validation_stats = { exception: true, exception_class: e.class.name }.to_json
79+
vr.validated_at = Time.current
80+
end
81+
82+
error_result.log_summary
83+
raise e
84+
end
85+
end
86+
87+
# Instance methods
88+
def parsed_errors
89+
return [] unless error_details.present?
90+
JSON.parse(error_details) rescue []
91+
end
92+
93+
def parsed_stats
94+
return {} unless validation_stats.present?
95+
JSON.parse(validation_stats) rescue {}
96+
end
97+
98+
def failure_summary
99+
return nil if success?
100+
101+
error_list = parsed_errors
102+
return "No error details" if error_list.empty?
103+
104+
# Return first few errors for summary
105+
error_list.first(3).join('; ')
106+
end
107+
108+
def log_summary(logger = Rails.logger)
109+
if success?
110+
stats_data = parsed_stats
111+
if stats_data['actual_creations'].to_i > 0 || stats_data['actual_transfers'].to_i > 0 || stats_data['storage_checks'].to_i > 0
112+
logger.info "✅ Block #{l1_block} validated successfully: " \
113+
"#{stats_data['actual_creations']} creations, " \
114+
"#{stats_data['actual_transfers']} transfers, " \
115+
"#{stats_data['storage_checks']} storage checks"
116+
end
117+
else
118+
logger.error "❌ Block #{l1_block} validation failed with #{parsed_errors.size} errors:"
119+
parsed_errors.first(5).each { |e| logger.error " - #{e}" }
120+
logger.error " ... and #{parsed_errors.size - 5} more errors" if parsed_errors.size > 5
121+
end
122+
end
123+
end

0 commit comments

Comments
 (0)