loams 0.1.0 → 0.1.2
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.
- checksums.yaml +4 -4
- data/README.md +80 -2
- data/gen/loams-descriptor.binpb +0 -0
- data/lib/loams/app.rb +726 -0
- data/lib/loams/batch.rb +93 -0
- data/lib/loams/builders.rb +622 -0
- data/lib/loams/client.rb +76 -2
- data/lib/loams/consistency.rb +201 -0
- data/lib/loams/descriptors.rb +6 -0
- data/lib/loams/errors.rb +60 -0
- data/lib/loams/invoker.rb +41 -2
- data/lib/loams/subscribe.rb +75 -0
- data/lib/loams/transport.rb +5 -3
- data/lib/loams/values.rb +87 -0
- data/lib/loams/version.rb +1 -1
- data/lib/loams.rb +11 -0
- metadata +7 -1
data/lib/loams/client.rb
CHANGED
|
@@ -1,25 +1,71 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
3
4
|
require "loams/descriptors"
|
|
4
5
|
require "loams/transport"
|
|
5
6
|
require "loams/invoker"
|
|
6
7
|
require "loams/pagination"
|
|
8
|
+
require "loams/consistency"
|
|
9
|
+
require "loams/app"
|
|
7
10
|
|
|
8
11
|
module Loams
|
|
9
12
|
class Client
|
|
10
|
-
attr_reader :invoker, :endpoint
|
|
13
|
+
attr_reader :invoker, :endpoint, :namespace, :session
|
|
11
14
|
|
|
15
|
+
# The app modules (SDKP §2), hand-written over the descriptor set's
|
|
16
|
+
# bindings. They take the place of the descriptor-derived module of the
|
|
17
|
+
# same name, whose raw calls stay reachable through `client.module(name)`.
|
|
18
|
+
APP_MODULES = {
|
|
19
|
+
"namespaces" => App::Namespaces,
|
|
20
|
+
"collections" => App::Collections,
|
|
21
|
+
"documents" => App::Documents,
|
|
22
|
+
"search" => App::SearchModule,
|
|
23
|
+
"vector" => App::VectorModule,
|
|
24
|
+
"graph" => App::Graph,
|
|
25
|
+
"sql" => App::Sql,
|
|
26
|
+
"streams" => App::Streams,
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
# A client from the environment (R1): `LOAMS_ENDPOINT`, then
|
|
30
|
+
# `LOAMS_API_KEY` or `LOAMS_TOKEN`, then `LOAMS_NAMESPACE`. Keyword
|
|
31
|
+
# arguments win over the environment.
|
|
32
|
+
def self.from_env(env = ENV, **options)
|
|
33
|
+
endpoint = env["LOAMS_ENDPOINT"]
|
|
34
|
+
if endpoint.nil? || endpoint.empty?
|
|
35
|
+
raise InvalidArgumentError.new("LOAMS_ENDPOINT is not set", code: :invalid_argument)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
key = env["LOAMS_API_KEY"]
|
|
39
|
+
token = env["LOAMS_TOKEN"]
|
|
40
|
+
defaults = {}
|
|
41
|
+
defaults[:api_key] = key unless key.nil? || key.empty?
|
|
42
|
+
defaults[:token] = token if defaults[:api_key].nil? && !(token.nil? || token.empty?)
|
|
43
|
+
ns = env["LOAMS_NAMESPACE"]
|
|
44
|
+
defaults[:namespace] = ns unless ns.nil? || ns.empty?
|
|
45
|
+
new(endpoint, **defaults, **options)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# +api_key+ is sent as `Authorization: Bearer` (R1), as are +token+ and
|
|
49
|
+
# +token_source+. +namespace+ is the default namespace (R11). +timeout+
|
|
50
|
+
# is the unary default in seconds, nil for none (R12).
|
|
51
|
+
# +session_consistency+ turns on the session store (R4).
|
|
12
52
|
def initialize(
|
|
13
53
|
endpoint = "http://127.0.0.1:4400",
|
|
14
54
|
protocol: :connect,
|
|
15
55
|
codec: :proto,
|
|
56
|
+
api_key: nil,
|
|
16
57
|
token: nil,
|
|
17
58
|
token_source: nil,
|
|
59
|
+
namespace: nil,
|
|
60
|
+
timeout: CallInvoker::DEFAULT_TIMEOUT,
|
|
61
|
+
session_consistency: false,
|
|
18
62
|
transport: nil,
|
|
19
63
|
max_retries: 3
|
|
20
64
|
)
|
|
21
65
|
@endpoint = endpoint
|
|
22
|
-
|
|
66
|
+
@namespace = namespace&.to_s
|
|
67
|
+
@session = session_consistency ? SessionStore.new : nil
|
|
68
|
+
tokens = token_source || api_key || token
|
|
23
69
|
retries = RetryPolicy.new(max_retries: max_retries)
|
|
24
70
|
tp = transport || HttpTransport.new(endpoint)
|
|
25
71
|
|
|
@@ -29,6 +75,7 @@ module Loams
|
|
|
29
75
|
codec: codec,
|
|
30
76
|
token_source: tokens,
|
|
31
77
|
retry_policy: retries,
|
|
78
|
+
timeout: timeout,
|
|
32
79
|
)
|
|
33
80
|
|
|
34
81
|
# Build module clients
|
|
@@ -40,12 +87,39 @@ module Loams
|
|
|
40
87
|
method_name = Descriptors.snake(mod.name)
|
|
41
88
|
define_singleton_method(method_name) { client }
|
|
42
89
|
end
|
|
90
|
+
|
|
91
|
+
APP_MODULES.each do |name, klass|
|
|
92
|
+
app = klass.new(self)
|
|
93
|
+
define_singleton_method(name) { app }
|
|
94
|
+
end
|
|
43
95
|
end
|
|
44
96
|
|
|
45
97
|
def module(name)
|
|
46
98
|
@modules[name.to_s]
|
|
47
99
|
end
|
|
48
100
|
|
|
101
|
+
# Any facade call by its facade-index name (`documents.write`,
|
|
102
|
+
# `instance.get_instance`), over its request's proto3 JSON or message.
|
|
103
|
+
# `headers:`, `timeout:` and `idempotency_key:` are the call's options.
|
|
104
|
+
def call(name, request = {}, **opts)
|
|
105
|
+
mod, call = name.split(".", 2)
|
|
106
|
+
if App::CALLS.key?([mod, call])
|
|
107
|
+
(@raw ||= App::Raw.new(self)).call(name, request, **opts)
|
|
108
|
+
else
|
|
109
|
+
client = @modules[mod] or raise ArgumentError, "no module #{mod}"
|
|
110
|
+
binding = client.module_binding.calls.find { |b| Descriptors.snake(b.facade_name) == call }
|
|
111
|
+
raise ArgumentError, "no call #{name}" if binding.nil?
|
|
112
|
+
|
|
113
|
+
message = request.is_a?(Hash) ? binding.request.msgclass.decode_json(JSON.generate(request)) : request
|
|
114
|
+
client.public_send(call, message, opts)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# A handle on one collection (SDKP §2.8).
|
|
119
|
+
def collection(name, namespace: nil)
|
|
120
|
+
CollectionHandle.new(self, name, namespace: namespace)
|
|
121
|
+
end
|
|
122
|
+
|
|
49
123
|
class ModuleClient
|
|
50
124
|
attr_reader :module_binding, :invoker
|
|
51
125
|
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "loams/errors"
|
|
4
|
+
|
|
5
|
+
module Loams
|
|
6
|
+
# A consistency token (runtime contract R4): `v1:s<stream>/p<partition>@<offset>,…`,
|
|
7
|
+
# the encoding `crates/loams-collection/src/token.rs` defines.
|
|
8
|
+
#
|
|
9
|
+
# A write answers with one and a read accepts one as `at_least`, so a caller
|
|
10
|
+
# that just wrote can read its own write. Two tokens merge by keeping the
|
|
11
|
+
# largest offset per `(stream, partition)`, which is what lets a session fold
|
|
12
|
+
# every answer it sees into one token.
|
|
13
|
+
class ConsistencyToken
|
|
14
|
+
PREFIX = "v1:"
|
|
15
|
+
U32_MAX = 0xFFFF_FFFF
|
|
16
|
+
U64_MAX = 0xFFFF_FFFF_FFFF_FFFF
|
|
17
|
+
|
|
18
|
+
# The `(stream, partition, offset)` triples, in the order the token
|
|
19
|
+
# spells them. `merge` (and `normalized`) sort them, one per
|
|
20
|
+
# `(stream, partition)`.
|
|
21
|
+
attr_reader :items
|
|
22
|
+
|
|
23
|
+
# Parses +text+, raising `InvalidArgumentError` (client-side, no network
|
|
24
|
+
# call) when it is not a `v1:` token.
|
|
25
|
+
def self.parse(text)
|
|
26
|
+
raise invalid(text) unless text.is_a?(String) && text.start_with?(PREFIX)
|
|
27
|
+
|
|
28
|
+
body = text[PREFIX.length..]
|
|
29
|
+
return new([]) if body.empty?
|
|
30
|
+
|
|
31
|
+
items = body.split(",", -1).map do |item|
|
|
32
|
+
parse_item(item) || raise(invalid(text))
|
|
33
|
+
end
|
|
34
|
+
new(items)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Whether +text+ parses.
|
|
38
|
+
def self.valid?(text)
|
|
39
|
+
parse(text)
|
|
40
|
+
true
|
|
41
|
+
rescue LoamsError
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize(items = [])
|
|
46
|
+
@items = items.map { |i| i.dup.freeze }.freeze
|
|
47
|
+
freeze
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The same token sorted by stream then partition, one item per pair (the
|
|
51
|
+
# largest offset).
|
|
52
|
+
def normalized
|
|
53
|
+
self.class.new(normalize(@items))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The token's text, `v1:` followed by the triples.
|
|
57
|
+
def to_s
|
|
58
|
+
PREFIX + @items.map { |s, p, o| "s#{s}/p#{p}@#{o}" }.join(",")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A new token holding the larger offset of the two for every
|
|
62
|
+
# `(stream, partition)`.
|
|
63
|
+
def merge(other)
|
|
64
|
+
other = self.class.parse(other) if other.is_a?(String)
|
|
65
|
+
self.class.new(normalize(@items + other.items))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The offset this token holds for one partition, or nil.
|
|
69
|
+
def offset(stream, partition)
|
|
70
|
+
@items.select { |s, p, _| s == stream && p == partition }.map(&:last).max
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def empty?
|
|
74
|
+
@items.empty?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def ==(other)
|
|
78
|
+
other.is_a?(ConsistencyToken) && other.items == @items
|
|
79
|
+
end
|
|
80
|
+
alias eql? ==
|
|
81
|
+
|
|
82
|
+
def hash
|
|
83
|
+
@items.hash
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def inspect
|
|
87
|
+
"#<Loams::ConsistencyToken #{self}>"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.parse_item(item)
|
|
91
|
+
return nil unless item.start_with?("s")
|
|
92
|
+
|
|
93
|
+
stream, rest = item[1..].split("/p", 2)
|
|
94
|
+
return nil if rest.nil?
|
|
95
|
+
|
|
96
|
+
partition, offset = rest.split("@", 2)
|
|
97
|
+
return nil if offset.nil?
|
|
98
|
+
|
|
99
|
+
s = canonical(stream, U64_MAX)
|
|
100
|
+
p = canonical(partition, U32_MAX)
|
|
101
|
+
o = canonical(offset, U64_MAX)
|
|
102
|
+
return nil if s.nil? || p.nil? || o.nil?
|
|
103
|
+
|
|
104
|
+
[s, p, o]
|
|
105
|
+
end
|
|
106
|
+
private_class_method :parse_item
|
|
107
|
+
|
|
108
|
+
# A decimal with no sign, no leading zero (except `0`) and in range.
|
|
109
|
+
def self.canonical(text, max)
|
|
110
|
+
return nil unless text.match?(/\A(0|[1-9][0-9]*)\z/)
|
|
111
|
+
|
|
112
|
+
n = Integer(text, 10)
|
|
113
|
+
n <= max ? n : nil
|
|
114
|
+
end
|
|
115
|
+
private_class_method :canonical
|
|
116
|
+
|
|
117
|
+
def self.invalid(text)
|
|
118
|
+
InvalidArgumentError.new("invalid consistency token #{text.inspect}", code: :invalid_argument, reason: :invalid_consistency_token)
|
|
119
|
+
end
|
|
120
|
+
private_class_method :invalid
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def normalize(items)
|
|
125
|
+
items.sort.each_with_object([]) do |item, out|
|
|
126
|
+
if out.last && out.last[0] == item[0] && out.last[1] == item[1]
|
|
127
|
+
out[-1] = item
|
|
128
|
+
else
|
|
129
|
+
out << item
|
|
130
|
+
end
|
|
131
|
+
end.freeze
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The session store (R4, `session_consistency: true`): every token an
|
|
136
|
+
# answer carries is merged in, and every read that leaves `consistency`
|
|
137
|
+
# unset is sent with `at_least` set to the session's token.
|
|
138
|
+
#
|
|
139
|
+
# One token per proto package: a graph's tokens name the graph's own
|
|
140
|
+
# stream and a graph read refuses a token of another stream, so the
|
|
141
|
+
# collection and graph sessions are kept apart.
|
|
142
|
+
class SessionStore
|
|
143
|
+
# The response fields a token is read from.
|
|
144
|
+
TOKEN_FIELDS = %w[token read_token consistency_token].freeze
|
|
145
|
+
|
|
146
|
+
def initialize
|
|
147
|
+
@tokens = {}
|
|
148
|
+
@lock = Mutex.new
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The session token for +scope+ (a proto package), or nil.
|
|
152
|
+
def token(scope = "loams.collection.v1")
|
|
153
|
+
@lock.synchronize { @tokens[scope] }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Merges +text+ into the session for +scope+. A token that does not
|
|
157
|
+
# parse is ignored: the session never holds a token it cannot merge.
|
|
158
|
+
def observe(scope, text)
|
|
159
|
+
return if text.nil? || text.empty?
|
|
160
|
+
return unless ConsistencyToken.valid?(text)
|
|
161
|
+
|
|
162
|
+
parsed = ConsistencyToken.parse(text)
|
|
163
|
+
@lock.synchronize do
|
|
164
|
+
current = @tokens[scope]
|
|
165
|
+
@tokens[scope] = current ? current.merge(parsed) : parsed
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Reads every token field off +message+ and merges it in.
|
|
170
|
+
def observe_message(scope, message)
|
|
171
|
+
return if message.nil? || !message.respond_to?(:class) || !message.class.respond_to?(:descriptor)
|
|
172
|
+
|
|
173
|
+
TOKEN_FIELDS.each do |name|
|
|
174
|
+
field = message.class.descriptor.lookup(name)
|
|
175
|
+
next if field.nil? || field.type != :string || field.label == :repeated
|
|
176
|
+
|
|
177
|
+
observe(scope, message[name])
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Sets `consistency.at_least` on +request+ when it has a `consistency`
|
|
182
|
+
# field the caller left unset and the session holds a token.
|
|
183
|
+
def apply(scope, request)
|
|
184
|
+
return request unless request.respond_to?(:class) && request.class.respond_to?(:descriptor)
|
|
185
|
+
|
|
186
|
+
field = request.class.descriptor.lookup("consistency")
|
|
187
|
+
return request if field.nil? || field.type != :message
|
|
188
|
+
return request unless request[field.name].nil?
|
|
189
|
+
|
|
190
|
+
current = token(scope)
|
|
191
|
+
return request if current.nil? || current.empty?
|
|
192
|
+
|
|
193
|
+
sub = field.subtype.msgclass.new
|
|
194
|
+
return request if sub.class.descriptor.lookup("at_least").nil?
|
|
195
|
+
|
|
196
|
+
sub["at_least"] = current.to_s
|
|
197
|
+
request[field.name] = sub
|
|
198
|
+
request
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
data/lib/loams/descriptors.rb
CHANGED
|
@@ -88,6 +88,12 @@ module Loams
|
|
|
88
88
|
collect_messages(file.message_type, file.package, "")
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
+
# Every message's class, built up front. `google-protobuf` builds a
|
|
92
|
+
# class on first `msgclass`, and iterating a repeated message field whose
|
|
93
|
+
# element class was never built raises `TypeError` (a `SearchResponse`'s
|
|
94
|
+
# `hits`), so no class is left to be built lazily.
|
|
95
|
+
MESSAGES.each_value { |descriptor| descriptor&.msgclass }
|
|
96
|
+
|
|
91
97
|
# Every service in the set, in the order the files declare them.
|
|
92
98
|
SERVICES = SET.file.flat_map do |file|
|
|
93
99
|
file.service.to_a.map do |raw_svc|
|
data/lib/loams/errors.rb
CHANGED
|
@@ -54,6 +54,7 @@ module Loams
|
|
|
54
54
|
:idempotency_key_reused,
|
|
55
55
|
:workspace_not_found,
|
|
56
56
|
:workspace_fenced,
|
|
57
|
+
:batch_partially_applied,
|
|
57
58
|
:workspace_read_only,
|
|
58
59
|
:workspace_name_taken,
|
|
59
60
|
:object_missing,
|
|
@@ -61,17 +62,46 @@ module Loams
|
|
|
61
62
|
:env_unsealed,
|
|
62
63
|
:env_access_denied,
|
|
63
64
|
:env_foreign_reference,
|
|
65
|
+
:memory_store_not_found,
|
|
66
|
+
:memory_space_not_found,
|
|
67
|
+
:memory_space_limit,
|
|
68
|
+
:memory_managed_object,
|
|
69
|
+
:memory_block_version_mismatch,
|
|
70
|
+
:memory_block_too_large,
|
|
71
|
+
:memory_core_budget_exceeded,
|
|
72
|
+
:memory_content_too_large,
|
|
73
|
+
:memory_secret_detected,
|
|
74
|
+
:memory_pii_refused,
|
|
75
|
+
:memory_model_not_configured,
|
|
76
|
+
:memory_model_not_local,
|
|
77
|
+
:memory_token_budget_exhausted,
|
|
78
|
+
:memory_extraction_failed,
|
|
79
|
+
:memory_too_many_spaces,
|
|
80
|
+
:memory_cross_space_write,
|
|
81
|
+
:memory_not_yet_available,
|
|
82
|
+
:code_repo_not_indexed,
|
|
83
|
+
:code_index_building,
|
|
84
|
+
:code_change_too_large,
|
|
85
|
+
:code_language_unsupported,
|
|
86
|
+
:code_scip_invalid,
|
|
64
87
|
:live_bad_cursor,
|
|
65
88
|
:live_worker_crashed,
|
|
66
89
|
:live_deploy_conflict,
|
|
67
90
|
:live_schema_violation,
|
|
91
|
+
:filter_conflict,
|
|
92
|
+
:search_after_needs_tiebreak,
|
|
93
|
+
:invalid_consistency_token,
|
|
94
|
+
:namespace_required,
|
|
95
|
+
:sql_param_count,
|
|
68
96
|
:live_index_not_ready,
|
|
69
97
|
:live_app_disabled,
|
|
70
98
|
:live_app_exists,
|
|
71
99
|
:live_function_error,
|
|
72
100
|
:live_function_timeout,
|
|
101
|
+
:client_deadline_exceeded,
|
|
73
102
|
:live_function_out_of_memory,
|
|
74
103
|
:live_limit_exceeded,
|
|
104
|
+
:result_too_large,
|
|
75
105
|
:live_app_mismatch,
|
|
76
106
|
:live_origin_not_allowed,
|
|
77
107
|
:session_rotate,
|
|
@@ -159,6 +189,7 @@ module Loams
|
|
|
159
189
|
idempotency_key_reused: :failed_precondition,
|
|
160
190
|
workspace_not_found: :not_found,
|
|
161
191
|
workspace_fenced: :aborted,
|
|
192
|
+
batch_partially_applied: :aborted,
|
|
162
193
|
workspace_read_only: :failed_precondition,
|
|
163
194
|
workspace_name_taken: :already_exists,
|
|
164
195
|
object_missing: :failed_precondition,
|
|
@@ -166,17 +197,46 @@ module Loams
|
|
|
166
197
|
env_unsealed: :invalid_argument,
|
|
167
198
|
env_access_denied: :permission_denied,
|
|
168
199
|
env_foreign_reference: :permission_denied,
|
|
200
|
+
memory_store_not_found: :not_found,
|
|
201
|
+
memory_space_not_found: :not_found,
|
|
202
|
+
memory_space_limit: :resource_exhausted,
|
|
203
|
+
memory_managed_object: :failed_precondition,
|
|
204
|
+
memory_block_version_mismatch: :aborted,
|
|
205
|
+
memory_block_too_large: :invalid_argument,
|
|
206
|
+
memory_core_budget_exceeded: :resource_exhausted,
|
|
207
|
+
memory_content_too_large: :invalid_argument,
|
|
208
|
+
memory_secret_detected: :failed_precondition,
|
|
209
|
+
memory_pii_refused: :failed_precondition,
|
|
210
|
+
memory_model_not_configured: :failed_precondition,
|
|
211
|
+
memory_model_not_local: :failed_precondition,
|
|
212
|
+
memory_token_budget_exhausted: :resource_exhausted,
|
|
213
|
+
memory_extraction_failed: :failed_precondition,
|
|
214
|
+
memory_too_many_spaces: :invalid_argument,
|
|
215
|
+
memory_cross_space_write: :invalid_argument,
|
|
216
|
+
memory_not_yet_available: :unimplemented,
|
|
217
|
+
code_repo_not_indexed: :failed_precondition,
|
|
218
|
+
code_index_building: :unavailable,
|
|
219
|
+
code_change_too_large: :invalid_argument,
|
|
220
|
+
code_language_unsupported: :failed_precondition,
|
|
221
|
+
code_scip_invalid: :invalid_argument,
|
|
169
222
|
live_bad_cursor: :invalid_argument,
|
|
170
223
|
live_worker_crashed: :internal,
|
|
171
224
|
live_deploy_conflict: :failed_precondition,
|
|
172
225
|
live_schema_violation: :invalid_argument,
|
|
226
|
+
filter_conflict: :invalid_argument,
|
|
227
|
+
search_after_needs_tiebreak: :invalid_argument,
|
|
228
|
+
invalid_consistency_token: :invalid_argument,
|
|
229
|
+
namespace_required: :invalid_argument,
|
|
230
|
+
sql_param_count: :invalid_argument,
|
|
173
231
|
live_index_not_ready: :failed_precondition,
|
|
174
232
|
live_app_disabled: :failed_precondition,
|
|
175
233
|
live_app_exists: :failed_precondition,
|
|
176
234
|
live_function_error: :unknown,
|
|
177
235
|
live_function_timeout: :deadline_exceeded,
|
|
236
|
+
client_deadline_exceeded: :deadline_exceeded,
|
|
178
237
|
live_function_out_of_memory: :resource_exhausted,
|
|
179
238
|
live_limit_exceeded: :resource_exhausted,
|
|
239
|
+
result_too_large: :resource_exhausted,
|
|
180
240
|
live_app_mismatch: :permission_denied,
|
|
181
241
|
live_origin_not_allowed: :permission_denied,
|
|
182
242
|
session_rotate: :unavailable,
|
data/lib/loams/invoker.rb
CHANGED
|
@@ -7,18 +7,25 @@ require "loams/idempotency"
|
|
|
7
7
|
require "loams/retry"
|
|
8
8
|
require "loams/token_source"
|
|
9
9
|
require "loams/streams"
|
|
10
|
+
require "net/http"
|
|
11
|
+
require "timeout"
|
|
10
12
|
|
|
11
13
|
module Loams
|
|
12
14
|
class CallInvoker
|
|
13
|
-
|
|
15
|
+
# The client default for a unary call, in seconds (R12). Streams have none.
|
|
16
|
+
DEFAULT_TIMEOUT = 30
|
|
17
|
+
|
|
18
|
+
attr_reader :transport, :protocol, :codec, :token_source, :retry_policy, :timeout
|
|
14
19
|
|
|
15
20
|
def initialize(
|
|
16
21
|
transport:,
|
|
17
22
|
protocol: :connect,
|
|
18
23
|
codec: :proto,
|
|
19
24
|
token_source: nil,
|
|
20
|
-
retry_policy: nil
|
|
25
|
+
retry_policy: nil,
|
|
26
|
+
timeout: DEFAULT_TIMEOUT
|
|
21
27
|
)
|
|
28
|
+
@timeout = timeout
|
|
22
29
|
@transport = transport
|
|
23
30
|
@protocol = protocol
|
|
24
31
|
@codec = codec
|
|
@@ -33,9 +40,15 @@ module Loams
|
|
|
33
40
|
|
|
34
41
|
attempt = 0
|
|
35
42
|
refreshed_token = false
|
|
43
|
+
# R12: one budget for the call, retries included. `timeout: nil` on the
|
|
44
|
+
# call or the client means no deadline.
|
|
45
|
+
budget = options.key?(:timeout) ? options[:timeout] : @timeout
|
|
46
|
+
deadline = budget && (monotonic + budget.to_f)
|
|
36
47
|
|
|
37
48
|
loop do
|
|
38
49
|
attempt += 1
|
|
50
|
+
remaining = deadline && (deadline - monotonic)
|
|
51
|
+
raise deadline_error(binding.rpc) if remaining && remaining <= 0
|
|
39
52
|
|
|
40
53
|
# Build headers
|
|
41
54
|
ct = ContentTypes.content_type_for(@protocol, @codec, streaming: false)
|
|
@@ -49,6 +62,15 @@ module Loams
|
|
|
49
62
|
headers["idempotency-key"] = key
|
|
50
63
|
end
|
|
51
64
|
|
|
65
|
+
if remaining
|
|
66
|
+
ms = [(remaining * 1000).ceil, 1].max
|
|
67
|
+
if @protocol == :grpc_web
|
|
68
|
+
headers["grpc-timeout"] = "#{ms}m"
|
|
69
|
+
else
|
|
70
|
+
headers["connect-timeout-ms"] = ms.to_s
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
52
74
|
if options[:headers]
|
|
53
75
|
options[:headers].each { |k, v| headers[k.to_s.downcase] = v.to_s }
|
|
54
76
|
end
|
|
@@ -65,10 +87,14 @@ module Loams
|
|
|
65
87
|
headers: headers,
|
|
66
88
|
body: body,
|
|
67
89
|
rpc: binding.rpc,
|
|
90
|
+
timeout: remaining,
|
|
68
91
|
)
|
|
69
92
|
|
|
70
93
|
res = begin
|
|
71
94
|
@transport.call_unary(req)
|
|
95
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Timeout::Error
|
|
96
|
+
# The budget is spent, so this is never retried.
|
|
97
|
+
raise deadline_error(binding.rpc)
|
|
72
98
|
rescue StandardError => e
|
|
73
99
|
raise ErrorParser.build_error(:unknown, e.message, nil, nil, binding.rpc)
|
|
74
100
|
end
|
|
@@ -192,6 +218,19 @@ module Loams
|
|
|
192
218
|
|
|
193
219
|
private
|
|
194
220
|
|
|
221
|
+
def monotonic
|
|
222
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def deadline_error(rpc)
|
|
226
|
+
DeadlineExceededError.new(
|
|
227
|
+
"the call ran out of time on the client",
|
|
228
|
+
code: :deadline_exceeded,
|
|
229
|
+
reason: :client_deadline_exceeded,
|
|
230
|
+
rpc: rpc,
|
|
231
|
+
)
|
|
232
|
+
end
|
|
233
|
+
|
|
195
234
|
def current_token(force_refresh: false)
|
|
196
235
|
@token_source&.token(force_refresh: force_refresh)
|
|
197
236
|
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Loams
|
|
4
|
+
# R14: `streams.subscribe`, a loop over unary `Fetch` (SDKP-2), so it works
|
|
5
|
+
# on Connect-unary and through any proxy.
|
|
6
|
+
#
|
|
7
|
+
# The next offset is the last record's offset + 1. An empty fetch backs off
|
|
8
|
+
# 100 ms, doubling up to 1 s, and a non-empty one resets the backoff.
|
|
9
|
+
# `offset` is where the next fetch starts, so a caller can resume from it;
|
|
10
|
+
# `cancel` stops the loop at the next fetch boundary.
|
|
11
|
+
#
|
|
12
|
+
# sub = client.streams.subscribe("events", 0, from_offset: 1)
|
|
13
|
+
# sub.each { |record| … ; sub.cancel if done }
|
|
14
|
+
class Subscription
|
|
15
|
+
include Enumerable
|
|
16
|
+
|
|
17
|
+
INITIAL_BACKOFF = 0.1
|
|
18
|
+
MAX_BACKOFF = 1.0
|
|
19
|
+
|
|
20
|
+
attr_reader :offset
|
|
21
|
+
|
|
22
|
+
# +fetch+ is called with an offset and answers the records at and after
|
|
23
|
+
# it, each responding to `offset` (or a Hash with `"offset"`).
|
|
24
|
+
def initialize(from_offset: 0, sleeper: nil, &fetch)
|
|
25
|
+
raise ArgumentError, "a subscription needs a fetch block" unless fetch
|
|
26
|
+
|
|
27
|
+
@offset = Integer(from_offset)
|
|
28
|
+
@fetch = fetch
|
|
29
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
30
|
+
@cancelled = false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def cancel
|
|
34
|
+
@cancelled = true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def cancelled?
|
|
38
|
+
@cancelled
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def each
|
|
42
|
+
return to_enum(:each) unless block_given?
|
|
43
|
+
|
|
44
|
+
backoff = INITIAL_BACKOFF
|
|
45
|
+
until @cancelled
|
|
46
|
+
records = Array(@fetch.call(@offset))
|
|
47
|
+
if records.empty?
|
|
48
|
+
@sleeper.call(backoff)
|
|
49
|
+
backoff = [backoff * 2, MAX_BACKOFF].min
|
|
50
|
+
next
|
|
51
|
+
end
|
|
52
|
+
backoff = INITIAL_BACKOFF
|
|
53
|
+
records.each do |record|
|
|
54
|
+
break if @cancelled
|
|
55
|
+
|
|
56
|
+
@offset = Integer(record_offset(record)) + 1
|
|
57
|
+
yield record
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def record_offset(record)
|
|
66
|
+
if record.respond_to?(:offset)
|
|
67
|
+
record.offset
|
|
68
|
+
elsif record.is_a?(Hash)
|
|
69
|
+
record["offset"] || record[:offset]
|
|
70
|
+
else
|
|
71
|
+
raise ArgumentError, "a fetched record has no offset"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/loams/transport.rb
CHANGED
|
@@ -4,7 +4,7 @@ require "net/http"
|
|
|
4
4
|
require "uri"
|
|
5
5
|
|
|
6
6
|
module Loams
|
|
7
|
-
Request = Struct.new(:method, :path, :headers, :body, :rpc, keyword_init: true)
|
|
7
|
+
Request = Struct.new(:method, :path, :headers, :body, :rpc, :timeout, keyword_init: true)
|
|
8
8
|
Response = Struct.new(:status, :headers, :body, :stream, keyword_init: true)
|
|
9
9
|
|
|
10
10
|
class HttpTransport
|
|
@@ -22,8 +22,10 @@ module Loams
|
|
|
22
22
|
url = URI.join(@endpoint + "/", request.path.sub(%r{^/+}, ""))
|
|
23
23
|
http = Net::HTTP.new(url.host, url.port)
|
|
24
24
|
http.use_ssl = (url.scheme == "https")
|
|
25
|
-
|
|
26
|
-
http.
|
|
25
|
+
# R12: the call's remaining budget, or no limit when it has none.
|
|
26
|
+
http.read_timeout = request.timeout
|
|
27
|
+
http.open_timeout = request.timeout ? [request.timeout, 5].min : 5
|
|
28
|
+
http.write_timeout = request.timeout if request.timeout
|
|
27
29
|
|
|
28
30
|
req = Net::HTTPGenericRequest.new(
|
|
29
31
|
request.method || "POST",
|