aerospike 2.19.0 → 2.26.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +354 -244
  3. data/lib/aerospike/atomic/atomic.rb +1 -1
  4. data/lib/aerospike/cdt/context.rb +137 -70
  5. data/lib/aerospike/cdt/list_return_type.rb +4 -0
  6. data/lib/aerospike/cdt/map_operation.rb +6 -6
  7. data/lib/aerospike/cdt/map_policy.rb +16 -2
  8. data/lib/aerospike/cdt/map_return_type.rb +13 -1
  9. data/lib/aerospike/client.rb +137 -115
  10. data/lib/aerospike/cluster/create_connection.rb +1 -1
  11. data/lib/aerospike/cluster.rb +41 -4
  12. data/lib/aerospike/command/admin_command.rb +368 -52
  13. data/lib/aerospike/command/batch_index_command.rb +4 -8
  14. data/lib/aerospike/command/batch_index_exists_command.rb +1 -1
  15. data/lib/aerospike/command/batch_index_node.rb +1 -1
  16. data/lib/aerospike/command/batch_item.rb +1 -1
  17. data/lib/aerospike/command/command.rb +180 -123
  18. data/lib/aerospike/command/field_type.rb +25 -24
  19. data/lib/aerospike/command/login_command.rb +164 -0
  20. data/lib/aerospike/command/multi_command.rb +25 -2
  21. data/lib/aerospike/command/operate_args.rb +99 -0
  22. data/lib/aerospike/command/operate_command.rb +6 -11
  23. data/lib/aerospike/command/read_command.rb +2 -2
  24. data/lib/aerospike/connection/authenticate.rb +36 -3
  25. data/lib/aerospike/exp/exp.rb +1329 -0
  26. data/lib/aerospike/exp/exp_bit.rb +388 -0
  27. data/lib/aerospike/exp/exp_hll.rb +169 -0
  28. data/lib/aerospike/exp/exp_list.rb +403 -0
  29. data/lib/aerospike/exp/exp_map.rb +493 -0
  30. data/lib/aerospike/exp/operation.rb +56 -0
  31. data/lib/aerospike/features.rb +22 -9
  32. data/lib/aerospike/host/parse.rb +2 -2
  33. data/lib/aerospike/key.rb +10 -1
  34. data/lib/aerospike/node/refresh/info.rb +1 -1
  35. data/lib/aerospike/node/verify/name.rb +1 -1
  36. data/lib/aerospike/node/verify/partition_generation.rb +1 -1
  37. data/lib/aerospike/node/verify/peers_generation.rb +1 -1
  38. data/lib/aerospike/node/verify/rebalance_generation.rb +1 -1
  39. data/lib/aerospike/node_validator.rb +6 -1
  40. data/lib/aerospike/operation.rb +20 -22
  41. data/lib/aerospike/policy/auth_mode.rb +36 -0
  42. data/lib/aerospike/policy/client_policy.rb +4 -1
  43. data/lib/aerospike/policy/policy.rb +29 -13
  44. data/lib/aerospike/policy/query_policy.rb +35 -2
  45. data/lib/aerospike/policy/scan_policy.rb +19 -2
  46. data/lib/aerospike/privilege.rb +133 -0
  47. data/lib/aerospike/query/filter.rb +44 -32
  48. data/lib/aerospike/query/node_partitions.rb +39 -0
  49. data/lib/aerospike/query/partition_filter.rb +66 -0
  50. data/lib/aerospike/{command/roles.rb → query/partition_status.rb} +16 -19
  51. data/lib/aerospike/query/partition_tracker.rb +347 -0
  52. data/lib/aerospike/query/query_command.rb +20 -10
  53. data/lib/aerospike/query/query_executor.rb +71 -0
  54. data/lib/aerospike/query/query_partition_command.rb +267 -0
  55. data/lib/aerospike/query/recordset.rb +9 -9
  56. data/lib/aerospike/query/scan_command.rb +3 -2
  57. data/lib/aerospike/query/scan_executor.rb +71 -0
  58. data/lib/aerospike/query/scan_partition_command.rb +49 -0
  59. data/lib/aerospike/query/statement.rb +8 -1
  60. data/lib/aerospike/query/stream_command.rb +17 -0
  61. data/lib/aerospike/result_code.rb +83 -8
  62. data/lib/aerospike/role.rb +55 -0
  63. data/lib/aerospike/task/execute_task.rb +19 -16
  64. data/lib/aerospike/task/index_task.rb +1 -1
  65. data/lib/aerospike/user_role.rb +26 -1
  66. data/lib/aerospike/utils/buffer.rb +93 -29
  67. data/lib/aerospike/utils/packer.rb +7 -6
  68. data/lib/aerospike/utils/pool.rb +1 -1
  69. data/lib/aerospike/value/particle_type.rb +1 -12
  70. data/lib/aerospike/value/value.rb +35 -60
  71. data/lib/aerospike/version.rb +1 -1
  72. data/lib/aerospike.rb +156 -136
  73. metadata +24 -6
