Skip to content

Commit e4fe3a4

Browse files
authored
fix: Allow for ValidationErrors to lead to failed events (#73)
* rescue all valdidatoin exceptions, prevent too-large jobs from being enqueued * bump patch version
1 parent ff11a9a commit e4fe3a4

8 files changed

Lines changed: 83 additions & 21 deletions

File tree

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
journaled (6.2.7)
4+
journaled (6.2.8)
55
activejob
66
activerecord
77
activesupport

gemfiles/rails_7_2.gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: ..
33
specs:
4-
journaled (6.2.7)
4+
journaled (6.2.8)
55
activejob
66
activerecord
77
activesupport

gemfiles/rails_8_0.gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: ..
33
specs:
4-
journaled (6.2.7)
4+
journaled (6.2.8)
55
activejob
66
activerecord
77
activesupport

lib/journaled/kinesis_batch_sender.rb

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ class KinesisBatchSender
1515
'ValidationException',
1616
].freeze
1717

18-
BATCH_TOO_LARGE_PATTERN = /too large/i
18+
# Kinesis rejects any record whose data blob exceeds 1 MB.
19+
KINESIS_MAX_RECORD_BYTES = 1_048_576
1920

2021
# Send a batch of database events to Kinesis
2122
#
@@ -43,9 +44,7 @@ def send_stream_batch(stream_name, stream_events)
4344
response = kinesis_client.put_records(stream_name:, records:)
4445
process_response(response, stream_events)
4546
rescue Aws::Kinesis::Errors::ValidationException => e
46-
raise unless e.message.match?(BATCH_TOO_LARGE_PATTERN)
47-
48-
handle_batch_too_large(stream_name, stream_events)
47+
handle_validation_error(stream_name, stream_events, e.message)
4948
rescue StandardError => e
5049
# Handle transient errors (throttling, network issues, service unavailable)
5150
handle_transient_batch_error(e, stream_events)
@@ -94,24 +93,28 @@ def create_failed_event(event, error_code:, error_message:, transient:)
9493
)
9594
end
9695

97-
def handle_batch_too_large(stream_name, stream_events)
96+
# Isolate the offending record(s) when Kinesis rejects the batch with a
97+
# ValidationException (aggregate payload too large, per-record size limit,
98+
# invalid partition key, etc.). Splits the batch in half and retries
99+
# recursively until a single offending event is marked as a permanent failure.
100+
def handle_validation_error(stream_name, stream_events, error_message)
98101
if stream_events.size <= 1
99-
# Single event exceeds payload limit — treat as permanent failure
100102
return {
101103
succeeded: [],
102104
failed: stream_events.map do |event|
103105
create_failed_event(
104106
event,
105107
error_code: 'ValidationException',
106-
error_message: 'Record exceeds Kinesis payload limit',
108+
error_message:,
107109
transient: false,
108110
)
109111
end,
110112
}
111113
end
112114

113115
Rails.logger.warn(
114-
"[journaled] Batch too large for Kinesis (#{stream_events.size} events), splitting in half and retrying",
116+
"[journaled] Kinesis ValidationException for batch of #{stream_events.size} events " \
117+
"(#{error_message}), splitting in half and retrying",
115118
)
116119

117120
mid = stream_events.size / 2

lib/journaled/outbox/adapter.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ module Outbox
1414
# 4. Start workers: bundle exec rake journaled_worker:work
1515
class Adapter < Journaled::DeliveryAdapter
1616
class TableNotFoundError < StandardError; end
17+
class RecordTooLargeError < StandardError; end
1718

1819
# Delivers events by inserting them into the database
1920
#
@@ -29,6 +30,15 @@ def self.deliver(events:, **)
2930
# Exclude the application-level id - the database will generate its own using uuid_generate_v7()
3031
event_data = event.journaled_attributes.except(:id)
3132

