k9o 1.1.1-aarch64-linux → 1.5.0-aarch64-linux
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/CHANGELOG.md +118 -0
- data/README.md +1573 -176
- data/k9o.gemspec +2 -2
- data/lib/k9o/_ffi/kibo_kms_rest_ffi.rb +928 -25
- data/lib/k9o/_ffi/libkibo_kms_rest_ffi.so +0 -0
- data/lib/k9o/ai.rb +15 -0
- data/lib/k9o/analytics.rb +403 -0
- data/lib/k9o/approvals.rb +253 -0
- data/lib/k9o/audit.rb +162 -14
- data/lib/k9o/client.rb +28 -9
- data/lib/k9o/compute.rb +32 -15
- data/lib/k9o/crypto.rb +471 -0
- data/lib/k9o/custody.rb +28 -0
- data/lib/k9o/data.rb +125 -5
- data/lib/k9o/ed25519.rb +182 -0
- data/lib/k9o/end_users.rb +93 -0
- data/lib/k9o/envelope.rb +20 -5
- data/lib/k9o/functions.rb +127 -9
- data/lib/k9o/gate.rb +52 -22
- data/lib/k9o/http_transport.rb +78 -16
- data/lib/k9o/jcs.rb +117 -0
- data/lib/k9o/kerror.rb +57 -5
- data/lib/k9o/keys.rb +131 -0
- data/lib/k9o/kibo.rb +3 -3
- data/lib/k9o/kibo_client_core.rb +385 -0
- data/lib/k9o/kibo_ffi_core.rb +178 -0
- data/lib/k9o/locate.rb +118 -0
- data/lib/k9o/me.rb +27 -87
- data/lib/k9o/metadata.rb +342 -0
- data/lib/k9o/moat.rb +443 -0
- data/lib/k9o/org.rb +81 -68
- data/lib/k9o/orgs.rb +1 -0
- data/lib/k9o/policies.rb +25 -6
- data/lib/k9o/projects.rb +43 -8
- data/lib/k9o/query.rb +174 -0
- data/lib/k9o/rampart.rb +273 -0
- data/lib/k9o/realtime.rb +261 -32
- data/lib/k9o/region.rb +20 -18
- data/lib/k9o/schedules.rb +109 -0
- data/lib/k9o/schema.rb +35 -0
- data/lib/k9o/streams.rb +94 -0
- data/lib/k9o/studio.rb +21 -11
- data/lib/k9o/url_grammar.rb +46 -20
- data/lib/k9o/vault.rb +79 -84
- data/lib/k9o/vectors.rb +124 -0
- data/lib/k9o/version.rb +1 -1
- data/lib/k9o.rb +17 -0
- data/sig/k9o/ai.rbs +4 -0
- data/sig/k9o/approvals.rbs +71 -0
- data/sig/k9o/datasets.rbs +125 -0
- data/sig/k9o/gate.rbs +9 -6
- data/sig/k9o/kerror.rbs +24 -0
- data/sig/k9o/kibo_client_core.rbs +25 -0
- data/sig/k9o/metadata.rbs +54 -0
- data/sig/k9o/moat.rbs +29 -0
- data/sig/k9o/query.rbs +26 -0
- data/sig/k9o/rampart.rbs +59 -0
- data/sig/k9o/realtime.rbs +34 -0
- data/sig/k9o/region.rbs +14 -3
- data/sig/k9o/workload.rbs +64 -0
- metadata +33 -4
|
Binary file
|
data/lib/k9o/ai.rb
CHANGED
|
@@ -7,6 +7,8 @@ module K9o
|
|
|
7
7
|
# Wire (project-plane):
|
|
8
8
|
# POST /@<slug>/ai/embeddings body { model?, input: String | [String] }
|
|
9
9
|
# -> { data: { embeddings: [[Float]], model:, dimensions: }, meta }
|
|
10
|
+
# POST /@<slug>/ai/generate body { prompt:, model?, maxTokens?, temperature?, usageType? }
|
|
11
|
+
# -> { data: { modelId:, content:, finishReason:, usage:, usageType: }, meta }
|
|
10
12
|
#
|
|
11
13
|
# Bounds are enforced by the gateway, not here. The SDK sends `input`
|
|
12
14
|
# exactly as given and never puts it in an error.
|
|
@@ -24,5 +26,18 @@ module K9o
|
|
|
24
26
|
HttpTransport.request(config: @config, method: 'POST',
|
|
25
27
|
path: UrlGrammar.project_path(slug, 'ai/embeddings'), body: body)
|
|
26
28
|
end
|
|
29
|
+
|
|
30
|
+
# POST /@<slug>/ai/generate — one synchronous text generation (verb sugar over compute.llms.invoke).
|
|
31
|
+
# Only the fields given are sent; without `model` the gateway uses the org's default LLM.
|
|
32
|
+
def generate(prompt, model: nil, max_tokens: nil, temperature: nil, usage_type: nil)
|
|
33
|
+
slug = Studio.require_project_slug(@config, 'ai')
|
|
34
|
+
body = { 'prompt' => prompt }
|
|
35
|
+
body['model'] = model unless model.nil?
|
|
36
|
+
body['maxTokens'] = max_tokens unless max_tokens.nil?
|
|
37
|
+
body['temperature'] = temperature unless temperature.nil?
|
|
38
|
+
body['usageType'] = usage_type unless usage_type.nil?
|
|
39
|
+
HttpTransport.request(config: @config, method: 'POST',
|
|
40
|
+
path: UrlGrammar.project_path(slug, 'ai/generate'), body: body)
|
|
41
|
+
end
|
|
27
42
|
end
|
|
28
43
|
end
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
require 'stringio'
|
|
6
|
+
|
|
7
|
+
module K9o
|
|
8
|
+
# Shared plumbing for the dataset namespaces.
|
|
9
|
+
module DatasetSupport
|
|
10
|
+
REATTEST_DAYS = [7, 30, 90, 365].freeze
|
|
11
|
+
ON_ERROR = %w[skip reject].freeze
|
|
12
|
+
UPLOAD_FORMATS = %w[csv json].freeze
|
|
13
|
+
MAX_UPLOAD_BYTES = 25 * 1024 * 1024
|
|
14
|
+
RESIDENCY = %w[per-subject shared].freeze
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def invalid(message, context = {})
|
|
19
|
+
ApprovalChecks.invalid(message, context)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def seg(value, what)
|
|
23
|
+
ApprovalChecks.id_segment(value, what)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def project
|
|
27
|
+
Studio.require_project_slug(@config, 'datasets')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def route(tail)
|
|
31
|
+
UrlGrammar.project_path(project, tail)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call(method, tail, body: nil, query: nil, **extra)
|
|
35
|
+
HttpTransport.request(config: @config, method: method, path: route(tail), body: body, query: query, **extra)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def stringify(value)
|
|
39
|
+
case value
|
|
40
|
+
when Hash then value.each_with_object({}) { |(k, v), out| out[k.to_s] = stringify(v) }
|
|
41
|
+
when Array then value.map { |v| stringify(v) }
|
|
42
|
+
when Symbol then value.to_s
|
|
43
|
+
else value
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def reattest_days!(value)
|
|
48
|
+
return nil if value.nil?
|
|
49
|
+
return value if REATTEST_DAYS.include?(value)
|
|
50
|
+
|
|
51
|
+
raise invalid("reattest_interval_days must be one of #{REATTEST_DAYS.join(', ')}", reattest_interval_days: value)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# client.analytics: datasets. Routes under /@<slug>/analytics.
|
|
56
|
+
#
|
|
57
|
+
# list GET analytics
|
|
58
|
+
# query(sql, datasets) POST analytics/query
|
|
59
|
+
# create(slug, display_name:, columns:, residency_class:, ...) POST analytics/<slug>
|
|
60
|
+
# get(slug) / delete(slug) GET / DELETE analytics/<slug>
|
|
61
|
+
# materialize(slug, from_collection:|from_query:) POST analytics/<slug>/materialize
|
|
62
|
+
# read(slug, partition_filter:, cursor:, limit:) POST analytics/<slug>/read
|
|
63
|
+
# write(slug, rows, on_error:) POST analytics/<slug>/write
|
|
64
|
+
# upload(slug, data, format:, ...) POST analytics/<slug>/upload
|
|
65
|
+
# download_original(slug) GET analytics/<slug>/upload/original (bytes)
|
|
66
|
+
# rollups / schedules see AnalyticsRollups and AnalyticsSchedules
|
|
67
|
+
#
|
|
68
|
+
# Every call needs project membership with the matching datasets permission; service, workload and extension
|
|
69
|
+
# tokens are refused. While the region's analytics storage is not provisioned, queries and writes answer 503
|
|
70
|
+
# K9O_ANALYTICS_UNAVAILABLE (raised as K9o::KError).
|
|
71
|
+
class Analytics
|
|
72
|
+
include DatasetSupport
|
|
73
|
+
|
|
74
|
+
attr_reader :rollups, :schedules
|
|
75
|
+
|
|
76
|
+
def initialize(config)
|
|
77
|
+
@config = config
|
|
78
|
+
@rollups = AnalyticsRollups.new(config)
|
|
79
|
+
@schedules = AnalyticsSchedules.new(config)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The project's active datasets, newest first.
|
|
83
|
+
def list
|
|
84
|
+
call('GET', 'analytics')
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Run one read-only SQL SELECT over datasets. Reference each as +dataset.<slug>+ and name it in +datasets+ (1 to
|
|
88
|
+
# 64 slugs). +sql+ is at most 100,000 characters. +data+ holds the rows.
|
|
89
|
+
def query(sql, datasets)
|
|
90
|
+
raise invalid('sql must be a non-empty String') unless sql.is_a?(String) && !sql.strip.empty?
|
|
91
|
+
raise invalid('sql must be at most 100000 characters', length: sql.length) if sql.length > 100_000
|
|
92
|
+
unless datasets.is_a?(Array) && !datasets.empty? && datasets.size <= 64 &&
|
|
93
|
+
datasets.all? { |d| d.is_a?(String) && !d.empty? }
|
|
94
|
+
raise invalid('datasets must be an Array of 1 to 64 dataset slugs')
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
call('POST', 'analytics/query', body: { 'sql' => sql, 'datasets' => datasets })
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Create a dataset at +slug+. +columns+ is an Array of { name:, type: } (1 to 512; types string, int32, int64,
|
|
101
|
+
# float64, bool, date, timestamp, json, bytes, decimal(p,s)). +residency_class+ is "per-subject" or "shared".
|
|
102
|
+
# Optional +partition_by+ (up to 16 keys; a timestamp key needs a transform such as "created_at:day") and
|
|
103
|
+
# +storage_target+ ({ kind: "managed" } or { kind: "byos", binding_id: ... } spelled { kind:, bindingId: }).
|
|
104
|
+
def create(slug, display_name:, columns:, residency_class:, partition_by: nil, storage_target: nil)
|
|
105
|
+
unless display_name.is_a?(String) && !display_name.empty? && display_name.length <= 200
|
|
106
|
+
raise invalid('display_name must be a String of 1 to 200 characters')
|
|
107
|
+
end
|
|
108
|
+
unless columns.is_a?(Array) && !columns.empty? && columns.size <= 512 && columns.all? { |c| c.is_a?(Hash) }
|
|
109
|
+
raise invalid('columns must be an Array of 1 to 512 { name:, type: } Hashes')
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
cls = residency_class.to_s
|
|
113
|
+
raise invalid("residency_class must be one of #{RESIDENCY.join(', ')}", residency_class: residency_class) unless RESIDENCY.include?(cls)
|
|
114
|
+
|
|
115
|
+
body = { 'displayName' => display_name, 'columns' => stringify(columns), 'residencyClass' => cls }
|
|
116
|
+
unless partition_by.nil?
|
|
117
|
+
raise invalid('partition_by must be an Array of at most 16 strings') unless partition_by.is_a?(Array) && partition_by.size <= 16
|
|
118
|
+
|
|
119
|
+
body['partitionBy'] = partition_by
|
|
120
|
+
end
|
|
121
|
+
body['storageTarget'] = stringify(storage_target) unless storage_target.nil?
|
|
122
|
+
call('POST', "analytics/#{seg(slug, 'dataset')}", body: body)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def get(slug)
|
|
126
|
+
call('GET', "analytics/#{seg(slug, 'dataset')}")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Retire the dataset: it reads as absent from then on and its slug is never reissued.
|
|
130
|
+
def delete(slug)
|
|
131
|
+
call('DELETE', "analytics/#{seg(slug, 'dataset')}")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Copy rows in from exactly one source: +from_collection+ (a collection name) or +from_query+ (a saved Studio
|
|
135
|
+
# query id that reads one collection). +data+ has rowsRead, ingested and rejected.
|
|
136
|
+
def materialize(slug, from_collection: nil, from_query: nil)
|
|
137
|
+
if from_collection.nil? == from_query.nil?
|
|
138
|
+
raise invalid('give exactly one of from_collection or from_query')
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
body = {}
|
|
142
|
+
body['fromCollection'] = from_collection unless from_collection.nil?
|
|
143
|
+
body['fromQuery'] = from_query unless from_query.nil?
|
|
144
|
+
call('POST', "analytics/#{seg(slug, 'dataset')}/materialize", body: body)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# One page of rows. Each row has a stable +_k9o_row_id+; +meta.cursor.next+ is the +cursor:+ of the next page and
|
|
148
|
+
# nil on the last. The first page carries +meta.schema+.
|
|
149
|
+
def read(slug, partition_filter: nil, cursor: nil, limit: nil)
|
|
150
|
+
body = {}
|
|
151
|
+
body['partitionFilter'] = stringify(partition_filter) unless partition_filter.nil?
|
|
152
|
+
body['cursor'] = cursor unless cursor.nil?
|
|
153
|
+
unless limit.nil?
|
|
154
|
+
raise invalid('limit must be a whole number of 1 or more', limit: limit) unless limit.is_a?(Integer) && limit >= 1
|
|
155
|
+
|
|
156
|
+
body['limit'] = limit
|
|
157
|
+
end
|
|
158
|
+
call('POST', "analytics/#{seg(slug, 'dataset')}/read", body: body)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Append rows. +rows+ is an Array of Hashes (sent as a JSON array) or a String of newline-delimited JSON. A bad
|
|
162
|
+
# row is skipped and reported (+on_error: "skip"+, the default) or refuses the whole write ("reject").
|
|
163
|
+
# +data+ has ingested, skipped, skippedDetails, the partitions touched and the bytes written.
|
|
164
|
+
def write(slug, rows, on_error: nil)
|
|
165
|
+
query = on_error_query(on_error)
|
|
166
|
+
path = "analytics/#{seg(slug, 'dataset')}/write"
|
|
167
|
+
if rows.is_a?(String)
|
|
168
|
+
call('POST', path, query: query, raw_body: rows, headers: { 'content-type' => 'application/x-ndjson' })
|
|
169
|
+
elsif rows.is_a?(Array) && rows.all? { |r| r.is_a?(Hash) }
|
|
170
|
+
call('POST', path, query: query, body: stringify(rows))
|
|
171
|
+
else
|
|
172
|
+
raise invalid('rows must be an Array of Hashes or a String of newline-delimited JSON')
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Upload a CSV or JSON file (a String of bytes or an IO; up to 25 MiB). +format+ is "csv" or "json" (required).
|
|
177
|
+
# +dry_run: true+ returns the detected columns and a 50-row preview without writing. +manifest+ (a Hash, same
|
|
178
|
+
# fields as #create) on a slug that does not exist yet creates the dataset and loads the file in one call; the
|
|
179
|
+
# file then goes as multipart with +filename+. CSV options: +delimiter+, +encoding+, +has_header+.
|
|
180
|
+
def upload(slug, data, format:, filename: 'upload', manifest: nil, dry_run: nil, on_error: nil, delimiter: nil,
|
|
181
|
+
encoding: nil, has_header: nil)
|
|
182
|
+
fmt = format.to_s
|
|
183
|
+
raise invalid("format must be one of #{UPLOAD_FORMATS.join(', ')}", format: format) unless UPLOAD_FORMATS.include?(fmt)
|
|
184
|
+
|
|
185
|
+
bytes = data.respond_to?(:read) ? data.read : data
|
|
186
|
+
raise invalid('data must be a String or an IO') unless bytes.is_a?(String)
|
|
187
|
+
raise invalid("the file is over #{MAX_UPLOAD_BYTES} bytes", size: bytes.bytesize) if bytes.bytesize > MAX_UPLOAD_BYTES
|
|
188
|
+
|
|
189
|
+
query = { 'format' => fmt }
|
|
190
|
+
query['dryRun'] = dry_run.to_s unless dry_run.nil?
|
|
191
|
+
query.merge!(on_error_query(on_error))
|
|
192
|
+
query['delimiter'] = delimiter unless delimiter.nil?
|
|
193
|
+
query['encoding'] = encoding unless encoding.nil?
|
|
194
|
+
query['hasHeader'] = has_header.to_s unless has_header.nil?
|
|
195
|
+
path = "analytics/#{seg(slug, 'dataset')}/upload"
|
|
196
|
+
mime = fmt == 'csv' ? 'text/csv' : 'application/json'
|
|
197
|
+
if manifest.nil?
|
|
198
|
+
call('POST', path, query: query, raw_body: bytes, headers: { 'content-type' => mime })
|
|
199
|
+
else
|
|
200
|
+
raise invalid('manifest must be a Hash') unless manifest.is_a?(Hash)
|
|
201
|
+
|
|
202
|
+
parts = [{ name: 'file', value: bytes, filename: filename, content_type: mime },
|
|
203
|
+
{ name: 'manifest', value: JSON.dump(stringify(manifest)), content_type: 'application/json' }]
|
|
204
|
+
call('POST', path, query: query, multipart: parts)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# The original file of the dataset's stored upload. Answers a K9o::Download (+body+ bytes, +content_type+),
|
|
209
|
+
# not an Envelope; 404 when none is stored.
|
|
210
|
+
def download_original(slug)
|
|
211
|
+
call('GET', "analytics/#{seg(slug, 'dataset')}/upload/original", binary: true)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
def on_error_query(on_error)
|
|
217
|
+
return {} if on_error.nil?
|
|
218
|
+
|
|
219
|
+
{ 'onError' => ApprovalChecks.enum!(on_error, ON_ERROR, 'on_error') }
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# client.analytics.rollups: continuous rollups that fold a stream into a dataset over fixed windows.
|
|
224
|
+
#
|
|
225
|
+
# A rollup keeps running only while a person re-attests it within +reattest_interval_days+ (7, 30, 90 or 365;
|
|
226
|
+
# 90 by default). Without #reattest it pauses when the interval ends (it is paused, not deleted).
|
|
227
|
+
class AnalyticsRollups
|
|
228
|
+
include DatasetSupport
|
|
229
|
+
|
|
230
|
+
AGGS = %w[count last sum avg min max count_distinct p50 p95 p99].freeze
|
|
231
|
+
|
|
232
|
+
def initialize(config)
|
|
233
|
+
@config = config
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Create a rollup on +dataset_slug+. +slug+ names it, +source+ is the stream it folds, +measures+ is an Array of
|
|
237
|
+
# { name:, agg:, column: } (up to 64; +agg+ is count, last, sum, avg, min, max, count_distinct, p50, p95 or p99;
|
|
238
|
+
# +column+ is not used by count), +dimensions+ up to 16 column names, +window_seconds+ at least 60. Give exactly
|
|
239
|
+
# one of +subject_field+ (per-subject data, suppressed below the cohort threshold) or +non_personal: true+.
|
|
240
|
+
def create(dataset_slug, slug:, source:, measures:, window_seconds:, dimensions: nil, subject_field: nil,
|
|
241
|
+
non_personal: nil, reattest_interval_days: nil)
|
|
242
|
+
raise invalid('slug must be a String of 1 to 63 characters') unless slug.is_a?(String) && !slug.empty? && slug.length <= 63
|
|
243
|
+
raise invalid('source must be a non-empty String') unless source.is_a?(String) && !source.empty?
|
|
244
|
+
unless measures.is_a?(Array) && !measures.empty? && measures.size <= 64 && measures.all? { |m| m.is_a?(Hash) }
|
|
245
|
+
raise invalid('measures must be an Array of 1 to 64 { name:, agg:, column: } Hashes')
|
|
246
|
+
end
|
|
247
|
+
unless window_seconds.is_a?(Integer) && window_seconds >= 60
|
|
248
|
+
raise invalid('window_seconds must be a whole number of 60 or more', window_seconds: window_seconds)
|
|
249
|
+
end
|
|
250
|
+
if dimensions && !(dimensions.is_a?(Array) && dimensions.size <= 16)
|
|
251
|
+
raise invalid('dimensions must be an Array of at most 16 column names')
|
|
252
|
+
end
|
|
253
|
+
if subject_field.nil? == (non_personal != true)
|
|
254
|
+
raise invalid('give exactly one of subject_field or non_personal: true')
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
body = { 'slug' => slug, 'source' => source, 'measures' => stringify(measures), 'windowSeconds' => window_seconds }
|
|
258
|
+
body['dimensions'] = dimensions unless dimensions.nil?
|
|
259
|
+
body['subjectField'] = subject_field unless subject_field.nil?
|
|
260
|
+
body['nonPersonal'] = true if non_personal == true
|
|
261
|
+
days = reattest_days!(reattest_interval_days)
|
|
262
|
+
body['reattestIntervalDays'] = days unless days.nil?
|
|
263
|
+
call('POST', "analytics/#{seg(dataset_slug, 'dataset')}/rollups", body: body)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# The dataset's rollups with attestation state: +attestedAt+, +expiresAt+ and +attestationStatus+ ("active",
|
|
267
|
+
# "expiring_soon" or "expired").
|
|
268
|
+
def list(dataset_slug)
|
|
269
|
+
call('GET', "analytics/#{seg(dataset_slug, 'dataset')}/rollups")
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def delete(dataset_slug, rollup_id)
|
|
273
|
+
call('DELETE', "analytics/#{seg(dataset_slug, 'dataset')}/rollups/#{seg(rollup_id, 'rollup')}")
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Confirm the rollup is still wanted: resets its attestation to now, optionally with a new interval, and resumes
|
|
277
|
+
# an expired rollup. Only a person can do this: a service, workload or extension token gets 403 K9O_RBAC_DENY
|
|
278
|
+
# with context reason "human_only" (raised as K9o::KError); a personal access token acts as its person.
|
|
279
|
+
def reattest(dataset_slug, rollup_id, reattest_interval_days: nil)
|
|
280
|
+
body = {}
|
|
281
|
+
days = reattest_days!(reattest_interval_days)
|
|
282
|
+
body['reattestIntervalDays'] = days unless days.nil?
|
|
283
|
+
call('POST', "analytics/#{seg(dataset_slug, 'dataset')}/rollups/#{seg(rollup_id, 'rollup')}/reattest", body: body)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# client.analytics.schedules: recurring materialization from a collection into a dataset. A schedule keeps running
|
|
288
|
+
# only while a person re-attests it within +reattest_interval_days+; see #reattest.
|
|
289
|
+
class AnalyticsSchedules
|
|
290
|
+
include DatasetSupport
|
|
291
|
+
|
|
292
|
+
def initialize(config)
|
|
293
|
+
@config = config
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# +interval_seconds+ is at least 60. Each run reads the collection with its creator's current access.
|
|
297
|
+
def create(dataset_slug, from_collection:, interval_seconds:, reattest_interval_days: nil)
|
|
298
|
+
unless from_collection.is_a?(String) && !from_collection.empty?
|
|
299
|
+
raise invalid('from_collection must be a non-empty String')
|
|
300
|
+
end
|
|
301
|
+
unless interval_seconds.is_a?(Integer) && interval_seconds >= 60
|
|
302
|
+
raise invalid('interval_seconds must be a whole number of 60 or more', interval_seconds: interval_seconds)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
body = { 'fromCollection' => from_collection, 'intervalSeconds' => interval_seconds }
|
|
306
|
+
days = reattest_days!(reattest_interval_days)
|
|
307
|
+
body['reattestIntervalDays'] = days unless days.nil?
|
|
308
|
+
call('POST', "analytics/#{seg(dataset_slug, 'dataset')}/schedules", body: body)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# The dataset's schedules with +lastRunAt+, +lastRunStatus+, +nextRunAt+ and attestation state.
|
|
312
|
+
def list(dataset_slug)
|
|
313
|
+
call('GET', "analytics/#{seg(dataset_slug, 'dataset')}/schedules")
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def delete(dataset_slug, schedule_id)
|
|
317
|
+
call('DELETE', "analytics/#{seg(dataset_slug, 'dataset')}/schedules/#{seg(schedule_id, 'schedule')}")
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Confirm the schedule is still wanted (a paused schedule resumes). Only a person can do this: a service,
|
|
321
|
+
# workload or extension token gets 403 K9O_RBAC_DENY with context reason "human_only" (raised as K9o::KError).
|
|
322
|
+
def reattest(dataset_slug, schedule_id, reattest_interval_days: nil)
|
|
323
|
+
body = {}
|
|
324
|
+
days = reattest_days!(reattest_interval_days)
|
|
325
|
+
body['reattestIntervalDays'] = days unless days.nil?
|
|
326
|
+
call('POST', "analytics/#{seg(dataset_slug, 'dataset')}/schedules/#{seg(schedule_id, 'schedule')}/reattest", body: body)
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# client.rollups: read the project's rollups.
|
|
331
|
+
#
|
|
332
|
+
# list GET /@<slug>/rollups
|
|
333
|
+
# read(rollup_slug, from:, to:, dims:) GET /@<slug>/rollups/<slug>/read
|
|
334
|
+
# subscribe(rollup_slug, from_sequence:, on_resync:) GET .../subscribe (server-sent events)
|
|
335
|
+
class Rollups
|
|
336
|
+
include DatasetSupport
|
|
337
|
+
|
|
338
|
+
def initialize(config)
|
|
339
|
+
@config = config
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Every rollup in the project whose dataset you can read, with its read and subscribe endpoints.
|
|
343
|
+
def list
|
|
344
|
+
call('GET', 'rollups')
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# The rollup's cells for a time range. +from+ and +to+ are ISO 8601 strings or Times (the gateway defaults to the
|
|
348
|
+
# last 24 hours; at most 50,000 cells). +dims+ is an Array or comma-separated String projecting dimensions (only
|
|
349
|
+
# count, sum, min and max measures combine). A cell below the cohort threshold is { "status" => "suppressed",
|
|
350
|
+
# "reason" => "cohort_below_k" }; +coverage+ says whether the range is complete.
|
|
351
|
+
def read(rollup_slug, from: nil, to: nil, dims: nil)
|
|
352
|
+
query = {}
|
|
353
|
+
query['from'] = time_text(from) unless from.nil?
|
|
354
|
+
query['to'] = time_text(to) unless to.nil?
|
|
355
|
+
query['dims'] = dims.is_a?(Array) ? dims.join(',') : dims unless dims.nil?
|
|
356
|
+
call('GET', "rollups/#{seg(rollup_slug, 'rollup')}/read", query: query)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# An Enumerator of closed cells, one per +cell+ frame. A frame wraps the cell as its +payload+; each cell
|
|
360
|
+
# yielded is { "cellKey", "bucket", "dims", "final", "measures", "suppressed", "eventId" }. A cell below the cohort
|
|
361
|
+
# threshold has no values: it is { "cellKey", "bucket", "status" => "suppressed", "reason" => "cohort_below_k",
|
|
362
|
+
# "suppressed" => true }; for every other cell +suppressed+ is false. +eventId+ is the publisher's id for the frame.
|
|
363
|
+
#
|
|
364
|
+
# It reconnects with +Last-Event-ID+ (the frame's SSE +id+, the resume cursor), drops cells it already delivered
|
|
365
|
+
# by +eventId+ (a resume re-sends the last 5 seconds), ignores the +:hb+ heartbeat (every 15 seconds) and reopens
|
|
366
|
+
# when the gateway ends the connection (after at most 300 seconds). On +event: resync+ (action +reread-source+:
|
|
367
|
+
# the position is lost, so re-read the rollup with #read) it calls +on_resync+ with { "reason", "action" } and
|
|
368
|
+
# continues from a fresh connection. +from_sequence+ starts from a known position on the first connection. The
|
|
369
|
+
# stream ends when the access token expires (the next connection is then refused 401). Stop with +break+ or
|
|
370
|
+
# +first(n)+.
|
|
371
|
+
def subscribe(rollup_slug, from_sequence: nil, on_resync: nil)
|
|
372
|
+
unless from_sequence.nil? || (from_sequence.is_a?(Integer) && from_sequence >= 0)
|
|
373
|
+
raise invalid('from_sequence must be a whole number, 0 or more', from_sequence: from_sequence)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
path = route("rollups/#{seg(rollup_slug, 'rollup')}/subscribe")
|
|
377
|
+
decode = lambda do |data, frame|
|
|
378
|
+
next nil unless data.is_a?(Hash) && data['payload'].is_a?(Hash)
|
|
379
|
+
next nil unless frame[:event].nil? || frame[:event] == 'cell' || data['eventType'] == 'cell'
|
|
380
|
+
|
|
381
|
+
cell = data['payload'].dup
|
|
382
|
+
cell['suppressed'] = cell['status'] == 'suppressed'
|
|
383
|
+
cell['eventId'] = data['eventId'] if data['eventId'].is_a?(String)
|
|
384
|
+
cell
|
|
385
|
+
end
|
|
386
|
+
keys = lambda do |cell, frame|
|
|
387
|
+
id = cell['eventId'].is_a?(String) ? cell['eventId'] : (frame && frame[:id])
|
|
388
|
+
id ? [id] : []
|
|
389
|
+
end
|
|
390
|
+
cursor = lambda do |cell, frame|
|
|
391
|
+
(frame && frame[:id]) || cell['eventId']
|
|
392
|
+
end
|
|
393
|
+
Realtime.new(@config).feed(path, {}, from_sequence.nil? ? nil : { 'fromSequence' => from_sequence }, on_resync,
|
|
394
|
+
decode: decode, keys: keys, cursor: cursor)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
private
|
|
398
|
+
|
|
399
|
+
def time_text(value)
|
|
400
|
+
value.respond_to?(:iso8601) ? value.iso8601 : value.to_s
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
end
|