@@ -18,24 +18,40 @@ module Aerospike
18
18
 
19
19
  private
20
20
  # Commands
21
- AUTHENTICATE = 0
22
- CREATE_USER = 1
23
- DROP_USER = 2
24
- SET_PASSWORD = 3
25
- CHANGE_PASSWORD = 4
26
- GRANT_ROLES = 5
27
- REVOKE_ROLES = 6
28
- #CREATE_ROLE = 8
29
- QUERY_USERS = 9
30
- #QUERY_ROLES = 10
21
+ AUTHENTICATE = 0
22
+ CREATE_USER = 1
23
+ DROP_USER = 2
24
+ SET_PASSWORD = 3
25
+ CHANGE_PASSWORD = 4
26
+ GRANT_ROLES = 5
27
+ REVOKE_ROLES = 6
28
+ QUERY_USERS = 9
29
+ CREATE_ROLE = 10
30
+ DROP_ROLE = 11
31
+ GRANT_PRIVILEGES = 12
32
+ REVOKE_PRIVILEGES = 13
33
+ SET_WHITELIST = 14
34
+ SET_QUOTAS = 15
35
+ QUERY_ROLES = 16
36
+ LOGIN = 20
31
37
 
32
38
  # Field IDs
33
- USER = 0
34
- PASSWORD = 1
35
- OLD_PASSWORD = 2
36
- CREDENTIAL = 3
37
- ROLES = 10
38
- #PRIVILEGES = 11
39
+ USER = 0
40
+ PASSWORD = 1
41
+ OLD_PASSWORD = 2
42
+ CREDENTIAL = 3
43
+ CLEAR_PASSWORD = 4
44
+ SESSION_TOKEN = 5
45
+ SESSION_TTL = 6
46
+ ROLES = 10
47
+ ROLE = 11
48
+ PRIVILEGES = 12
49
+ ALLOWLIST = 13
50
+ READ_QUOTA = 14
51
+ WRITE_QUOTA = 15
52
+ READ_INFO = 16
53
+ WRITE_INFO = 17
54
+ CONNECTIONS = 18
39
55
 
40
56
  # Misc
41
57
  MSG_VERSION = 2
@@ -53,28 +69,6 @@ module Aerospike
53
69
  @data_offset = 8
54
70
  end
55
71
 
56
- def authenticate(conn, user, password)
57
- begin
58
- set_authenticate(user, password)
59
- conn.write(@data_buffer, @data_offset)
60
- conn.read(@data_buffer, HEADER_SIZE)
61
-
62
- result = @data_buffer.read(RESULT_CODE)
63
- raise Exceptions::Aerospike.new(result, "Authentication failed") if result != 0
64
- ensure
65
- Buffer.put(@data_buffer)
66
- end
67
- end
68
-
69
- def set_authenticate(user, password)
70
- write_header(AUTHENTICATE, 2)
71
- write_field_str(USER, user)
72
- write_field_bytes(CREDENTIAL, password)
73
- write_size
74
-
75
- return @data_offset
76
- end
77
-
78
72
  def create_user(cluster, policy, user, password, roles)
79
73
  write_header(CREATE_USER, 3)
80
74
  write_field_str(USER, user)
@@ -118,6 +112,61 @@ module Aerospike
118
112
  execute_command(cluster, policy)
119
113
  end
120
114
 
