bzync-nextsql 0.1.1 → 0.2.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c3ea6924ca4732695f486e9def584fd7d8eb6dfb90e646c54819c047ee4ff61a
4
- data.tar.gz: e70bf019af638b3ba62255994dfe881a033c340ccc1f6f20732a625e3a0dd19e
3
+ metadata.gz: 0146d77d1a64f774c074a7675c2c10be3cb9c00b65e1e60fead33c7baf5d4b48
4
+ data.tar.gz: 6490a676adbdc20b430a45eebd356d155d5f882ab76aab8b94af4a6302cbe5cf
5
5
  SHA512:
6
- metadata.gz: 8115ef2bd516eefc60bc59c5a237e71f668571cc95cdaf97cd37928eb73582552fdbd912ff6ec16c2b46256218dc89f0c853049cad0e7038aae895aa3b6dc573
7
- data.tar.gz: 27caa6edbcf6b55c10e9af5f2fb59159a32d3d4f9cbfbb5bdd96f1ba744b7435ccf9a1430585219ed8a6fb5fdaaed2efdd2063de75752f5186cb2b55486962b2
6
+ metadata.gz: 6d7e2921ed7f87454c6d65893d06178a9f776dc451b04e0258005f8a7d210f870f4fc9fc94e6cf9ad69a650b43a58b0bc22814844772ab96c7e968b31181bf06
7
+ data.tar.gz: 7cc3a24342dbaacb69cb828c6fc7214b618dce91eb6ca4c832019d681e5f7915da4c5f8456039805189fe4eaeb80f9ce5cbec231a912014ff5d18cb78b9d6042
data/README.md CHANGED
@@ -31,7 +31,23 @@ end
31
31
  Plaintext connections are allowed only on loopback. For an HA cluster with
32
32
  follower-read routing, use `NextSQL.connect_cluster`.
33
33
 
34
+ ### Connection Pooling & Statement Caching
35
+
36
+ ```ruby
37
+ pool = NextSQL.create_pool(cfg, min_size: 2, max_size: 10, statement_cache_size: 50)
38
+ begin
39
+ pool.with do |conn|
40
+ res = conn.exec("SELECT * FROM users WHERE status = $1", ["active"])
41
+ end
42
+ ensure
43
+ pool.close
44
+ end
45
+ ```
46
+
47
+ Parameterized queries automatically reuse cached server-side prepared statements via LRU eviction policy.
48
+
34
49
  - Full driver docs: <https://nextsql.bzync.com/docs/drivers>
35
50
  - Wire protocol: <https://github.com/bzync/nextsql/blob/master/docs/protocol.md>
51
+ - Client-side field encryption: <https://nextsql.bzync.com/docs/client-encryption>
36
52
 
37
53
  MIT licensed.
@@ -10,6 +10,7 @@ require "ipaddr"
10
10
 
11
11
  require_relative "protocol"
12
12
  require_relative "errors"
13
+ require_relative "field_encryption"
13
14
 
14
15
  module NextSQL
15
16
  # TLS options for a remote connection. +ca+ is PEM text/bytes; omit both
@@ -24,12 +25,14 @@ module NextSQL
24
25
  end
25
26
 
26
27
  Config = Struct.new(:address, :nodes, :database, :realm, :user, :password, :key, :key_version, :tls,
27
- :insecure_no_tls, :read_consistency, :max_staleness_ms, :timeout, keyword_init: true) do
28
+ :insecure_no_tls, :read_consistency, :max_staleness_ms, :timeout, :field_keys,
29
+ :statement_cache_size, keyword_init: true) do
28
30
  def initialize(**kwargs)
29
31
  defaults = {
30
32
  address: "", nodes: [], database: "", realm: "", user: "", password: "",
31
33
  key: nil, key_version: 1, tls: nil, insecure_no_tls: false,
32
- read_consistency: Protocol::READ_STRONG, max_staleness_ms: 0, timeout: 60.0
34
+ read_consistency: Protocol::READ_STRONG, max_staleness_ms: 0, timeout: 60.0,
35
+ field_keys: nil, statement_cache_size: 50
33
36
  }
34
37
  super(defaults.merge(kwargs))
35
38
  end
@@ -95,6 +98,7 @@ module NextSQL
95
98
  raise Error.new("invalid_argument", "plaintext is only allowed on loopback")
96
99
  end
97
100
  raise Error.new("invalid_argument", "user is required") if cfg.user.to_s.empty?
101
+ raise Error.new("invalid_argument", "statement_cache_size must be >= 0") if cfg.statement_cache_size && cfg.statement_cache_size < 0
98
102
  end
99
103
 