33+
# The DB-generated id adds bytes to the JSON payload at send time, so a
34+
# placeholder id keeps this estimate honest.
35+
payload_bytesize = event_data.merge(id: SecureRandom.uuid).to_json.bytesize
36+
if payload_bytesize > KinesisBatchSender::KINESIS_MAX_RECORD_BYTES
37+
raise RecordTooLargeError, "Journaled event '#{event.journaled_attributes[:event_type]}' " \
38+
"exceeds Kinesis #{KinesisBatchSender::KINESIS_MAX_RECORD_BYTES}-byte per-record limit " \
39+
"(#{payload_bytesize} bytes); refusing to enqueue."
40+
end
41+
3242
{
3343
event_type: event.journaled_attributes[:event_type],
3444
event_data:,

lib/journaled/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# frozen_string_literal: true
22

33
module Journaled
4-
VERSION = "6.2.7"
4+
VERSION = "6.2.8"
55
end

spec/lib/journaled/kinesis_batch_sender_spec.rb

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@
163163
it 'logs a warning about the split' do
164164
subject.send_batch(events)
165165

166-
expect(Rails.logger).to have_received(:warn).with(/Batch too large.*splitting in half/)
166+
expect(Rails.logger).to have_received(:warn).with(/Kinesis ValidationException.*splitting in half/)
167167
end
168168
end
169169

@@ -180,7 +180,7 @@
180180
.and_raise(Aws::Kinesis::Errors::ValidationException.new(nil, 'Request Payload is too large'))
181181
end
182182

183-
it 'marks the event as a permanent failure' do
183+
it 'marks the event as a permanent failure with the Kinesis error message' do
184184
result = subject.send_batch(events)
185185

186186
expect(result[:succeeded]).to be_empty
@@ -189,7 +189,7 @@
189189
failure = result[:failed].first
190190
expect(failure.event).to eq(event)
191191
expect(failure.error_code).to eq('ValidationException')
192-
expect(failure.error_message).to eq('Record exceeds Kinesis payload limit')
192+
expect(failure.error_message).to eq('Request Payload is too large')
193193
expect(failure.permanent?).to be true
194194
end
195195
end
@@ -202,17 +202,23 @@
202202
let(:event) { create_database_event }
203203
let(:events) { [event] }
204204

205-
context 'when entire batch fails with validation exception' do
205+
context 'when entire batch fails with a non-payload ValidationException' do
206206
before do
207207
allow(kinesis_client).to receive(:put_records)
208208
.and_raise(Aws::Kinesis::Errors::ValidationException.new(nil, 'Invalid stream name'))
209209
end
210210

211-
it 'raises the exception (configuration error, not event data error)' do
212-
expect { subject.send_batch(events) }.to raise_error(
213-
Aws::Kinesis::Errors::ValidationException,
214-
'Invalid stream name',
215-
)
211+
it 'isolates the offending event(s) as permanent failures with the original error message' do
212+
result = subject.send_batch(events)
213+
214+
expect(result[:succeeded]).to be_empty
215+
expect(result[:failed].length).to eq(1)
216+
217+
failure = result[:failed].first
218+
expect(failure.event).to eq(event)
219+
expect(failure.error_code).to eq('ValidationException')
220+
expect(failure.error_message).to eq('Invalid stream name')
221+
expect(failure.permanent?).to be true
216222
end
217223
end
218224

spec/lib/journaled/outbox/adapter_spec.rb

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,49 @@
136136
}.to change { Journaled::Outbox::Event.count }.by(2)
137137
end
138138
end
139+
140+
context 'when an event exceeds the Kinesis per-record size limit' do
141+
let(:huge_payload) { 'x' * (Journaled::KinesisBatchSender::KINESIS_MAX_RECORD_BYTES + 1) }
142+
let(:oversized_event) do
143+
instance_double(
144+
event_class,
145+
id: SecureRandom.uuid,
146+
journaled_attributes: {
147+
id: 'big_id',
148+
event_type: 'big_event',
149+
payload: huge_payload,
150+
created_at: Time.current,
151+
},
152+
journaled_partition_key: 'test_partition_key',
153+
journaled_stream_name: 'test_stream',
154+
)
155+
end
156+
157+
context 'with a single oversized event' do
158+
let(:events) { [oversized_event] }
159+
160+
it 'raises RecordTooLargeError and inserts nothing' do
161+
expect {
162+
described_class.deliver(events:, enqueue_opts:)
163+
}.to raise_error(
164+
Journaled::Outbox::Adapter::RecordTooLargeError,
165+
/big_event.*exceeds Kinesis.*per-record limit/,
166+
)
167+
expect(Journaled::Outbox::Event.count).to eq(0)
168+
end
169+
end
170+
171+
context 'mixed with a normally-sized event' do
172+
let(:events) { [event, oversized_event] }
173+
174+
it 'raises and inserts none of the events in the batch' do
175+
expect {
176+
described_class.deliver(events:, enqueue_opts:)
177+
}.to raise_error(Journaled::Outbox::Adapter::RecordTooLargeError)
178+
expect(Journaled::Outbox::Event.count).to eq(0)
179+
end
180+
end
181+
end
139182
end
140183

141184
context 'when tables do not exist' do

0 commit comments

Comments
 (0)