115
+ def create_role(cluster, policy, role_name, privileges = [], allowlist = [], read_quota = 0, write_quota = 0)
116
+ field_count = 1
117
+ field_count += 1 if privileges.size > 0
118
+ field_count += 1 if allowlist.size > 0
119
+ field_count += 1 if read_quota > 0
120
+ field_count += 1 if write_quota > 0
121
+
122
+ write_header(CREATE_ROLE, field_count)
123
+ write_field_str(ROLE, role_name)
124
+
125
+ write_privileges(privileges) if privileges.size > 0
126
+ write_allowlist(allowlist) if allowlist.size > 0
127
+
128
+ write_field_uint32(READ_QUOTA, read_quota) if read_quota > 0
129
+ write_field_uint32(WRITE_QUOTA, write_quota) if write_quota > 0
130
+
131
+ execute_command(cluster, policy)
132
+ end
133
+
134
+ def drop_role(cluster, policy, role)
135
+ write_header(DROP_ROLE, 1)
136
+ write_field_str(ROLE, role)
137
+ execute_command(cluster, policy)
138
+ end
139
+
140
+ def grant_privileges(cluster, policy, role, privileges)
141
+ write_header(GRANT_PRIVILEGES, 2)
142
+ write_field_str(ROLE, role)
143
+ write_privileges(privileges)
144
+ execute_command(cluster, policy)
145
+ end
146
+
147
+ def revoke_privileges(cluster, policy, role, privileges)
148
+ write_header(REVOKE_PRIVILEGES, 2)
149
+ write_field_str(ROLE, role)
150
+ write_privileges(privileges)
151
+ execute_command(cluster, policy)
152
+ end
153
+
154
+ def set_allowlist(cluster, policy, role, allowlist = [])
155
+ field_count = 1
156
+ field_count += 1 if allowlist.size > 0
157
+ write_header(SET_WHITELIST, field_count)
158
+ write_allowlist(allowlist) if allowlist.size > 0
159
+ execute_command(cluster, policy)
160
+ end
161
+
162
+ def set_quotas(cluster, policy, role, read_quota, write_quota)
163
+ write_header(SET_QUOTAS, 3)
164
+ write_field_str(ROLE, role)
165
+ write_field_uint32(READ_QUOTA, read_quota)
166
+ write_field_uint32(WRITE_QUOTA, write_quota)
167
+ execute_command(cluster, policy)
168
+ end
169
+
121
170
  def query_user(cluster, policy, user)
122
171
  # TODO: Remove the workaround in the future
123
172
  sleep(0.010)
@@ -144,6 +193,32 @@ module Aerospike
144
193
  end
145
194
  end
146
195
 
196
+ def query_role(cluster, policy, role)
197
+ # TODO: Remove the workaround in the future
198
+ sleep(0.010)
199
+
200
+ list = []
201
+ begin
202
+ write_header(QUERY_ROLES, 1)
203
+ write_field_str(ROLE, role)
204
+ list = read_roles(cluster, policy)
205
+ return (list.is_a?(Array) && list.length > 0 ? list.first : nil)
206
+ ensure
207
+ Buffer.put(@data_buffer)
208
+ end
209
+ end
210
+
211
+ def query_roles(cluster, policy)
212
+ # TODO: Remove the workaround in the future
213
+ sleep(0.010)
214
+ begin
215
+ write_header(QUERY_ROLES, 0)
216
+ return read_roles(cluster, policy)
217
+ ensure
218
+ Buffer.put(@data_buffer)
219
+ end
220
+ end
221
+
147
222
  def write_roles(roles)
148
223
  offset = @data_offset + FIELD_HEADER_SIZE
149
224
  @data_buffer.write_byte(roles.length.ord, offset)
@@ -166,6 +241,54 @@ module Aerospike
166
241
  @data_buffer.write_int64(size, 0)
167
242
  end
168
243
 