100
104
  LEADING_WS_RE = /\A[ \t\r\n(]+/.freeze
@@ -175,6 +179,8 @@ module NextSQL
175
179
  @sock = self.class.dial(cfg)
176
180
  @secret = "".b
177
181
  @busy = false
182
+ @statement_cache_size = cfg.statement_cache_size || 0
183
+ @stmt_cache = {}
178
184
  begin
179
185
  handshake
180
186
  set_read_consistency(cfg.read_consistency, cfg.max_staleness_ms) if cfg.read_consistency != Protocol::READ_STRONG
@@ -203,6 +209,30 @@ module NextSQL
203
209
  st
204
210
  end
205
211
 
212
+ def encrypt_field(table, column, field_type, value)
213
+ raise Error.new("invalid_argument", "field_keys is required for client encryption") unless @cfg.field_keys
214
+
215
+ FieldEncryption.encrypt(@cfg.field_keys, @cfg.database.to_s.empty? ? "default" : @cfg.database, table, column, field_type, value)
216
+ end
217
+
218
+ def decrypt_field(table, column, expected_type, ciphertext)
219
+ raise Error.new("invalid_argument", "field_keys is required for client encryption") unless @cfg.field_keys
220
+
221
+ FieldEncryption.decrypt(@cfg.field_keys, @cfg.database.to_s.empty? ? "default" : @cfg.database, table, column, expected_type, ciphertext)
222
+ end
223
+
224
+ def encrypt_field_deterministic(table, column, field_type, value)
225
+ raise Error.new("invalid_argument", "field_keys is required for client encryption") unless @cfg.field_keys
226
+
227
+ FieldEncryption.encrypt_deterministic(@cfg.field_keys, @cfg.database.to_s.empty? ? "default" : @cfg.database, table, column, field_type, value)
228
+ end
229
+
230
+ def decrypt_field_deterministic(table, column, expected_type, ciphertext)
231
+ raise Error.new("invalid_argument", "field_keys is required for client encryption") unless @cfg.field_keys
232
+
233
+ FieldEncryption.decrypt_deterministic(@cfg.field_keys, @cfg.database.to_s.empty? ? "default" : @cfg.database, table, column, expected_type, ciphertext)
234
+ end
235
+
206
236
  def exec(sql, params = [])
207
237
  query(sql, params).collect
208
238
  end
@@ -211,6 +241,29 @@ module NextSQL
211
241
  raise Error.new("unavailable", "connection closed") if @sock.nil?
212
242
  raise Error.new("conflict", "connection is busy") if @busy
213
243
 
244
+ if !params.empty? && @statement_cache_size > 0
245
+ stmt_id = @stmt_cache.delete(sql)
246
+ if stmt_id.nil?
247
+ stmt_id = prepare_id(sql)
248
+ if @stmt_cache.size >= @statement_cache_size
249
+ _evicted_sql, evicted_id = @stmt_cache.shift
250
+ begin
251
+ close_statement(evicted_id)
252
+ rescue StandardError
253
+ nil
254
+ end
255
+ end
256
+ end
257
+ @stmt_cache[sql] = stmt_id
258
+
259
+ begin
260
+ return execute_prepared(stmt_id, params)
261
+ rescue StandardError
262
+ @stmt_cache.delete(sql)
263
+ raise
264
+ end
265
+ end
266
+
214
267
  @busy = true
215
268
  begin
216
269
  write_frame(Protocol::TYPE_QUERY, Protocol.encode_query(sql, params))
@@ -241,7 +294,7 @@ module NextSQL
241
294
  end
242
295
  end
243
296
 
244
- def prepare(sql)
297
+ def prepare_id(sql)
245
298
  raise Error.new("conflict", "connection is busy") if @busy
246
299
 
247
300
  write_frame(Protocol::TYPE_PREPARE, Protocol.u32bytes(sql.b, Protocol::MAX_SQL))
@@ -251,7 +304,11 @@ module NextSQL
251
304
 
252
305
  stmt_id = Protocol.u32(payload, 0)
253
306
  expect_ready
254
- Statement.new(self, stmt_id)
307
+ stmt_id
308
+ end
309
+
310
+ def prepare(sql)
311
+ Statement.new(self, prepare_id(sql))
255
312
  end
256
313
 
257
314
  def execute_prepared(stmt_id, params)
@@ -300,6 +357,17 @@ module NextSQL
300
357
  def close
301
358
  return if @sock.nil?
302
359
 
360
+ if @stmt_cache
361
+ @stmt_cache.each_value do |stmt_id|
362
+ begin
363
+ close_statement(stmt_id)
364
+ rescue StandardError
365
+ nil
366
+ end
367
+ end
368
+ @stmt_cache.clear
369
+ end
370
+
303
371
  begin
304
372
  write_frame(Protocol::TYPE_TERMINATE, "")
305
373
  rescue Error
@@ -0,0 +1,823 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "openssl"
5
+ require "securerandom"
6
+ require "bigdecimal"
7
+ require "json"
8
+
9
+ require_relative "protocol"
10
+ require_relative "errors"
11
+
12
+ module NextSQL
13
+ FieldType = Struct.new(:kind, :precision, :scale, :vec_elem, keyword_init: true) do
14
+ def self.decimal(precision, scale)
15
+ if precision < 1 || precision > 38 || scale.negative? || scale > precision
16
+ raise Error.new("invalid_argument", "unsupported client-encrypted type")
17
+ end
18
+
19
+ new(kind: Protocol::KIND_DECIMAL, precision: precision, scale: scale, vec_elem: 0)
20
+ end
21
+ end
22
+
23
+ FieldType::UUID = FieldType.new(kind: Protocol::KIND_UUID, precision: 0, scale: 0, vec_elem: 0).freeze
24
+ FieldType::STRING = FieldType.new(kind: Protocol::KIND_STRING, precision: 0, scale: 0, vec_elem: 0).freeze
25
+ FieldType::TEXT = FieldType.new(kind: Protocol::KIND_TEXT, precision: 0, scale: 0, vec_elem: 0).freeze
26
+ FieldType::BLOB = FieldType.new(kind: Protocol::KIND_BLOB, precision: 0, scale: 0, vec_elem: 0).freeze
27
+ FieldType::TIMESTAMPTZ = FieldType.new(kind: Protocol::KIND_TIMESTAMPTZ, precision: 0, scale: 0, vec_elem: 0).freeze
28
+ FieldType::JSON = FieldType.new(kind: Protocol::KIND_JSON, precision: 0, scale: 0, vec_elem: 0).freeze
29
+ FieldType::BOOL = FieldType.new(kind: Protocol::KIND_BOOL, precision: 0, scale: 0, vec_elem: 0).freeze
30
+ FieldType::INT8 = FieldType.new(kind: Protocol::KIND_INT8, precision: 0, scale: 0, vec_elem: 0).freeze
31
+ FieldType::INT16 = FieldType.new(kind: Protocol::KIND_INT16, precision: 0, scale: 0, vec_elem: 0).freeze
32
+ FieldType::INT32 = FieldType.new(kind: Protocol::KIND_INT32, precision: 0, scale: 0, vec_elem: 0).freeze
33
+ FieldType::INT64 = FieldType.new(kind: Protocol::KIND_INT64, precision: 0, scale: 0, vec_elem: 0).freeze
34
+ FieldType::UINT8 = FieldType.new(kind: Protocol::KIND_UINT8, precision: 0, scale: 0, vec_elem: 0).freeze
35
+ FieldType::UINT16 = FieldType.new(kind: Protocol::KIND_UINT16, precision: 0, scale: 0, vec_elem: 0).freeze
36
+ FieldType::UINT32 = FieldType.new(kind: Protocol::KIND_UINT32, precision: 0, scale: 0, vec_elem: 0).freeze
37
+ FieldType::UINT64 = FieldType.new(kind: Protocol::KIND_UINT64, precision: 0, scale: 0, vec_elem: 0).freeze
38
+
39
+ FieldKey = Struct.new(:id, :material, keyword_init: true) do
40
+ def initialize(id:, material:)
41
+ FieldEncryption.validate_key_id!(id)
42
+ material_bytes = material.b
43
+ if material_bytes.bytesize != FieldEncryption::KEY_SIZE || material_bytes == ("\x00".b * FieldEncryption::KEY_SIZE)
44
+ raise Error.new("invalid_argument", "invalid field key")
45
+ end
46
+
47
+ super(id: id, material: material_bytes)
48
+ end
49
+ end
50
+
51
+ FieldKeyInfo = Struct.new(:id, :created, :current, :revoked, keyword_init: true)
52
+
53
+ class MemoryFieldKeyring
54
+ def initialize(current, overlap = [])
55
+ @keys = {}
56
+ @revoked = {}
57
+ all_keys = [current] + (overlap || [])
58
+ if all_keys.length > FieldEncryption::MAX_KEYS
59
+ raise Error.new("invalid_argument", "too many field keys")
60
+ end
61
+
62
+ seen = {}
63
+ all_keys.each do |k|
64
+ raise Error.new("invalid_argument", "invalid field key") unless k.is_a?(FieldKey)
65
+ raise Error.new("invalid_argument", "duplicate field key id") if seen[k.id]
66
+
67
+ seen[k.id] = true
68
+ @keys[k.id] = k
69
+ end
70
+ @current_id = current.id
71
+ end
72
+
73
+ def current_field_key(_database, _table, _column)
74
+ k = @keys[@current_id]
75
+ raise Error.new("crypto", "current field key unavailable") unless k
76
+
77
+ k
78
+ end
79
+
80
+ def field_key(_database, _table, _column, key_id)
81
+ if @revoked[key_id] || !@keys.key?(key_id)
82
+ raise Error.new("crypto", "field key unavailable or revoked")
83
+ end
84
+
85
+ @keys[key_id]
86
+ end
87
+
88
+ def rotate(key)
89
+ raise Error.new("invalid_argument", "invalid field key") unless key.is_a?(FieldKey)
90
+ raise Error.new("conflict", "cannot reuse a revoked field key id") if @revoked[key.id]
91
+
92
+ if !@keys.key?(key.id) && @keys.length >= FieldEncryption::MAX_KEYS
93
+ raise Error.new("exhausted", "field key limit reached")
94
+ end
95
+
96
+ @keys[key.id] = key
97
+ @current_id = key.id
98
+ end
99
+
100
+ def revoke(key_id)
101
+ if !@keys.key?(key_id) && !@revoked.key?(key_id)
102
+ raise Error.new("not_found", "unknown field key id")
103
+ end
104
+ if key_id == @current_id
105
+ raise Error.new("conflict", "cannot revoke the current field key")
106
+ end
107
+ return if @revoked[key_id]
108
+
109
+ @revoked[key_id] = true
110
+ @keys.delete(key_id)
111
+ end
112
+ end
113
+
114
+ class FileFieldKeyring
115
+ FileRecord = Struct.new(:id, :created, :current, :revoked, :material, keyword_init: true)
116
+
117
+ attr_reader :path
118
+
119
+ def initialize(path, records)
120
+ @path = path
121
+ @records = records
122
+ end
123
+
124
+ class << self
125
+ def create(path, current)
126
+ raise Error.new("already_exists", "keyring file exists") if File.exist?(path)
127
+ raise Error.new("invalid_argument", "invalid field key") unless current.is_a?(FieldKey)
128
+
129
+ rec = FileRecord.new(
130
+ id: current.id,
131
+ created: Time.now.utc,
132
+ current: true,
133
+ revoked: false,
134
+ material: current.material
135
+ )
136
+ kr = new(path, [rec])
137
+ kr.send(:persist!)
138
+ kr
139
+ end
140
+
141
+ def open(path)
142
+ raise Error.new("not_found", "keyring file not found") unless File.exist?(path)
143
+
144
+ raw = File.binread(path)
145
+ records = decode_keyring(raw)
146
+ new(path, records)
147
+ rescue Error
148
+ raise
149
+ rescue StandardError => e
150
+ raise Error.new("io", "failed to read keyring: #{e.message}")
151
+ end
152
+
153
+ def decode_keyring(raw)
154
+ raise Error.new("invalid_format", "truncated keyring") if raw.bytesize < 8
155
+ raise Error.new("invalid_format", "bad keyring magic") if raw[0, 4] != FieldEncryption::KEYRING_MAGIC
156
+
157
+ ver = raw[4, 2].unpack1("v")
158
+ count = raw[6, 2].unpack1("v")
159
+ raise Error.new("invalid_format", "unsupported keyring version") if ver != FieldEncryption::KEYRING_VERSION
160
+ raise Error.new("invalid_format", "keyring key count exceeds limit") if count > FieldEncryption::MAX_KEYS
161
+
162
+ off = 8
163
+ records = []
164
+ seen = {}
165
+ current_count = 0
166
+
167
+ count.times do
168
+ raise Error.new("invalid_format", "truncated keyring record") if off >= raw.bytesize
169
+
170
+ id_len = raw.getbyte(off)
171
+ off += 1
172
+ if id_len < 1 || id_len > FieldEncryption::MAX_KEY_ID || off + id_len + 8 + 1 + FieldEncryption::KEY_SIZE > raw.bytesize
173
+ raise Error.new("invalid_format", "invalid field key id length or truncated record")
174
+ end
175
+
176
+ id_str = raw[off, id_len].force_encoding("utf-8")
177
+ off += id_len
178
+ unless FieldEncryption.valid_key_id?(id_str) && !seen[id_str]
179
+ raise Error.new("invalid_format", "invalid or duplicate field key id")
180
+ end
181
+
182
+ seen[id_str] = true
183
+
184
+ created_sec = raw[off, 8].unpack1("Q<")
185
+ flags = raw.getbyte(off + 8)
186
+ off += 9
187
+ material = raw[off, FieldEncryption::KEY_SIZE]
188
+ off += FieldEncryption::KEY_SIZE
189
+
190
+ is_current = (flags & FieldEncryption::FLAG_CURRENT) != 0
191
+ is_revoked = (flags & FieldEncryption::FLAG_REVOKED) != 0
192
+
193
+ raise Error.new("invalid_format", "current key cannot be revoked") if is_current && is_revoked
194
+
195
+ if is_revoked && material != ("\x00".b * FieldEncryption::KEY_SIZE)
196
+ raise Error.new("invalid_format", "revoked key has non-zero material")
197
+ end
198
+ if !is_revoked && material == ("\x00".b * FieldEncryption::KEY_SIZE)
199
+ raise Error.new("invalid_format", "unrevoked key has all-zero material")
200
+ end
201
+
202
+ current_count += 1 if is_current
203
+
204
+ records << FileRecord.new(
205
+ id: id_str,
206
+ created: Time.at(created_sec).utc,
207
+ current: is_current,
208
+ revoked: is_revoked,
209
+ material: material
210
+ )
211
+ end
212
+
213
+ if count.positive? && current_count != 1
214
+ raise Error.new("invalid_format", "keyring must contain exactly one current key")
215
+ end
216
+ if off != raw.bytesize
217
+ raise Error.new("invalid_format", "trailing bytes after keyring records")
218
+ end
219
+
220
+ records
221
+ end
222
+ end
223
+
224
+ def current_field_key(_database, _table, _column)
225
+ rec = @records.find { |r| r.current && !r.revoked }
226
+ raise Error.new("crypto", "current field key unavailable") unless rec
227
+
228
+ FieldKey.new(id: rec.id, material: rec.material)
229
+ end
230
+
231
+ def field_key(_database, _table, _column, key_id)
232
+ rec = @records.find { |r| r.id == key_id }
233
+ if !rec || rec.revoked
234
+ raise Error.new("crypto", "field key unavailable or revoked")
235
+ end
236
+
237
+ FieldKey.new(id: rec.id, material: rec.material)
238
+ end
239
+
240
+ def rotate(key)
241
+ raise Error.new("invalid_argument", "invalid field key") unless key.is_a?(FieldKey)
242
+
243
+ idx = @records.find_index { |r| r.id == key.id }
244
+ if idx
245
+ raise Error.new("conflict", "cannot reuse a revoked field key id") if @records[idx].revoked
246
+ else
247
+ if @records.length >= FieldEncryption::MAX_KEYS
248
+ raise Error.new("exhausted", "field key limit reached")
249
+ end
250
+
251
+ @records << FileRecord.new(
252
+ id: key.id,
253
+ created: Time.now.utc,
254
+ current: false,
255
+ revoked: false,
256
+ material: key.material
257
+ )
258
+ idx = @records.length - 1
259
+ end
260
+
261
+ @records.each { |r| r.current = false }
262
+ @records[idx].current = true
263
+ @records[idx].material = key.material
264
+ persist!
265
+ end
266
+
267
+ def revoke(key_id)
268
+ idx = @records.find_index { |r| r.id == key_id }
269
+ raise Error.new("not_found", "unknown field key id") unless idx
270
+ if @records[idx].current
271
+ raise Error.new("conflict", "cannot revoke the current field key")
272
+ end
273
+ return if @records[idx].revoked
274
+
275
+ @records[idx].revoked = true
276
+ @records[idx].material = "\x00".b * FieldEncryption::KEY_SIZE
277
+ persist!
278
+ end
279
+
280
+ def reload
281
+ raw = File.binread(@path)
282
+ @records = self.class.decode_keyring(raw)
283
+ rescue Error
284
+ raise
285
+ rescue StandardError => e
286
+ raise Error.new("io", "failed to reload keyring: #{e.message}")
287
+ end
288
+
289
+ def list
290
+ @records.map do |r|
291
+ FieldKeyInfo.new(id: r.id, created: r.created, current: r.current, revoked: r.revoked)
292
+ end
293
+ end
294
+
295
+ private
296
+
297
+ def persist!
298
+ raw = encode_keyring
299
+ tmp = "#{@path}.tmp.#{Process.pid}.#{SecureRandom.hex(4)}"
300
+ File.open(tmp, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |f|
301
+ f.binmode
302
+ f.write(raw)
303
+ f.flush
304
+ f.fsync
305
+ end
306
+ File.rename(tmp, @path)
307
+ rescue StandardError => e
308
+ File.unlink(tmp) if File.exist?(tmp)
309
+ raise Error.new("io", "failed to persist keyring: #{e.message}")
310
+ end
311
+
312
+ def encode_keyring
313
+ buf = +"".b
314
+ buf << FieldEncryption::KEYRING_MAGIC
315
+ buf << [FieldEncryption::KEYRING_VERSION].pack("v")
316
+ buf << [@records.length].pack("v")
317
+
318
+ @records.each do |r|
319
+ id_b = r.id.encode("utf-8").b
320
+ buf << [id_b.bytesize].pack("C")
321
+ buf << id_b
322
+ buf << [r.created.to_i].pack("Q<")
323
+ flags = 0
324
+ flags |= FieldEncryption::FLAG_CURRENT if r.current
325
+ flags |= FieldEncryption::FLAG_REVOKED if r.revoked
326
+ buf << [flags].pack("C")
327
+ buf << r.material
328
+ end
329
+
330
+ buf
331
+ end
332
+ end
333
+
334
+ module FieldEncryption
335
+ PREFIX = "NSCE1."
336
+ DETERMINISTIC_PREFIX = "NSCE2."
337
+ VERSION = 1
338
+ DETERMINISTIC_VERSION = 2
339
+ SUITE_AES_256_GCM = 1
340
+ SUITE_AES_SIV = 2
341
+ KEY_SIZE = 32
342
+ MAX_KEY_ID = 64
343
+ MAX_KEYS = 64
344
+ MAX_PLAINTEXT = 1 << 20
345
+ NONCE_SIZE = 12
346
+ TAG_SIZE = 16
347
+
348
+ KEYRING_MAGIC = "NSFK".b
349
+ KEYRING_VERSION = 1
350
+ FLAG_CURRENT = 1 << 0
351
+ FLAG_REVOKED = 1 << 1
352
+
353
+ KEY_ID_RE = /\A[A-Za-z0-9._-]+\z/
354
+
355
+ class << self
356
+ def generate_field_key(id)
357
+ validate_key_id!(id)
358
+ FieldKey.new(id: id, material: SecureRandom.random_bytes(KEY_SIZE))
359
+ end
360
+
361
+ def valid_key_id?(id)
362
+ id.is_a?(String) && id.length >= 1 && id.length <= MAX_KEY_ID && KEY_ID_RE.match?(id)
363
+ end
364
+
365
+ def validate_key_id!(id)
366
+ raise Error.new("invalid_argument", "invalid field key id") unless valid_key_id?(id)
367
+ end
368
+
369
+ def encrypt(provider, database, table, column, field_type, value)
370
+ return nil if value.nil?
371
+
372
+ validate_field_type!(field_type)
373
+ plain = encode_scalar(field_type, value)
374
+ if plain.bytesize > MAX_PLAINTEXT
375
+ raise Error.new("exhausted", "plaintext exceeds field limit")
376
+ end
377
+
378
+ key = provider.current_field_key(database, table, column)
379
+ public_header = build_public_header(VERSION, SUITE_AES_256_GCM, key.id, field_type)
380
+ nonce = SecureRandom.random_bytes(NONCE_SIZE)
381
+ aad = build_aad(PREFIX, database, table, column, public_header)
382
+
383
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
384
+ cipher.encrypt
385
+ cipher.key = key.material
386
+ cipher.iv = nonce
387
+ cipher.auth_data = aad
388
+ ct = cipher.update(plain) + cipher.final
389
+ tag = cipher.auth_tag
390
+
391
+ body = public_header + nonce + ct + tag
392
+ PREFIX + Base64.urlsafe_encode64(body, padding: false)
393
+ end
394
+
395
+ def decrypt(provider, database, table, column, expected_type, ciphertext)
396
+ return nil if ciphertext.nil?
397
+
398
+ validate_field_type!(expected_type)
399
+ parsed = inspect_field(ciphertext)
400
+ if parsed[:mode] != "randomized"
401
+ raise Error.new("invalid_format", "deterministic ciphertext requires decrypt_field_deterministic")
402
+ end
403
+ if parsed[:type] != expected_type
404
+ raise Error.new("invalid_format", "encrypted logical type mismatch")
405
+ end
406
+
407
+ key = provider.field_key(database, table, column, parsed[:key_id])
408
+ body = parsed[:body]
409
+ header_len = parsed[:header_length]
410
+ public_header = body[0...(header_len - NONCE_SIZE)]
411
+ nonce = body[(header_len - NONCE_SIZE)...header_len]
412
+ ct_and_tag = body[header_len..]
413
+ tag = ct_and_tag[-TAG_SIZE..]
414
+ ct = ct_and_tag[0...-TAG_SIZE]
415
+ aad = build_aad(PREFIX, database, table, column, public_header)
416
+
417
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
418
+ cipher.decrypt
419
+ cipher.key = key.material
420
+ cipher.iv = nonce
421
+ cipher.auth_tag = tag
422
+ cipher.auth_data = aad
423
+ begin
424
+ plain = cipher.update(ct) + cipher.final
425
+ rescue OpenSSL::Cipher::CipherError
426
+ raise Error.new("crypto", "ciphertext authentication failed")
427
+ end
428
+
429
+ if plain.bytesize > MAX_PLAINTEXT
430
+ raise Error.new("invalid_format", "plaintext exceeds field limit")
431
+ end
432
+
433
+ decode_scalar(expected_type, plain)
434
+ end
435
+
436
+ def encrypt_deterministic(provider, database, table, column, field_type, value)
437
+ return nil if value.nil?
438
+
439
+ validate_field_type!(field_type)
440
+ plain = encode_scalar(field_type, value)
441
+ if plain.bytesize > MAX_PLAINTEXT
442
+ raise Error.new("exhausted", "plaintext exceeds field limit")
443
+ end
444
+
445
+ key = provider.current_field_key(database, table, column)
446
+ public_header = build_public_header(DETERMINISTIC_VERSION, SUITE_AES_SIV, key.id, field_type)
447
+ aad = build_aad(DETERMINISTIC_PREFIX, database, table, column, public_header)
448
+
449
+ derived = hkdf_sha256(key.material, "NextSQL NSCE2 AES-SIV v2".b, 32)
450
+ k1 = derived[0, 16]
451
+ k2 = derived[16, 16]
452
+
453
+ tag = s2v(k1, aad, plain)
454
+ cipher_bytes = ctr_crypt(k2, tag, plain)
455
+
456
+ body = public_header + tag + cipher_bytes
457
+ DETERMINISTIC_PREFIX + Base64.urlsafe_encode64(body, padding: false)
458
+ end
459
+
460
+ def decrypt_deterministic(provider, database, table, column, expected_type, ciphertext)
461
+ return nil if ciphertext.nil?
462
+
463
+ validate_field_type!(expected_type)
464
+ parsed = inspect_field(ciphertext)
465
+ if parsed[:mode] != "deterministic"
466
+ raise Error.new("invalid_format", "randomized ciphertext requires decrypt_field")
467
+ end
468
+ if parsed[:type] != expected_type
469
+ raise Error.new("invalid_format", "encrypted logical type mismatch")
470
+ end
471
+
472
+ key = provider.field_key(database, table, column, parsed[:key_id])
473
+ body = parsed[:body]
474
+ header_len = parsed[:header_length]
475
+ public_header = body[0...header_len]
476
+ tag = body[header_len, 16]
477
+ ct = body[(header_len + 16)..]
478
+
479
+ aad = build_aad(DETERMINISTIC_PREFIX, database, table, column, public_header)
480
+ derived = hkdf_sha256(key.material, "NextSQL NSCE2 AES-SIV v2".b, 32)
481
+ k1 = derived[0, 16]
482
+ k2 = derived[16, 16]
483
+
484
+ plain = ctr_crypt(k2, tag, ct)
485
+ expected_tag = s2v(k1, aad, plain)
486
+ unless OpenSSL.fixed_length_secure_compare(tag, expected_tag)
487
+ raise Error.new("crypto", "ciphertext authentication failed")
488
+ end
489
+
490
+ if plain.bytesize > MAX_PLAINTEXT
491
+ raise Error.new("invalid_format", "plaintext exceeds field limit")
492
+ end
493
+
494
+ decode_scalar(expected_type, plain)
495
+ end
496
+
497
+ def inspect_field(ciphertext)
498
+ raise Error.new("invalid_format", "invalid client ciphertext") unless ciphertext.is_a?(String)
499
+
500
+ if ciphertext.start_with?(PREFIX)
501
+ prefix = PREFIX
502
+ version = VERSION
503
+ suite = SUITE_AES_256_GCM
504
+ nonce_size = NONCE_SIZE
505
+ mode = "randomized"
506
+ elsif ciphertext.start_with?(DETERMINISTIC_PREFIX)
507
+ prefix = DETERMINISTIC_PREFIX
508
+ version = DETERMINISTIC_VERSION
509
+ suite = SUITE_AES_SIV
510
+ nonce_size = 0
511
+ mode = "deterministic"
512
+ else
513
+ raise Error.new("invalid_format", "invalid client ciphertext prefix")
514
+ end
515
+
516
+ encoded = ciphertext[prefix.length..]
517
+ max_encoded = ((MAX_PLAINTEXT + 101) * 4 / 3) + 4
518
+ if encoded.nil? || encoded.empty? || encoded.length > max_encoded || encoded.length % 4 == 1 || !encoded.match?(/\A[A-Za-z0-9_-]+\z/)
519
+ raise Error.new("invalid_format", "client ciphertext length out of range")
520
+ end
521
+
522
+ padded = encoded + ("=" * ((4 - (encoded.length % 4)) % 4))
523
+ begin
524
+ body = Base64.urlsafe_decode64(padded)
525
+ rescue StandardError
526
+ raise Error.new("invalid_format", "invalid client ciphertext encoding")
527
+ end
528
+
529
+ min_len = 3 + 1 + 6 + nonce_size + TAG_SIZE
530
+ if body.bytesize < min_len || body.getbyte(0) != version || body.getbyte(1) != suite
531
+ raise Error.new("invalid_format", "unsupported or truncated client ciphertext")
532
+ end
533
+
534
+ key_id_len = body.getbyte(2)
535
+ if key_id_len < 1 || key_id_len > MAX_KEY_ID || body.bytesize < 3 + key_id_len + 6 + nonce_size + TAG_SIZE
536
+ raise Error.new("invalid_format", "invalid field key id length")
537
+ end
538
+
539
+ key_id = body[3, key_id_len].force_encoding("utf-8")
540
+ raise Error.new("invalid_format", "invalid field key id") unless valid_key_id?(key_id)
541
+
542
+ off = 3 + key_id_len
543
+ kind = body.getbyte(off)
544
+ precision = body[off + 1, 2].unpack1("v")
545
+ scale = body[off + 3, 2].unpack1("v")
546
+ vec_elem = body.getbyte(off + 5)
547
+ ft = FieldType.new(kind: kind, precision: precision, scale: scale, vec_elem: vec_elem)
548
+ validate_field_type!(ft)
549
+
550
+ header_len = off + 6 + nonce_size
551
+ {
552
+ key_id: key_id,
553
+ type: ft,
554
+ body: body,
555
+ header_length: header_len,
556
+ mode: mode,
557
+ }
558
+ end
559
+
560
+ # --- internal helpers ---
561
+
562
+ def validate_field_type!(ft)
563
+ if ft.kind == Protocol::KIND_DECIMAL
564
+ if ft.precision < 1 || ft.precision > 38 || ft.scale.negative? || ft.scale > ft.precision
565
+ raise Error.new("invalid_argument", "unsupported client-encrypted type")
566
+ end
567
+ return
568
+ end
569
+
570
+ valid = [
571
+ Protocol::KIND_UUID, Protocol::KIND_STRING, Protocol::KIND_TEXT, Protocol::KIND_BLOB,
572
+ Protocol::KIND_TIMESTAMPTZ, Protocol::KIND_JSON, Protocol::KIND_BOOL,
573
+ Protocol::KIND_INT8, Protocol::KIND_INT16, Protocol::KIND_INT32, Protocol::KIND_INT64,
574
+ Protocol::KIND_UINT8, Protocol::KIND_UINT16, Protocol::KIND_UINT32, Protocol::KIND_UINT64,
575
+ ]
576
+ if !valid.include?(ft.kind) || ft.precision != 0 || ft.scale != 0 || ft.vec_elem != 0
577
+ raise Error.new("invalid_argument", "unsupported client-encrypted type")
578
+ end
579
+ end
580
+
581
+ def build_public_header(version, suite, key_id, field_type)
582
+ key_id_b = key_id.encode("utf-8").b
583
+ [version, suite, key_id_b.bytesize].pack("CCC") +
584
+ key_id_b +
585
+ [field_type.kind, field_type.precision, field_type.scale, field_type.vec_elem].pack("CvvC")
586
+ end
587
+
588
+ def build_aad(prefix, database, table, column, public_header)
589
+ db_b = validate_name!(database, "database")
590
+ tbl_b = validate_name!(table, "table")
591
+ col_b = validate_name!(column, "column")
592
+
593
+ out = prefix.encode("utf-8").b
594
+ out << [db_b.bytesize].pack("v") << db_b
595
+ out << [tbl_b.bytesize].pack("v") << tbl_b
596
+ out << [col_b.bytesize].pack("v") << col_b
597
+ out << public_header
598
+ out
599
+ end
600
+
601
+ def validate_name!(val, what)
602
+ raise Error.new("invalid_argument", "#{what} must not be empty") unless val.is_a?(String) && !val.empty?
603
+ b = val.encode("utf-8").b
604
+ raise Error.new("invalid_argument", "#{what} exceeds maximum length") if b.bytesize > 0xFFFF
605
+ b
606
+ end
607
+
608
+ def encode_scalar(ft, value)
609
+ case ft.kind
610
+ when Protocol::KIND_UUID
611
+ if value.is_a?(String)
612
+ clean = value.delete("-")
613
+ raise Error.new("invalid_argument", "invalid UUID") unless clean.match?(/\A[0-9a-fA-F]{32}\z/)
614
+ [clean].pack("H*")
615
+ elsif value.is_a?(String) && value.bytesize == 16
616
+ value.b
617
+ else
618
+ raise Error.new("invalid_argument", "invalid UUID for encrypted field")
619
+ end
620
+ when Protocol::KIND_STRING, Protocol::KIND_TEXT
621
+ raise Error.new("invalid_argument", "encrypted string value must be a string") unless value.is_a?(String)
622
+ b = value.encode("utf-8").b
623
+ [b.bytesize].pack("V") + b
624
+ when Protocol::KIND_BLOB
625
+ raise Error.new("invalid_argument", "encrypted BLOB value must be bytes") unless value.is_a?(String)
626
+ b = value.b
627
+ [b.bytesize].pack("V") + b
628
+ when Protocol::KIND_BOOL
629
+ raise Error.new("invalid_argument", "encrypted BOOL value must be boolean") unless value == true || value == false
630
+ value ? "\x01".b : "\x00".b
631
+ when Protocol::KIND_INT8
632
+ raise Error.new("invalid_argument", "encrypted int value out of range") if value < -0x80 || value > 0x7F
633
+ [value].pack("c")
634
+ when Protocol::KIND_INT16
635
+ raise Error.new("invalid_argument", "encrypted int value out of range") if value < -0x8000 || value > 0x7FFF
636
+ [value].pack("s<")
637
+ when Protocol::KIND_INT32
638
+ raise Error.new("invalid_argument", "encrypted int value out of range") if value < -0x80000000 || value > 0x7FFFFFFF
639
+ [value].pack("l<")
640
+ when Protocol::KIND_INT64
641
+ raise Error.new("invalid_argument", "encrypted int value out of range") if value < -0x8000000000000000 || value > 0x7FFFFFFFFFFFFFFF
642
+ [value].pack("q<")
643
+ when Protocol::KIND_UINT8
644
+ raise Error.new("invalid_argument", "encrypted uint value out of range") if value.negative? || value > 0xFF
645
+ [value].pack("C")
646
+ when Protocol::KIND_UINT16
647
+ raise Error.new("invalid_argument", "encrypted uint value out of range") if value.negative? || value > 0xFFFF
648
+ [value].pack("S<")
649
+ when Protocol::KIND_UINT32
650
+ raise Error.new("invalid_argument", "encrypted uint value out of range") if value.negative? || value > 0xFFFFFFFF
651
+ [value].pack("L<")
652
+ when Protocol::KIND_UINT64
653
+ raise Error.new("invalid_argument", "encrypted uint value out of range") if value.negative? || value > 0xFFFFFFFFFFFFFFFF
654
+ [value].pack("Q<")
655
+ when Protocol::KIND_TIMESTAMPTZ
656
+ if value.is_a?(Time)
657
+ ns = (value.to_r * 1_000_000_000).to_i
658
+ [ns].pack("q<")
659
+ elsif value.is_a?(Integer)
660
+ [value].pack("q<")
661
+ else
662
+ raise Error.new("invalid_argument", "encrypted TIMESTAMPTZ value must be Time or unix nanoseconds")
663
+ end
664
+ when Protocol::KIND_DECIMAL
665
+ dec_str = value.is_a?(BigDecimal) ? value.to_s("F") : value.to_s
666
+ Protocol.encode_decimal(dec_str)
667
+ when Protocol::KIND_JSON
668
+ json_str = value.is_a?(String) ? value : JSON.generate(value)
669
+ b = json_str.encode("utf-8").b
670
+ [b.bytesize].pack("V") + b
671
+ else
672
+ raise Error.new("invalid_argument", "unsupported client-encrypted type")
673
+ end
674
+ end
675
+
676
+ def decode_scalar(ft, raw)
677
+ case ft.kind
678
+ when Protocol::KIND_UUID
679
+ raise Error.new("invalid_format", "invalid encrypted UUID payload") if raw.bytesize != 16
680
+ Protocol.format_uuid(raw)
681
+ when Protocol::KIND_STRING, Protocol::KIND_TEXT
682
+ raise Error.new("invalid_format", "truncated string payload") if raw.bytesize < 4
683
+ len = raw[0, 4].unpack1("V")
684
+ raise Error.new("invalid_format", "string payload length mismatch") if raw.bytesize != 4 + len
685
+ raw[4..].force_encoding("utf-8")
686
+ when Protocol::KIND_BLOB
687
+ raise Error.new("invalid_format", "truncated blob payload") if raw.bytesize < 4
688
+ len = raw[0, 4].unpack1("V")
689
+ raise Error.new("invalid_format", "blob payload length mismatch") if raw.bytesize != 4 + len
690
+ raw[4..]
691
+ when Protocol::KIND_BOOL
692
+ raise Error.new("invalid_format", "invalid encrypted bool payload") if raw.bytesize != 1
693
+ raw.getbyte(0) != 0
694
+ when Protocol::KIND_INT8
695
+ raise Error.new("invalid_format", "invalid encrypted int payload") if raw.bytesize != 1
696
+ raw.unpack1("c")
697
+ when Protocol::KIND_INT16
698
+ raise Error.new("invalid_format", "invalid encrypted int payload") if raw.bytesize != 2
699
+ raw.unpack1("s<")
700
+ when Protocol::KIND_INT32
701
+ raise Error.new("invalid_format", "invalid encrypted int payload") if raw.bytesize != 4
702
+ raw.unpack1("l<")
703
+ when Protocol::KIND_INT64
704
+ raise Error.new("invalid_format", "invalid encrypted int payload") if raw.bytesize != 8
705
+ raw.unpack1("q<")
706
+ when Protocol::KIND_UINT8
707
+ raise Error.new("invalid_format", "invalid encrypted uint payload") if raw.bytesize != 1
708
+ raw.unpack1("C")
709
+ when Protocol::KIND_UINT16
710
+ raise Error.new("invalid_format", "invalid encrypted uint payload") if raw.bytesize != 2
711
+ raw.unpack1("S<")
712
+ when Protocol::KIND_UINT32
713
+ raise Error.new("invalid_format", "invalid encrypted uint payload") if raw.bytesize != 4
714
+ raw.unpack1("L<")
715
+ when Protocol::KIND_UINT64
716
+ raise Error.new("invalid_format", "invalid encrypted uint payload") if raw.bytesize != 8
717
+ raw.unpack1("Q<")
718
+ when Protocol::KIND_TIMESTAMPTZ
719
+ raise Error.new("invalid_format", "invalid encrypted timestamptz payload") if raw.bytesize != 8
720
+ ns = raw.unpack1("q<")
721
+ Time.at(Rational(ns, 1_000_000_000)).utc
722
+ when Protocol::KIND_DECIMAL
723
+ raise Error.new("invalid_format", "truncated decimal payload") if raw.bytesize < 4
724
+ len = raw[0, 4].unpack1("V")
725
+ raise Error.new("invalid_format", "decimal payload length mismatch") if raw.bytesize != 4 + len
726
+ Protocol.decode_decimal(raw[4..])
727
+ when Protocol::KIND_JSON
728
+ raise Error.new("invalid_format", "truncated json payload") if raw.bytesize < 4
729
+ len = raw[0, 4].unpack1("V")
730
+ raise Error.new("invalid_format", "json payload length mismatch") if raw.bytesize != 4 + len
731
+ JSON.parse(raw[4..].force_encoding("utf-8"))
732
+ else
733
+ raise Error.new("invalid_format", "unsupported client-encrypted type")
734
+ end
735
+ end
736
+
737
+ # --- RFC 5297 AES-SIV implementation ---
738
+
739
+ def hkdf_sha256(ikm, info, length = 32)
740
+ prk = OpenSSL::HMAC.digest("SHA256", "".b, ikm)
741
+ t = "".b
742
+ okm = +"".b
743
+ i = 1
744
+ while okm.bytesize < length
745
+ t = OpenSSL::HMAC.digest("SHA256", prk, t + info + [i].pack("C"))
746
+ okm << t
747
+ i += 1
748
+ end
749
+ okm[0, length]
750
+ end
751
+
752
+ def aes_block(key, block)
753
+ c = OpenSSL::Cipher.new("aes-128-ecb")
754
+ c.encrypt
755
+ c.key = key
756
+ c.padding = 0
757
+ c.update(block) + c.final
758
+ end
759
+
760
+ def dbl(block)
761
+ out = +"\x00".b * 16
762
+ carry = 0
763
+ 15.downto(0) do |i|
764
+ b = block.getbyte(i)
765
+ out.setbyte(i, ((b << 1) & 0xFF) | carry)
766
+ carry = (b >> 7) & 1
767
+ end
768
+ out.setbyte(15, out.getbyte(15) ^ 0x87) if carry != 0
769
+ out
770
+ end
771
+
772
+ def xor_16(a, b)
773
+ res = +"\x00".b * 16
774
+ 16.times { |i| res.setbyte(i, a.getbyte(i) ^ b.getbyte(i)) }
775
+ res
776
+ end
777
+
778
+ def cmac(key, message)
779
+ k1 = dbl(aes_block(key, "\x00".b * 16))
780
+ k2 = dbl(k1)
781
+ n = message.bytesize
782
+ blocks = [1, (n + 15) / 16].max
783
+ last = if n.positive? && (n % 16).zero?
784
+ xor_16(message[(n - 16)..], k1)
785
+ else
786
+ rem = n % 16
787
+ pad = message[(n - rem)..] + "\x80".b + ("\x00".b * (15 - rem))
788
+ xor_16(pad, k2)
789
+ end
790
+ y = "\x00".b * 16
791
+ (blocks - 1).times do |i|
792
+ blk = message[(i * 16), 16]
793
+ y = aes_block(key, xor_16(y, blk))
794
+ end
795
+ aes_block(key, xor_16(y, last))
796
+ end
797
+
798
+ def s2v(key, ad, plain)
799
+ d = dbl(cmac(key, "\x00".b * 16))
800
+ d = xor_16(d, cmac(key, ad))
801
+ t = if plain.bytesize >= 16
802
+ off = plain.bytesize - 16
803
+ plain[0...off] + xor_16(plain[off..], d)
804
+ else
805
+ pad = plain + "\x80".b + ("\x00".b * (15 - plain.bytesize))
806
+ xor_16(dbl(d), pad)
807
+ end
808
+ cmac(key, t)
809
+ end
810
+
811
+ def ctr_crypt(key, tag, input)
812
+ q = tag.dup
813
+ q.setbyte(8, q.getbyte(8) & 0x7F)
814
+ q.setbyte(12, q.getbyte(12) & 0x7F)
815
+ c = OpenSSL::Cipher.new("aes-128-ctr")
816
+ c.encrypt
817
+ c.key = key
818
+ c.iv = q
819
+ c.update(input) + c.final
820
+ end
821
+ end
822
+ end
823
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module NextSQL
6
+ # Thread-safe connection pool for NextSQL Ruby driver.
7
+ #
8
+ # pool = NextSQL.create_pool(cfg, min_size: 2, max_size: 10)
9
+ # pool.with do |conn|
10
+ # res = conn.exec("SELECT * FROM users")
11
+ # end
12
+ #
13
+ # # Or directly on the pool:
14
+ # pool.exec("INSERT INTO logs VALUES ($1)", ["event"])
15
+ # pool.close
16
+ class ConnectionPool
17
+ class PooledItem
18
+ attr_reader :conn
19
+ attr_accessor :last_used
20
+
21
+ def initialize(conn)
22
+ @conn = conn
23
+ @last_used = Process.clock_gettime(Process::CLOCK_MONOTONIC)
24
+ end
25
+ end
26
+
27
+ def initialize(config, min_size: 1, max_size: 10, timeout: 30.0, max_idle: 300.0, statement_cache_size: 50)
28
+ raise Error.new("invalid_argument", "min_size must be >= 0") if min_size < 0
29
+ raise Error.new("invalid_argument", "max_size must be >= 1") if max_size < 1
30
+ raise Error.new("invalid_argument", "max_size must be >= min_size") if max_size < min_size
31
+ raise Error.new("invalid_argument", "statement_cache_size must be >= 0") if statement_cache_size < 0
32
+
33
+ @config = config.dup
34
+ @config.statement_cache_size = statement_cache_size if @config.respond_to?(:statement_cache_size=)
35
+ @min_size = min_size
36
+ @max_size = max_size
37
+ @timeout = timeout
38
+ @max_idle = max_idle
39
+
40
+ @monitor = Monitor.new
41
+ @cond = @monitor.new_cond
42
+ @idle = []
43
+ @in_use = {}.compare_by_identity
44
+ @closed = false
45
+
46
+ if min_size > 0
47
+ min_size.times do
48
+ begin
49
+ conn = Connection.connect(@config)
50
+ @idle << PooledItem.new(conn)
51
+ rescue StandardError
52
+ close
53
+ raise
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ def size
60
+ @monitor.synchronize { @idle.size + @in_use.size }
61
+ end
62
+
63
+ def idle_count
64
+ @monitor.synchronize { @idle.size }
65
+ end
66
+
67
+ def in_use_count
68
+ @monitor.synchronize { @in_use.size }
69
+ end
70
+
71
+ def closed?
72
+ @monitor.synchronize { @closed }
73
+ end
74
+
75
+ # Acquires a connection from the pool, waiting up to +timeout+ seconds if
76
+ # all connections are currently in use.
77
+ def acquire(timeout: nil)
78
+ wait_time = timeout || @timeout
79
+ deadline = wait_time > 0 ? Process.clock_gettime(Process::CLOCK_MONOTONIC) + wait_time : Float::INFINITY
80
+
81
+ @monitor.synchronize do
82
+ loop do
83
+ raise Error.new("unavailable", "connection pool is closed") if @closed
84
+
85
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
86
+ while (item = @idle.pop)
87
+ if @max_idle > 0 && (now - item.last_used) > @max_idle
88
+ safe_close(item.conn)
89
+ next
90
+ end
91
+ if !alive?(item.conn)
92
+ safe_close(item.conn)
93
+ next
94
+ end
95
+ @in_use[item.conn] = item
96
+ return item.conn
97
+ end
98
+
99
+ if (@idle.size + @in_use.size) < @max_size
100
+ conn = Connection.connect(@config)
101
+ item = PooledItem.new(conn)
102
+ @in_use[conn] = item
103
+ return conn
104
+ end
105
+
106
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
107
+ if remaining <= 0
108
+ raise Error.new("resource_exhausted", "connection pool exhausted, wait timeout exceeded")
109
+ end
110
+
111
+ @cond.wait([remaining, 0.5].min)
112
+ end
113
+ end
114
+ end
115
+
116
+ # Returns a connection to the pool.
117
+ def release(conn)
118
+ @monitor.synchronize do
119
+ item = @in_use.delete(conn)
120
+ return if item.nil?
121
+
122
+ if @closed
123
+ safe_close(conn)
124
+ @cond.signal
125
+ return
126
+ end
127
+
128
+ unless clean_connection(item)
129
+ safe_close(conn)
130
+ @cond.signal
131
+ return
132
+ end
133
+
134
+ item.last_used = Process.clock_gettime(Process::CLOCK_MONOTONIC)
135
+ @idle << item
136
+ @cond.signal
137
+ end
138
+ end
139
+
140
+ # Checks out a connection for the duration of the block and guarantees its
141
+ # release even if the block raises.
142
+ def with(timeout: nil)
143
+ conn = acquire(timeout: timeout)
144
+ begin
145
+ yield conn
146
+ ensure
147
+ release(conn)
148
+ end
149
+ end
150
+
151
+ # Executes a statement on a pooled connection and returns the result.
152
+ def exec(sql, params = nil)
153
+ with { |conn| conn.exec(sql, params) }
154
+ end
155
+
156
+ # Queries a statement on a pooled connection, buffering all rows into
157
+ # a Result before releasing the connection.
158
+ def query(sql, params = nil)
159
+ with do |conn|
160
+ rows = conn.query(sql, params)
161
+ rows.collect
162
+ end
163
+ end
164
+
165
+ # Closes all idle and active connections in the pool.
166
+ def close
167
+ @monitor.synchronize do
168
+ return if @closed
169
+
170
+ @closed = true
171
+ @idle.each { |item| safe_close(item.conn) }
172
+ @idle.clear
173
+
174
+ @in_use.each_key { |conn| safe_close(conn) }
175
+ @in_use.clear
176
+
177
+ @cond.broadcast
178
+ end
179
+ end
180
+
181
+ private
182
+
183
+ def safe_close(conn)
184
+ conn.close
185
+ rescue StandardError
186
+ nil
187
+ end
188
+
189
+ def alive?(conn)
190
+ if conn.instance_variable_defined?(:@sock) && conn.instance_variable_get(:@sock).nil?
191
+ return false
192
+ end
193
+ true
194
+ rescue StandardError
195
+ true
196
+ end
197
+
198
+ def clean_connection(item)
199
+ item.conn.exec("ROLLBACK")
200
+ true
201
+ rescue StandardError
202
+ false
203
+ end
204
+ end
205
+
206
+ def self.create_pool(config, min_size: 1, max_size: 10, timeout: 30.0, max_idle: 300.0)
207
+ ConnectionPool.new(
208
+ config,
209
+ min_size: min_size,
210
+ max_size: max_size,
211
+ timeout: timeout,
212
+ max_idle: max_idle,
213
+ )
214
+ end
215
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NextSQL
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/nextsql.rb CHANGED
@@ -25,8 +25,10 @@
25
25
  require_relative "nextsql/version"
26
26
  require_relative "nextsql/errors"
27
27
  require_relative "nextsql/protocol"
28
+ require_relative "nextsql/field_encryption"
28
29
  require_relative "nextsql/client"
29
30
  require_relative "nextsql/cluster"
31
+ require_relative "nextsql/pool"
30
32
 
31
33
  module NextSQL
32
34
  READ_STRONG = Protocol::READ_STRONG
@@ -65,4 +67,9 @@ module NextSQL
65
67
  def self.connect_cluster(cfg)
66
68
  Cluster.connect(cfg)
67
69
  end
70
+
71
+ # Creates a connection pool managing a set of connections to a NextSQL server.
72
+ def self.create_pool(cfg, **kwargs)
73
+ ConnectionPool.new(cfg, **kwargs)
74
+ end
68
75
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bzync-nextsql
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rayan Reynaldo
@@ -10,8 +10,8 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: 'Native-protocol client for NextSQL: TLS 1.3, prepared statements, follower-read
13
- routing, and NSCE1 client-side field encryption. Keys and passwords are never accepted
14
- in a URL.'
13
+ routing, and NSCE1/NSCE2 client-side field encryption. Keys and passwords are never
14
+ accepted in a URL.'
15
15
  executables: []
16
16
  extensions: []
17
17
  extra_rdoc_files: []
@@ -22,6 +22,8 @@ files:
22
22
  - lib/nextsql/client.rb
23
23
  - lib/nextsql/cluster.rb
24
24
  - lib/nextsql/errors.rb
25
+ - lib/nextsql/field_encryption.rb
26
+ - lib/nextsql/pool.rb
25
27
  - lib/nextsql/protocol.rb
26
28
  - lib/nextsql/version.rb
27
29
  homepage: https://nextsql.bzync.com/docs/drivers
@@ -47,7 +49,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
47
49
  - !ruby/object:Gem::Version
48
50
  version: '0'
49
51
  requirements: []
50
- rubygems_version: 4.0.20
52
+ rubygems_version: 4.0.23
51
53
  specification_version: 4
52
54
  summary: Official NextSQL Ruby driver. Speaks the native NSQL v1 protocol.
53
55
  test_files: []