loams 0.1.0 → 0.1.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.
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "loams/errors"
5
+ require "loams/idempotency"
6
+ require "loams/consistency"
7
+
8
+ module Loams
9
+ # What a `write_batch` applied: the merged consistency token (a String, nil
10
+ # when no chunk answered one), the chunks and the rows.
11
+ BatchResult = Struct.new(:token, :chunks, :rows, keyword_init: true)
12
+
13
+ # A `write_batch` stopped at a failed chunk (R13): the chunks before it were
14
+ # applied, and `token` is their merged consistency token.
15
+ class BatchError < LoamsError
16
+ attr_reader :chunks_applied, :rows_applied, :token, :cause_error
17
+
18
+ def initialize(cause, chunks_applied:, rows_applied:, token:)
19
+ @chunks_applied = chunks_applied
20
+ @rows_applied = rows_applied
21
+ @token = token
22
+ @cause_error = cause
23
+ code = cause.respond_to?(:code) ? cause.code : :unknown
24
+ metadata = { "chunks_applied" => chunks_applied.to_s, "rows_applied" => rows_applied.to_s }
25
+ cause_reason = cause.respond_to?(:reason) && cause.reason
26
+ metadata["cause_reason"] = cause_reason.to_s if cause_reason
27
+ super(
28
+ "write_batch stopped at chunk #{chunks_applied + 1}: #{chunks_applied} chunks (#{rows_applied} operations) " \
29
+ "were applied before it; #{cause.message}",
30
+ # SDKP-23: registered under `aborted`; the failed chunk's error is the cause.
31
+ code: :aborted,
32
+ reason: :batch_partially_applied,
33
+ metadata: metadata,
34
+ http_status: cause.respond_to?(:http_status) ? cause.http_status : nil,
35
+ rpc: cause.respond_to?(:rpc) ? cause.rpc : nil,
36
+ )
37
+ end
38
+ end
39
+
40
+ # R13: a large write as a sequence of `WriteDocuments` chunks.
41
+ #
42
+ # One chunk is in flight at a time. A chunk closes at +chunk_rows+ ops or
43
+ # when the next op would take it past +chunk_bytes+ of JSON. Each chunk gets
44
+ # its own idempotency key, which the invoker reuses on that chunk's retries,
45
+ # so a retried chunk is applied once. The first chunk that fails stops the
46
+ # batch with a `BatchError`.
47
+ module Batch
48
+ DEFAULT_CHUNK_ROWS = 10_000
49
+ DEFAULT_CHUNK_BYTES = 4 * 1024 * 1024
50
+
51
+ # +ops+ is any Enumerable of `WriteOp` JSON Hashes; +write+ is called with
52
+ # each chunk's ops and its idempotency key and answers the response.
53
+ # Returns a `BatchResult`.
54
+ def self.run(ops, chunk_rows: DEFAULT_CHUNK_ROWS, chunk_bytes: DEFAULT_CHUNK_BYTES, &write)
55
+ raise ArgumentError, "chunk_rows must be positive" unless chunk_rows.positive?
56
+ raise ArgumentError, "chunk_bytes must be positive" unless chunk_bytes.positive?
57
+
58
+ token = nil
59
+ applied = 0
60
+ rows = 0
61
+ chunk = []
62
+ size = 0
63
+
64
+ flush = lambda do
65
+ return if chunk.empty?
66
+
67
+ begin
68
+ res = write.call(chunk, Idempotency.uuidv7)
69
+ rescue LoamsError => e
70
+ raise BatchError.new(e, chunks_applied: applied, rows_applied: rows, token: token&.to_s)
71
+ end
72
+ text = res.respond_to?(:token) ? res.token : res["token"]
73
+ if text && !text.empty? && ConsistencyToken.valid?(text)
74
+ parsed = ConsistencyToken.parse(text)
75
+ token = token ? token.merge(parsed) : parsed.normalized
76
+ end
77
+ applied += 1
78
+ rows += chunk.size
79
+ chunk = []
80
+ size = 0
81
+ end
82
+
83
+ ops.each do |op|
84
+ bytes = JSON.generate(op).bytesize
85
+ flush.call if !chunk.empty? && (chunk.size >= chunk_rows || size + bytes > chunk_bytes)
86
+ chunk << op
87
+ size += bytes
88
+ end
89
+ flush.call
90
+ BatchResult.new(token: token&.to_s, chunks: applied, rows: rows)
91
+ end
92
+ end
93
+ end