244
+ def write_privileges(privileges)
245
+ offset = @data_offset
246
+ @data_offset += FIELD_HEADER_SIZE
247
+ write_byte(privileges.size)
248
+
249
+ for privilege in privileges
250
+ write_byte(privilege.to_code)
251
+ if privilege.can_scope?
252
+ if privilege.set_name.to_s.size > 0 && privilege.namespace.to_s.size == 0
253
+ raise Aerospike::Exceptions::Aerospike.new(Aerospike::ResultCode::INVALID_PRIVILEGE, "Admin privilege #{privilege.namespace} has a set scope with an empty namespace")
254
+ end
255
+
256
+ write_str(privilege.namespace.to_s)
257
+ write_str(privilege.set_name.to_s)
258
+ else
259
+ if privilege.set_name.to_s.bytesize > 0 || privilege.namespace.to_s.bytesize > 0
260
+ raise Aerospike::Exceptions::Aerospike.new(Aerospike::ResultCode::INVALID_PRIVILEGE, "Admin global privilege #{privilege} can't have a namespace or set")
261
+ end
262
+ end
263
+ end
264
+
265
+ size = @data_offset - offset - FIELD_HEADER_SIZE
266
+ @data_offset = offset
267
+ write_field_header(PRIVILEGES, size)
268
+ @data_offset += size
269
+ end
270
+
271
+ def write_allowlist(allowlist)
272
+ offset = @data_offset
273
+ @data_offset += FIELD_HEADER_SIZE
274
+
275
+ comma = false
276
+ for addr in allowlist
277
+ if comma
278
+ write_byte(",")
279
+ else
280
+ comma = true
281
+ end
282
+
283
+ @data_offset += @data_buffer.write_binary(addr, @data_offset)
284
+ end
285
+
286
+ size = @data_offset - offset - FIELD_HEADER_SIZE
287
+ @data_offset = offset
288
+ write_field_header(ALLOWLIST, size)
289
+ @data_offset += size
290
+ end
291
+
169
292
  def write_header(command, field_count)
170
293
  # Authenticate header is almost all zeros
171
294
  i = @data_offset
@@ -178,12 +301,27 @@ module Aerospike
178
301
  @data_offset += 16
179
302
  end
180
303
 
304
+ def write_byte(b)
305
+ @data_offset += @data_buffer.write_byte(b, @data_offset)
306
+ end
307
+
308
+ def write_str(str)
309
+ @data_offset += @data_buffer.write_byte(str.bytesize, @data_offset)
310
+ @data_offset += @data_buffer.write_binary(str, @data_offset)
311
+ end
312
+
181
313
  def write_field_str(id, str)
182
314
  len = @data_buffer.write_binary(str, @data_offset+FIELD_HEADER_SIZE)
183
315
  write_field_header(id, len)
184
316
  @data_offset += len
185
317
  end
186
318
 
319
+ def write_field_uint32(id, val)
320
+ len = @data_buffer.write_uint32(val, @data_offset+FIELD_HEADER_SIZE)
321
+ write_field_header(id, len)
322
+ @data_offset += len
323
+ end
324
+
187
325
  def write_field_bytes(id, bytes)
188
326
  @data_buffer.write_binary(bytes, @data_offset+FIELD_HEADER_SIZE)
189
327
  write_field_header(id, bytes.bytesize)
@@ -243,7 +381,7 @@ module Aerospike
243
381
  raise e
244
382
  end
245
383
 
246
- raise Exceptions::Aerospike.new(result) if status > 0
384
+ raise Exceptions::Aerospike.new(status) if status > 0
247
385
 
248
386
  return list
249
387
  end
@@ -284,7 +422,7 @@ module Aerospike
284
422
  return (result_code == QUERY_END ? -1 : result_code)
285
423
  end
286
424
 
287
- userRoles = UserRoles.new
425
+ user_roles = UserRoles.new
288
426
  field_count = @data_buffer.read(@data_offset+3)
289
427
  @data_offset += HEADER_REMAINING
290
428
 
@@ -298,10 +436,17 @@ module Aerospike
298
436
 
299
437
  case id
300
438
  when USER
301
- userRoles.user = @data_buffer.read(@data_offset, len)
439
+ user_roles.user = @data_buffer.read(@data_offset, len)
302
440
  @data_offset += len
303
441
  when ROLES
304
- parse_roles(userRoles)
442
+ parse_roles(user_roles)
443
+ when READ_INFO
444
+ user_roles.read_info = parse_info
445
+ when WRITE_INFO
446
+ user_roles.write_info = parse_info
447
+ when CONNECTIONS
448
+ user_roles.conns_in_use = @data_buffer.read_int32(@data_offset)
449
+ @data_offset += len
305
450
  else
306
451
  @data_offset += len
307
452
  end
@@ -309,19 +454,19 @@ module Aerospike
309
454
  i = i.succ
310
455
  end
311
456
 
312
- next if userRoles.user == "" && userRoles.roles == nil
457
+ next if user_roles.user == "" && user_roles.roles == nil
313
458
 
314
- userRoles.roles = [] if userRoles.roles == nil
315
- list << userRoles
459
+ user_roles.roles = [] if user_roles.roles == nil
460
+ list << user_roles
316
461
  end
317
462
 
318
463
  return 0, list
319
464
  end
320
465
 
321
- def parse_roles(userRoles)
466
+ def parse_roles(user_roles)
322
467
  size = @data_buffer.read(@data_offset)
323
468
  @data_offset += 1
324
- userRoles.roles = []
469
+ user_roles.roles = []
325
470
 
326
471
  i = 0
327
472
  while i < size
@@ -329,17 +474,188 @@ module Aerospike
329
474
  @data_offset += 1
330
475
  role = @data_buffer.read(@data_offset, len)
331
476
  @data_offset += len
332
- userRoles.roles << role
477
+ user_roles.roles << role
333
478
 
334
479
  i = i.succ
335
480
  end
336
481
  end
337
482
 
338
- SALT = '$2a$10$7EqJtq98hPqEX7fNZaFWoO'
339
- def self.hash_password(password)
340
- # Hashing the password with the cost of 10, with a static salt
341
- return BCrypt::Engine.hash_secret(password, SALT, :cost => 10)
483
+ def parse_info
484
+ size = @data_buffer.read(@data_offset)
485
+ @data_offset += 1
486
+ list = []
487
+
488
+ i = 0
489
+ while i < size
490
+ val = @data_buffer.read_int32(@data_offset)
491
+ @data_offset += 4
492
+ list << val
493
+
494
+ i = i.succ
495
+ end
496
+
497
+ list
342
498
  end
499
+
500
+ def read_roles(cluster, policy)
501
+ write_size
502
+ node = cluster.random_node
503
+
504
+ timeout = 1
505
+ timeout = policy.timeout if policy != nil && policy.timeout > 0
506
+
507
+ status = -1
508
+ list = []
509
+ begin
510
+ conn = node.get_connection(timeout)
511
+ conn.write(@data_buffer, @data_offset)
512
+ status, list = read_role_blocks(conn)
513
+ node.put_connection(conn)
514
+ rescue => e
515
+ conn.close if conn
516
+ raise e
517
+ end
518
+
519
+ raise Exceptions::Aerospike.new(status) if status > 0
520
+
521
+ return list
522
+ end
523
+
524
+ def read_role_blocks(conn)
525
+ rlist = []
526
+ status = 0
527
+ begin
528
+ while status == 0
529
+ conn.read(@data_buffer, 8)
530
+ size = @data_buffer.read_int64(0)
531
+ receive_size = (size & 0xFFFFFFFFFFFF)
532
+
533
+ if receive_size > 0
534
+ @data_buffer.resize(receive_size) if receive_size > @data_buffer.size
535
+
536
+ conn.read(@data_buffer, receive_size)
537
+ status, list = parse_roles_full(receive_size)
538
+ rlist.concat(list.to_a)
539
+ else
540
+ break
541
+ end
542
+ end
543
+ return status, rlist
544
+ rescue => e
545
+ return -1, []
546
+ end
547
+ end
548
+
549
+ def parse_roles_full(receive_size)
550
+ @data_offset = 0
551
+ list = []
552
+
553
+ while @data_offset < receive_size
554
+ result_code = @data_buffer.read(@data_offset+1)
555
+
556
+ if result_code != 0
557
+ return (result_code == QUERY_END ? -1 : result_code)
558
+ end
559
+
560
+ role = Role.new
561
+ field_count = @data_buffer.read(@data_offset+3)
562
+ @data_offset += HEADER_REMAINING
563
+
564
+ i = 0
565
+ while i < field_count
566
+ len = @data_buffer.read_int32(@data_offset)
567
+ @data_offset += 4
568
+ id = @data_buffer.read(@data_offset)
569
+ @data_offset += 1
570
+ len -= 1
571
+
572
+ case id
573
+ when ROLE
574
+ role.name = @data_buffer.read(@data_offset, len).to_s
575
+ @data_offset += len
576
+ when PRIVILEGES
577
+ parse_privileges(role)
578
+ when ALLOWLIST
579
+ role.allowlist = parse_allowlist(len)
580
+ when READ_QUOTA
581
+ role.read_quota = @data_buffer.read_uint32(@data_offset)
582
+ @data_offset += len
583
+ when WRITE_QUOTA
584
+ role.write_quota = @data_buffer.read_uint32(@data_offset)
585
+ @data_offset += len
586
+ else
587
+ @data_offset += len
588
+ end
589
+
590
+ i = i.succ
591
+ end
592
+
593
+ next if role.name == "" && role.privileges == nil
594
+
595
+ role.privileges ||= []
596
+ list << role
597
+ end
598
+
599
+ return 0, list
600
+ end
601
+
602
+ def parse_privileges(role)
603
+ size = @data_buffer.read(@data_offset)
604
+ @data_offset += 1
605
+ role.privileges = []
606
+
607
+ i = 0
608
+ while i < size
609
+ priv = Privilege.new
610
+ priv.code = Privilege.from(@data_buffer.read(@data_offset))
611
+ @data_offset += 1
612
+
613
+ if priv.can_scope?
614
+ len = @data_buffer.read(@data_offset)
615
+ @data_offset += 1
616
+ priv.namespace = @data_buffer.read(@data_offset, len)
617
+ @data_offset += len
618
+
619
+ len = @data_buffer.read(@data_offset)
620
+ @data_offset += 1
621
+ priv.set_name = @data_buffer.read(@data_offset, len)
622
+ @data_offset += len
623
+ end
624
+
625
+ role.privileges << priv
626
+
627
+ i = i.succ
628
+ end
629
+ end
630
+
631
+ def parse_allowlist(len)
632
+ list = []
633
+ begn = @data_offset
634
+ max = begn + len
635
+
636
+ while @data_offset < max
637
+ if @data_buffer.read(@data_offset) == ','
638
+ l = @data_offset - begn
639
+ if l > 0
640
+ s = @data_buffer.read(begn, l)
641
+ list << s
642
+ end
643
+ @data_offset += 1
644
+ begn = @data_offset
645
+ else
646
+ @data_offset += 1
647
+ end
648
+ end
649
+
650
+ l = @data_offset - begn
651
+ if l > 0
652
+ s = @data_buffer.read(begn, l)
653
+ list << s
654
+ end
655
+
656
+ list
657
+ end
658
+
343
659
  end
344
660
  end
345
661
 
@@ -110,16 +110,12 @@ module Aerospike
110
110
  field_count = @data_buffer.read_int16(18)
111
111
  op_count = @data_buffer.read_int16(20)
112
112
 
113
- key = parse_key(field_count)
113
+ skip_key(field_count)
114
114
  req_key = batch.key_for_index(batch_index)
115
115
 
116
- if key.digest == req_key.digest
117
- if result_code == 0
118
- record = parse_record(req_key, op_count, generation, expiration)
119
- results[batch_index] = record
120
- end
121
- else
122
- Aerospike.logger.warn("Unexpected batch key returned: #{key}")
116
+ if result_code == 0
117
+ record = parse_record(req_key, op_count, generation, expiration)
118
+ results[batch_index] = record
123
119
  end
124
120
  end
125
121
 
@@ -36,7 +36,7 @@ module Aerospike
36
36
  raise Aerospike::Exceptions::Parse.new('Received bins that were not requested!')
37
37
  end
38
38
 
39
- parse_key(field_count)
39
+ skip_key(field_count)
40
40
  results[batch_index] = (result_code == 0)
41
41
  end
42
42
 
@@ -30,7 +30,7 @@ module Aerospike
30
30
 
31
31
  def initialize(node, keys_with_idx)
32
32
  @node = node
33
- @keys_by_idx = Hash[keys_with_idx.map(&:reverse)]
33
+ @keys_by_idx = keys_with_idx.map(&:reverse).to_h
34
34
  end
35
35
 
36
36
  def keys
@@ -28,7 +28,7 @@ module Aerospike
28
28
  .map { |key, keys_with_idx|
29
29
  [key.digest, BatchItem.new(key, keys_with_idx.map(&:last))]
30
30
  }
31
- Hash[map]
31
+ map.to_h
32
32
  end
33
33
 
34
34
  def initialize(key, indexes)