nucliadb 6.9.1.post5192__py3-none-any.whl → 6.9.1.post5208__py3-none-any.whl

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.

Potentially problematic release.


This version of nucliadb might be problematic. Click here for more details.

@@ -18,165 +18,494 @@
18
18
  # along with this program. If not, see <http://www.gnu.org/licenses/>.
19
19
  #
20
20
  import asyncio
21
+ import dataclasses
21
22
  import logging
23
+ import math
24
+ import random
25
+ from typing import Optional
22
26
 
27
+ import aioitertools
28
+ from grpc import StatusCode
29
+ from grpc.aio import AioRpcError
23
30
  from nidx_protos import nodereader_pb2, noderesources_pb2
24
31
 
25
32
  from nucliadb.common import datamanagers, locking
26
33
  from nucliadb.common.cluster.utils import get_shard_manager
27
34
  from nucliadb.common.context import ApplicationContext
35
+ from nucliadb.common.datamanagers.resources import KB_RESOURCE_SHARD
28
36
  from nucliadb.common.nidx import get_nidx_api_client, get_nidx_searcher_client
37
+ from nucliadb_protos import writer_pb2
29
38
  from nucliadb_telemetry import errors
30
39
  from nucliadb_telemetry.logs import setup_logging
31
40
  from nucliadb_telemetry.utils import setup_telemetry
41
+ from nucliadb_utils import const
32
42
  from nucliadb_utils.fastapi.run import serve_metrics
43
+ from nucliadb_utils.utilities import has_feature
33
44
 
34
45
  from .settings import settings
35
- from .utils import delete_resource_from_shard, index_resource_to_shard
46
+ from .utils import delete_resource_from_shard, index_resource_to_shard, wait_for_nidx
36
47
 
37
48
  logger = logging.getLogger(__name__)
38
49
 
39
50
  REBALANCE_LOCK = "rebalance"
40
51
 
41
-
42
- async def get_shards_paragraphs(kbid: str) -> list[tuple[str, int]]:
43
- """
44
- Ordered shard -> num paragraph by number of paragraphs
45
- """
46
- async with datamanagers.with_ro_transaction() as txn:
47
- kb_shards = await datamanagers.cluster.get_kb_shards(txn, kbid=kbid)
48
- if kb_shards is None:
49
- return []
50
-
51
- results = {}
52
- for shard_meta in kb_shards.shards:
53
- # Rebalance using node as source of truth. But it will rebalance nidx
54
- shard_data: nodereader_pb2.Shard = await get_nidx_api_client().GetShard(
55
- nodereader_pb2.GetShardRequest(
56
- shard_id=noderesources_pb2.ShardId(id=shard_meta.nidx_shard_id)
57
- ) # type: ignore
52
+ MAX_MOVES_PER_SHARD = 100
53
+
54
+
55
+ @dataclasses.dataclass
56
+ class RebalanceShard:
57
+ id: str
58
+ nidx_id: str
59
+ paragraphs: int
60
+ active: bool
61
+
62
+ def to_dict(self):
63
+ return self.__dict__
64
+
65
+
66
+ class Rebalancer:
67
+ def __init__(self, context: ApplicationContext, kbid: str):
68
+ self.context = context
69
+ self.kbid = kbid
70
+ self.kb_shards: Optional[writer_pb2.Shards] = None
71
+ self.index: dict[str, set[str]] = {}
72
+
73
+ async def get_rebalance_shards(self) -> list[RebalanceShard]:
74
+ """
75
+ Return the sorted list of shards by increasing paragraph count.
76
+ """
77
+ self.kb_shards = await datamanagers.atomic.cluster.get_kb_shards(kbid=self.kbid)
78
+ if self.kb_shards is None: # pragma: no cover
79
+ return []
80
+ return list(
81
+ sorted(
82
+ [
83
+ RebalanceShard(
84
+ id=shard.shard,
85
+ nidx_id=shard.nidx_shard_id,
86
+ paragraphs=await get_shard_paragraph_count(shard.nidx_shard_id),
87
+ active=(idx == self.kb_shards.actual),
88
+ )
89
+ for idx, shard in enumerate(self.kb_shards.shards)
90
+ ],
91
+ key=lambda x: x.paragraphs,
92
+ )
58
93
  )
59
- results[shard_meta.shard] = shard_data.paragraphs
60
94
 
61
- return [(shard, paragraphs) for shard, paragraphs in sorted(results.items(), key=lambda x: x[1])]
95
+ async def build_shard_resources_index(self):
96
+ async with datamanagers.with_ro_transaction() as txn:
97
+ iterable = datamanagers.resources.iterate_resource_ids(kbid=self.kbid)
98
+ async for resources_batch in aioitertools.batched(iterable, n=200):
99
+ shards = await txn.batch_get(
100
+ keys=[KB_RESOURCE_SHARD.format(kbid=self.kbid, uuid=rid) for rid in resources_batch],
101
+ for_update=False,
102
+ )
103
+ for rid, shard_bytes in zip(resources_batch, shards):
104
+ if shard_bytes is not None:
105
+ self.index.setdefault(shard_bytes.decode(), set()).add(rid)
106
+
107
+ async def move_paragraphs(
108
+ self, from_shard: RebalanceShard, to_shard: RebalanceShard, max_paragraphs: int
109
+ ) -> int:
110
+ """
111
+ Takes random resources from the source shard and tries to move at most max_paragraphs.
112
+ It stops moving paragraphs until the are no more resources to move.
113
+ """
114
+ moved_paragraphs = 0
115
+
116
+ while moved_paragraphs < max_paragraphs:
117
+ # Take a random resource to move
118
+ try:
119
+ resource_id = random.choice(tuple(self.index[from_shard.id]))
120
+ except (KeyError, IndexError):
121
+ # No more resources in shard or shard not found
122
+ break
123
+
124
+ assert self.kb_shards is not None
125
+ from_shard_obj = next(s for s in self.kb_shards.shards if s.shard == from_shard.id)
126
+ to_shard_obj = next(s for s in self.kb_shards.shards if s.shard == to_shard.id)
127
+ paragraphs_count = await get_resource_paragraphs_count(resource_id, from_shard.nidx_id)
128
+ moved = await move_resource_to_shard(
129
+ self.context, self.kbid, resource_id, from_shard_obj, to_shard_obj
130
+ )
131
+ if moved:
132
+ self.index[from_shard.id].remove(resource_id)
133
+ self.index.setdefault(to_shard.id, set()).add(resource_id)
134
+ moved_paragraphs += paragraphs_count
62
135
 
136
+ return moved_paragraphs
63
137
 
64
- async def maybe_add_shard(kbid: str) -> None:
65
- async with locking.distributed_lock(locking.NEW_SHARD_LOCK.format(kbid=kbid)):
66
- async with datamanagers.with_ro_transaction() as txn:
67
- kb_shards = await datamanagers.cluster.get_kb_shards(txn, kbid=kbid)
68
- if kb_shards is None:
138
+ async def wait_for_indexing(self):
139
+ try:
140
+ self.context.nats_manager
141
+ except AssertionError: # pragma: no cover
142
+ logger.warning(f"Nats manager not initialized. Cannot wait for indexing")
69
143
  return
144
+ while True:
145
+ try:
146
+ await wait_for_nidx(self.context.nats_manager, max_wait_seconds=60, max_pending=1000)
147
+ return
148
+ except asyncio.TimeoutError:
149
+ logger.warning("Nidx is behind. Backing off rebalancing.", extra={"kbid": self.kbid})
150
+ await asyncio.sleep(30)
151
+
152
+ async def rebalance_shards(self):
153
+ """
154
+ Iterate over shards until none of them need more rebalancing.
155
+
156
+ Will move excess of paragraphs to other shards (potentially creating new ones), and
157
+ merge small shards together when possible (potentially deleting empty ones.)
158
+
159
+
160
+ Merge chooses a <90% filled shard and fills it to almost 100%
161
+ Split chooses a >110% filled shard and reduces it to 100%
162
+ If the shard is between 90% and 110% full, nobody touches it
163
+ """
164
+ await self.build_shard_resources_index()
165
+ while True:
166
+ await self.wait_for_indexing()
167
+
168
+ shards = await self.get_rebalance_shards()
169
+
170
+ # Any shards to split?
171
+ shard_to_split = next((s for s in shards[::-1] if needs_split(s)), None)
172
+ if shard_to_split is not None:
173
+ await self.split_shard(shard_to_split, shards)
174
+ continue
175
+
176
+ # Any shards to merge?
177
+ shard_to_merge = next((s for s in shards if needs_merge(s, shards)), None)
178
+ if shard_to_merge is not None:
179
+ await self.merge_shard(shard_to_merge, shards)
180
+ else:
181
+ break
182
+
183
+ async def split_shard(self, shard_to_split: RebalanceShard, shards: list[RebalanceShard]):
184
+ logger.info(
185
+ "Splitting excess of paragraphs to other shards",
186
+ extra={
187
+ "kbid": self.kbid,
188
+ "shard": shard_to_split.to_dict(),
189
+ },
190
+ )
70
191
 
71
- shard_paragraphs = await get_shards_paragraphs(kbid)
72
- total_paragraphs = sum([c for _, c in shard_paragraphs])
73
-
74
- if (total_paragraphs / len(kb_shards.shards)) > (
75
- settings.max_shard_paragraphs * 0.9 # 90% of the max
76
- ):
77
- # create new shard
78
- async with datamanagers.with_rw_transaction() as txn:
79
- kb_config = await datamanagers.kb.get_config(txn, kbid=kbid)
192
+ # First off, calculate if the excess fits in the other shards or we need to add a new shard.
193
+ # Note that we don't filter out the active shard on purpose.
194
+ excess = shard_to_split.paragraphs - settings.max_shard_paragraphs
195
+ other_shards = [s for s in shards if s.id != shard_to_split.id]
196
+ other_shards_capacity = sum(
197
+ [max(0, (settings.max_shard_paragraphs - s.paragraphs)) for s in other_shards]
198
+ )
199
+ if excess > other_shards_capacity:
200
+ shards_to_add = math.ceil((excess - other_shards_capacity) / settings.max_shard_paragraphs)
201
+ logger.info(
202
+ "More shards needed",
203
+ extra={
204
+ "kbid": self.kbid,
205
+ "shards_to_add": shards_to_add,
206
+ "all_shards": [s.to_dict() for s in shards],
207
+ },
208
+ )
209
+ # Add new shards where to rebalance the excess of paragraphs
210
+ async with (
211
+ locking.distributed_lock(locking.NEW_SHARD_LOCK.format(kbid=self.kbid)),
212
+ datamanagers.with_rw_transaction() as txn,
213
+ ):
214
+ kb_config = await datamanagers.kb.get_config(txn, kbid=self.kbid)
80
215
  prewarm = kb_config is not None and kb_config.prewarm_enabled
81
-
82
216
  sm = get_shard_manager()
83
- await sm.create_shard_by_kbid(txn, kbid, prewarm_enabled=prewarm)
217
+ for _ in range(shards_to_add):
218
+ await sm.create_shard_by_kbid(txn, self.kbid, prewarm_enabled=prewarm)
84
219
  await txn.commit()
85
220
 
221
+ # Recalculate after having created shards, the active shard is a different one
222
+ shards = await self.get_rebalance_shards()
223
+
224
+ # Now, move resources to other shards as long as we are still over the max
225
+ for _ in range(MAX_MOVES_PER_SHARD):
226
+ shard_paragraphs = next(s.paragraphs for s in shards if s.id == shard_to_split.id)
227
+ excess = shard_paragraphs - settings.max_shard_paragraphs
228
+ if excess <= 0:
229
+ logger.info(
230
+ "Shard rebalanced successfuly",
231
+ extra={"kbid": self.kbid, "shard": shard_to_split.to_dict()},
232
+ )
233
+ break
234
+
235
+ target_shard, target_capacity = get_target_shard(shards, shard_to_split, skip_active=False)
236
+ if target_shard is None:
237
+ logger.warning("No target shard found for splitting", extra={"kbid": self.kbid})
238
+ break
86
239
 
87
- async def move_set_of_kb_resources(
88
- context: ApplicationContext,
89
- kbid: str,
90
- from_shard_id: str,
91
- to_shard_id: str,
92
- count: int = 20,
93
- ) -> None:
94
- async with datamanagers.with_ro_transaction() as txn:
95
- kb_shards = await datamanagers.cluster.get_kb_shards(txn, kbid=kbid)
96
- if kb_shards is None: # pragma: no cover
97
- logger.warning("No shards found for kb. This should not happen.", extra={"kbid": kbid})
98
- return
240
+ moved_paragraphs = await self.move_paragraphs(
241
+ from_shard=shard_to_split,
242
+ to_shard=target_shard,
243
+ max_paragraphs=min(excess, target_capacity),
244
+ )
99
245
 
100
- logger.info(
101
- "Rebalancing kb shards",
102
- extra={"kbid": kbid, "from": from_shard_id, "to": to_shard_id, "count": count},
103
- )
246
+ # Update shard paragraph counts
247
+ shard_to_split.paragraphs -= moved_paragraphs
248
+ target_shard.paragraphs += moved_paragraphs
249
+ shards.sort(key=lambda x: x.paragraphs)
104
250
 
105
- from_shard = [s for s in kb_shards.shards if s.shard == from_shard_id][0]
106
- to_shard = [s for s in kb_shards.shards if s.shard == to_shard_id][0]
251
+ await self.wait_for_indexing()
107
252
 
108
- request = nodereader_pb2.SearchRequest(
109
- shard=from_shard.nidx_shard_id,
110
- paragraph=False,
111
- document=True,
112
- result_per_page=count,
113
- )
114
- request.field_filter.field.field_type = "a"
115
- request.field_filter.field.field_id = "title"
116
- search_response: nodereader_pb2.SearchResponse = await get_nidx_searcher_client().Search(request)
253
+ async def merge_shard(self, shard_to_merge: RebalanceShard, shards: list[RebalanceShard]):
254
+ logger.info(
255
+ "Merging shard",
256
+ extra={
257
+ "kbid": self.kbid,
258
+ "shard": shard_to_merge.to_dict(),
259
+ },
260
+ )
261
+ empty_shard = False
262
+
263
+ for _ in range(MAX_MOVES_PER_SHARD):
264
+ resources_count = len(self.index.get(shard_to_merge.id, []))
265
+ if resources_count == 0:
266
+ logger.info(
267
+ "Shard is now empty",
268
+ extra={
269
+ "kbid": self.kbid,
270
+ "shard": shard_to_merge.to_dict(),
271
+ },
272
+ )
273
+ empty_shard = True
274
+ break
275
+
276
+ logger.info(
277
+ "Shard not yet empty",
278
+ extra={
279
+ "kbid": self.kbid,
280
+ "shard": shard_to_merge.to_dict(),
281
+ "remaining": resources_count,
282
+ },
283
+ )
117
284
 
118
- for result in search_response.document.results:
119
- resource_id = result.uuid
120
- try:
121
- async with (
122
- datamanagers.with_transaction() as txn,
123
- locking.distributed_lock(
124
- locking.RESOURCE_INDEX_LOCK.format(kbid=kbid, resource_id=resource_id)
125
- ),
126
- ):
127
- found_shard_id = await datamanagers.resources.get_resource_shard_id(
128
- txn, kbid=kbid, rid=resource_id, for_update=True
285
+ target_shard, target_capacity = get_target_shard(shards, shard_to_merge, skip_active=True)
286
+ if target_shard is None:
287
+ logger.warning(
288
+ "No target shard could be found for merging. Moving on",
289
+ extra={"kbid": self.kbid, "shard": shard_to_merge.to_dict()},
129
290
  )
130
- if found_shard_id is None:
131
- # resource deleted
132
- continue
133
- if found_shard_id != from_shard_id:
134
- # resource could have already been moved
135
- continue
291
+ break
136
292
 
137
- await datamanagers.resources.set_resource_shard_id(
138
- txn, kbid=kbid, rid=resource_id, shard=to_shard_id
293
+ moved_paragraphs = await self.move_paragraphs(
294
+ from_shard=shard_to_merge,
295
+ to_shard=target_shard,
296
+ max_paragraphs=target_capacity,
297
+ )
298
+
299
+ # Update shard paragraph counts
300
+ shard_to_merge.paragraphs -= moved_paragraphs
301
+ target_shard.paragraphs += moved_paragraphs
302
+ shards.sort(key=lambda x: x.paragraphs)
303
+
304
+ await self.wait_for_indexing()
305
+
306
+ if empty_shard:
307
+ # Build the index again, and make sure there is no resource assigned to this shard
308
+ await self.build_shard_resources_index()
309
+ shard_resources = self.index.get(shard_to_merge.id, set())
310
+ if len(shard_resources) > 0:
311
+ logger.error(
312
+ f"Shard expected to be empty, but it isn't. Won't be deleted.",
313
+ extra={
314
+ "kbid": self.kbid,
315
+ "shard": shard_to_merge.id,
316
+ "resources": list(shard_resources)[:30],
317
+ },
139
318
  )
140
- await index_resource_to_shard(context, kbid, resource_id, to_shard)
141
- await delete_resource_from_shard(context, kbid, resource_id, from_shard)
142
- await txn.commit()
319
+ return
320
+
321
+ # If shard was emptied, delete it
322
+ async with locking.distributed_lock(locking.NEW_SHARD_LOCK.format(kbid=self.kbid)):
323
+ async with datamanagers.with_rw_transaction() as txn:
324
+ kb_shards = await datamanagers.cluster.get_kb_shards(
325
+ txn, kbid=self.kbid, for_update=True
326
+ )
327
+ if kb_shards is not None:
328
+ logger.info(
329
+ "Deleting empty shard",
330
+ extra={
331
+ "kbid": self.kbid,
332
+ "shard_id": shard_to_merge.id,
333
+ "nidx_shard_id": shard_to_merge.nidx_id,
334
+ },
335
+ )
336
+
337
+ # Delete shards from kb shards in maindb
338
+ to_delete, to_delete_idx = next(
339
+ (s, idx)
340
+ for idx, s in enumerate(kb_shards.shards)
341
+ if s.shard == shard_to_merge.id
342
+ )
343
+ kb_shards.shards.remove(to_delete)
344
+ if to_delete_idx <= kb_shards.actual:
345
+ # Only decrement the actual pointer if we remove before the pointer.
346
+ kb_shards.actual -= 1
347
+ assert kb_shards.actual >= 0
348
+ await datamanagers.cluster.update_kb_shards(
349
+ txn, kbid=self.kbid, shards=kb_shards
350
+ )
351
+ await txn.commit()
352
+
353
+ # Delete shard from nidx
354
+ await get_nidx_api_client().DeleteShard(
355
+ noderesources_pb2.ShardId(id=to_delete.nidx_shard_id)
356
+ )
357
+
358
+
359
+ async def get_resource_paragraphs_count(resource_id: str, nidx_shard_id: str) -> int:
360
+ # Do a search on the fields (paragraph) index and return the number of paragraphs this resource has
361
+ try:
362
+ request = nodereader_pb2.SearchRequest(
363
+ shard=nidx_shard_id,
364
+ paragraph=True,
365
+ document=False,
366
+ result_per_page=0,
367
+ field_filter=nodereader_pb2.FilterExpression(
368
+ resource=nodereader_pb2.FilterExpression.ResourceFilter(resource_id=resource_id)
369
+ ),
370
+ )
371
+ search_response: nodereader_pb2.SearchResponse = await get_nidx_searcher_client().Search(request)
372
+ return search_response.paragraph.total
373
+ except AioRpcError as exc: # pragma: no cover
374
+ if exc.code() == StatusCode.NOT_FOUND:
375
+ logger.warning(f"Shard not found in nidx", extra={"nidx_shard_id": nidx_shard_id})
376
+ return 0
377
+ raise
378
+
379
+
380
+ def get_target_shard(
381
+ shards: list[RebalanceShard], rebalanced_shard: RebalanceShard, skip_active: bool = True
382
+ ) -> tuple[Optional[RebalanceShard], int]:
383
+ """
384
+ Return the biggest shard with capacity (< 90% of the max paragraphs per shard).
385
+ """
386
+ target_shard = next(
387
+ reversed(
388
+ [
389
+ s
390
+ for s in shards
391
+ if s.id != rebalanced_shard.id
392
+ and s.paragraphs < settings.max_shard_paragraphs * 0.9
393
+ and (not skip_active or (skip_active and not s.active))
394
+ ]
395
+ ),
396
+ None,
397
+ )
398
+ if target_shard is None: # pragma: no cover
399
+ return None, 0
400
+
401
+ # Aim to fill target shards up to 100% of max
402
+ capacity = int(max(0, settings.max_shard_paragraphs - target_shard.paragraphs))
403
+ return target_shard, capacity
404
+
405
+
406
+ async def get_shard_paragraph_count(nidx_shard_id: str) -> int:
407
+ # Do a search on the fields (paragraph) index
408
+ try:
409
+ request = nodereader_pb2.SearchRequest(
410
+ shard=nidx_shard_id,
411
+ paragraph=True,
412
+ document=False,
413
+ result_per_page=0,
414
+ )
415
+ search_response: nodereader_pb2.SearchResponse = await get_nidx_searcher_client().Search(request)
416
+ return search_response.paragraph.total
417
+ except AioRpcError as exc: # pragma: no cover
418
+ if exc.code() == StatusCode.NOT_FOUND:
419
+ logger.warning(f"Shard not found in nidx", extra={"nidx_shard_id": nidx_shard_id})
420
+ return 0
421
+ raise
422
+
423
+
424
+ async def move_resource_to_shard(
425
+ context: ApplicationContext,
426
+ kbid: str,
427
+ resource_id: str,
428
+ from_shard: writer_pb2.ShardObject,
429
+ to_shard: writer_pb2.ShardObject,
430
+ ) -> bool:
431
+ indexed_to_new = False
432
+ deleted_from_old = False
433
+ try:
434
+ async with (
435
+ datamanagers.with_transaction() as txn,
436
+ locking.distributed_lock(
437
+ locking.RESOURCE_INDEX_LOCK.format(kbid=kbid, resource_id=resource_id)
438
+ ),
439
+ ):
440
+ found_shard_id = await datamanagers.resources.get_resource_shard_id(
441
+ txn, kbid=kbid, rid=resource_id, for_update=True
442
+ )
443
+ if found_shard_id is None: # pragma: no cover
444
+ # resource deleted
445
+ return False
446
+ if found_shard_id != from_shard.shard: # pragma: no cover
447
+ # resource could have already been moved
448
+ return False
449
+
450
+ await datamanagers.resources.set_resource_shard_id(
451
+ txn, kbid=kbid, rid=resource_id, shard=to_shard.shard
452
+ )
453
+ await index_resource_to_shard(context, kbid, resource_id, to_shard)
454
+ indexed_to_new = True
455
+ await delete_resource_from_shard(context, kbid, resource_id, from_shard)
456
+ deleted_from_old = True
457
+ await txn.commit()
458
+ return True
459
+ except Exception:
460
+ logger.exception(
461
+ "Failed to move resource",
462
+ extra={"kbid": kbid, "resource_id": resource_id},
463
+ )
464
+ # XXX Not ideal failure situation here. Try reverting the whole move even though it could be redundant
465
+ try:
466
+ if indexed_to_new:
467
+ await delete_resource_from_shard(context, kbid, resource_id, to_shard)
468
+ if deleted_from_old:
469
+ await index_resource_to_shard(context, kbid, resource_id, from_shard)
143
470
  except Exception:
144
471
  logger.exception(
145
- "Failed to move resource",
472
+ "Failed to revert move resource. Hopefully you never see this message.",
146
473
  extra={"kbid": kbid, "resource_id": resource_id},
147
474
  )
148
- # XXX Not ideal failure situation here. Try reverting the whole move even though it could be redundant
149
- try:
150
- await index_resource_to_shard(context, kbid, resource_id, from_shard)
151
- await delete_resource_from_shard(context, kbid, resource_id, to_shard)
152
- except Exception:
153
- logger.exception(
154
- "Failed to revert move resource. Hopefully you never see this message.",
155
- extra={"kbid": kbid, "resource_id": resource_id},
156
- )
475
+ return False
157
476
 
158
477
 
159
- async def rebalance_kb(context: ApplicationContext, kbid: str) -> None:
160
- await maybe_add_shard(kbid)
478
+ def needs_split(shard: RebalanceShard) -> bool:
479
+ """
480
+ Return true if the shard is more than 110% of the max.
481
+
482
+ Active shards are not considered for splitting: the shard creator subscriber will
483
+ eventually create a new shard, make it the active one and the previous one, if
484
+ too full, will be split.
485
+ """
486
+ return not shard.active and (shard.paragraphs > (settings.max_shard_paragraphs * 1.1))
161
487
 
162
- shard_paragraphs = await get_shards_paragraphs(kbid)
163
- rebalanced_shards = set()
164
- while any(paragraphs > settings.max_shard_paragraphs for _, paragraphs in shard_paragraphs):
165
- # find the shard with the least/most paragraphs
166
- smallest_shard = shard_paragraphs[0][0]
167
- largest_shard = shard_paragraphs[-1][0]
168
- assert smallest_shard != largest_shard
169
488
 
170
- if smallest_shard in rebalanced_shards:
171
- # XXX This is to prevent flapping data between shards on a single pass
172
- # if we already rebalanced this shard, then we can't do anything else
173
- break
489
+ def needs_merge(shard: RebalanceShard, all_shards: list[RebalanceShard]) -> bool:
490
+ """
491
+ Returns true if a shard is less 75% full and there is enough capacity on the other shards to fit it.
174
492
 
175
- await move_set_of_kb_resources(context, kbid, largest_shard, smallest_shard)
493
+ Active shards are not considered for merging. Shards that are more than 75% full are also skipped.
494
+ """
495
+ if shard.active:
496
+ return False
497
+ if shard.paragraphs > (settings.max_shard_paragraphs * 0.75):
498
+ return False
499
+ other_shards = [s for s in all_shards if s.id != shard.id and not s.active]
500
+ other_shards_capacity = sum(
501
+ [max(0, (settings.max_shard_paragraphs - s.paragraphs)) for s in other_shards]
502
+ )
503
+ return shard.paragraphs < other_shards_capacity
176
504
 
177
- rebalanced_shards.add(largest_shard)
178
505
 
179
- shard_paragraphs = await get_shards_paragraphs(kbid)
506
+ async def rebalance_kb(context: ApplicationContext, kbid: str) -> None:
507
+ rebalancer = Rebalancer(context, kbid)
508
+ await rebalancer.rebalance_shards()
180
509
 
181
510
 
182
511
  async def run(context: ApplicationContext) -> None:
@@ -185,8 +514,12 @@ async def run(context: ApplicationContext) -> None:
185
514
  # get all kb ids
186
515
  async with datamanagers.with_ro_transaction() as txn:
187
516
  kbids = [kbid async for kbid, _ in datamanagers.kb.get_kbs(txn)]
188
- # go through each kb and see if shards need to be reduced in size
517
+ # go through each kb and see if shards need to be rebalanced
189
518
  for kbid in kbids:
519
+ if not has_feature(
520
+ const.Features.REBALANCE_ENABLED, default=False, context={"kbid": kbid}
521
+ ):
522
+ continue
190
523
  async with locking.distributed_lock(locking.KB_SHARDS_LOCK.format(kbid=kbid)):
191
524
  await rebalance_kb(context, kbid)
192
525
  except locking.ResourceLocked as exc:
@@ -47,6 +47,7 @@ from .utils import (
47
47
  get_resource,
48
48
  get_rollover_resource_index_message,
49
49
  index_resource_to_shard,
50
+ wait_for_nidx,
50
51
  )
51
52
 
52
53
  logger = logging.getLogger(__name__)
@@ -256,6 +257,7 @@ async def index_to_rollover_index(
256
257
  for rid in resource_ids
257
258
  ]
258
259
  await asyncio.gather(*batch)
260
+ await wait_for_indexing_to_catch_up(app_context)
259
261
 
260
262
  async with datamanagers.with_transaction() as txn:
261
263
  state.resources_indexed = True
@@ -264,6 +266,22 @@ async def index_to_rollover_index(
264
266
  await txn.commit()
265
267
 
266
268
 
269
+ async def wait_for_indexing_to_catch_up(app_context: ApplicationContext):
270
+ try:
271
+ app_context.nats_manager
272
+ except AssertionError:
273
+ logger.warning("Nats manager not initialized. Cannot wait for indexing to catch up")
274
+ return
275
+ max_pending = 1000
276
+ while True:
277
+ try:
278
+ await wait_for_nidx(app_context.nats_manager, max_wait_seconds=60, max_pending=max_pending)
279
+ return
280
+ except asyncio.TimeoutError:
281
+ logger.warning(f"Nidx is behind more than {max_pending} messages. Throttling rollover.")
282
+ await asyncio.sleep(30)
283
+
284
+
267
285
  async def _index_resource_to_rollover_index(
268
286
  app_context: ApplicationContext,
269
287
  rollover_shards: writer_pb2.Shards,
@@ -32,6 +32,7 @@ from nucliadb.common.cluster.settings import settings
32
32
  from nucliadb.ingest.orm import index_message
33
33
  from nucliadb.ingest.orm.resource import Resource
34
34
  from nucliadb_protos import writer_pb2
35
+ from nucliadb_utils.nats import NatsConnectionManager
35
36
  from nucliadb_utils.utilities import Utility, clean_utility, get_utility, set_utility
36
37
 
37
38
  if TYPE_CHECKING: # pragma: no cover
@@ -125,3 +126,28 @@ async def delete_resource_from_shard(
125
126
  partition = partitioning.generate_partition(kbid, resource_id)
126
127
 
127
128
  await sm.delete_resource(shard, resource_id, 0, str(partition), kbid)
129
+
130
+
131
+ async def get_nats_consumer_pending_messages(
132
+ nats_manager: NatsConnectionManager, *, stream: str, consumer: str
133
+ ) -> int:
134
+ # get raw js client
135
+ js = nats_manager.js
136
+ consumer_info = await js.consumer_info(stream, consumer)
137
+ return consumer_info.num_pending
138
+
139
+
140
+ async def wait_for_nidx(
141
+ nats_manager: NatsConnectionManager,
142
+ max_pending: int,
143
+ poll_interval_seconds: int = 5,
144
+ max_wait_seconds: int = 60,
145
+ ):
146
+ async with asyncio.timeout(max_wait_seconds): # type: ignore
147
+ while True:
148
+ pending = await get_nats_consumer_pending_messages(
149
+ nats_manager, stream="nidx", consumer="nidx"
150
+ )
151
+ if pending < max_pending:
152
+ return
153
+ await asyncio.sleep(poll_interval_seconds)
@@ -446,26 +446,27 @@ class Processor:
446
446
  # a resource was move to another shard while it was being indexed
447
447
  shard_id = await datamanagers.resources.get_resource_shard_id(txn, kbid=kbid, rid=uuid)
448
448
 
449
- shard = None
450
- if shard_id is not None:
451
- # Resource already has a shard assigned
452
- shard = await kb.get_resource_shard(shard_id)
453
- if shard is None:
454
- raise AttributeError("Shard not available")
455
- else:
456
- # It's a new resource, get KB's current active shard to place new resource on
457
- shard = await self.index_node_shard_manager.get_current_active_shard(txn, kbid)
458
- if shard is None:
459
- # No current shard available, create a new one
460
- kb_config = await datamanagers.kb.get_config(txn, kbid=kbid)
461
- prewarm = kb_config is not None and kb_config.prewarm_enabled
462
- shard = await self.index_node_shard_manager.create_shard_by_kbid(
463
- txn, kbid, prewarm_enabled=prewarm
449
+ shard = None
450
+ if shard_id is not None:
451
+ # Resource already has a shard assigned
452
+ shard = await kb.get_resource_shard(shard_id)
453
+ if shard is None:
454
+ raise AttributeError("Shard not available")
455
+ else:
456
+ # It's a new resource, get KB's current active shard to place new resource on
457
+ shard = await self.index_node_shard_manager.get_current_active_shard(txn, kbid)
458
+ if shard is None:
459
+ # No current shard available, create a new one
460
+ async with locking.distributed_lock(locking.NEW_SHARD_LOCK.format(kbid=kbid)):
461
+ kb_config = await datamanagers.kb.get_config(txn, kbid=kbid)
462
+ prewarm = kb_config is not None and kb_config.prewarm_enabled
463
+ shard = await self.index_node_shard_manager.create_shard_by_kbid(
464
+ txn, kbid, prewarm_enabled=prewarm
465
+ )
466
+ await datamanagers.resources.set_resource_shard_id(
467
+ txn, kbid=kbid, rid=uuid, shard=shard.shard
464
468
  )
465
- await datamanagers.resources.set_resource_shard_id(
466
- txn, kbid=kbid, rid=uuid, shard=shard.shard
467
- )
468
- return shard
469
+ return shard
469
470
 
470
471
  @processor_observer.wrap({"type": "index_resource"})
471
472
  async def index_resource(
@@ -19,6 +19,7 @@
19
19
  #
20
20
  import asyncio
21
21
  import importlib.metadata
22
+ from itertools import batched # type: ignore
22
23
  from typing import AsyncGenerator
23
24
 
24
25
  from nucliadb.common import datamanagers
@@ -233,7 +234,7 @@ async def purge_kb_vectorsets(driver: Driver, storage: Storage):
233
234
  fields.extend((await resource.get_fields(force=True)).values())
234
235
 
235
236
  logger.info(f"Purging {len(fields)} fields for vectorset {vectorset}", extra={"kbid": kbid})
236
- for fields_batch in batchify(fields, 20):
237
+ for fields_batch in batched(fields, n=20):
237
238
  tasks = []
238
239
  for field in fields_batch:
239
240
  if purge_payload.storage_key_kind == VectorSetConfig.StorageKeyKind.UNSET:
@@ -317,9 +318,3 @@ def run() -> int: # pragma: no cover
317
318
  setup_logging()
318
319
  errors.setup_error_handling(importlib.metadata.distribution("nucliadb").version)
319
320
  return asyncio.run(main())
320
-
321
-
322
- def batchify(iterable, n=1):
323
- """Yield successive n-sized chunks from iterable."""
324
- for i in range(0, len(iterable), n):
325
- yield iterable[i : i + n]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nucliadb
3
- Version: 6.9.1.post5192
3
+ Version: 6.9.1.post5208
4
4
  Summary: NucliaDB
5
5
  Author-email: Nuclia <nucliadb@nuclia.com>
6
6
  License-Expression: AGPL-3.0-or-later
@@ -19,11 +19,11 @@ Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Programming Language :: Python :: 3 :: Only
20
20
  Requires-Python: <4,>=3.9
21
21
  Description-Content-Type: text/markdown
22
- Requires-Dist: nucliadb-telemetry[all]>=6.9.1.post5192
23
- Requires-Dist: nucliadb-utils[cache,fastapi,storages]>=6.9.1.post5192
24
- Requires-Dist: nucliadb-protos>=6.9.1.post5192
25
- Requires-Dist: nucliadb-models>=6.9.1.post5192
26
- Requires-Dist: nidx-protos>=6.9.1.post5192
22
+ Requires-Dist: nucliadb-telemetry[all]>=6.9.1.post5208
23
+ Requires-Dist: nucliadb-utils[cache,fastapi,storages]>=6.9.1.post5208
24
+ Requires-Dist: nucliadb-protos>=6.9.1.post5208
25
+ Requires-Dist: nucliadb-models>=6.9.1.post5208
26
+ Requires-Dist: nidx-protos>=6.9.1.post5208
27
27
  Requires-Dist: nucliadb-admin-assets>=1.0.0.post1224
28
28
  Requires-Dist: nuclia-models>=0.50.0
29
29
  Requires-Dist: uvicorn[standard]
@@ -86,10 +86,10 @@ nucliadb/common/cluster/__init__.py,sha256=cp15ZcFnHvpcu_5-aK2A4uUyvuZVV_MJn4bIX
86
86
  nucliadb/common/cluster/exceptions.py,sha256=t7v_l93t44l2tQpdQXgO_w-c4YZRcaayOz1A2i0w4RQ,1258
87
87
  nucliadb/common/cluster/grpc_node_dummy.py,sha256=JkufazWzMA4KFEU8EBkMbiiDW4C8lLcRhiiCxP7aCQY,2949
88
88
  nucliadb/common/cluster/manager.py,sha256=p-haaGEnCa-20t-I2XEo4QJ5ZC1QgJ6p2jzXFYVB6nQ,12346
89
- nucliadb/common/cluster/rebalance.py,sha256=U-LgmDZq7JaEKh4fruUoW8VrqAAnpQrdwqN_p0tbCKo,8899
90
- nucliadb/common/cluster/rollover.py,sha256=i_W5_ds_5c_SZWxQGg4I8c_d7tP2VEPSnCVq5HU55jI,26490
89
+ nucliadb/common/cluster/rebalance.py,sha256=s0YJ07Y358T9x22QnHHEvhmHqf1CnQWQD6oJznOo2Xc,22239
90
+ nucliadb/common/cluster/rollover.py,sha256=kmVCdyjJ1dilnSodHMqf0EUkTjPC5H0aA4JqW9KsEa4,27168
91
91
  nucliadb/common/cluster/settings.py,sha256=f6Y5K0PGahkedwe5wtkWMnbqwDFJgOOwX_MOIGwH9Dg,2271
92
- nucliadb/common/cluster/utils.py,sha256=IfW5PA7Ab26xWUYNOc3yBoksWV1GK4BGhTRo1vnHNKg,4662
92
+ nucliadb/common/cluster/utils.py,sha256=E4GqidwTKczJX_lTnncBCof2fL4CFVVF1eLiz9NWjlc,5494
93
93
  nucliadb/common/cluster/standalone/__init__.py,sha256=itSI7dtTwFP55YMX4iK7JzdMHS5CQVUiB1XzQu4UBh8,833
94
94
  nucliadb/common/cluster/standalone/utils.py,sha256=af3r-x_GF7A6dwIAhZLR-r-SZQEVxsFrDKeMfUTA6G0,1908
95
95
  nucliadb/common/context/__init__.py,sha256=IKAHuiCjbOEsqfLozWwJ6mRFzFncsZMyxNC5E_XZ5EM,6016
@@ -174,7 +174,7 @@ nucliadb/ingest/orm/utils.py,sha256=fCQRuyecgqhaY7mcBG93oaXMkzkKb9BFjOcy4-ZiSNw,
174
174
  nucliadb/ingest/orm/processor/__init__.py,sha256=xhDNKCxY0XNOlIVKEtM8QT75vDUkJIt7K-_VgGbbOQU,904
175
175
  nucliadb/ingest/orm/processor/auditing.py,sha256=gxn5v30KVaH0TnIjo715mWjzKGJ-DMviElEXJG9BNN4,4612
176
176
  nucliadb/ingest/orm/processor/data_augmentation.py,sha256=v-pj4GbBWSuO8dQyahs5UDr5ghsyfhCZDS0ftKd6ZYc,5179
177
- nucliadb/ingest/orm/processor/processor.py,sha256=t9k0AxKRC3w5btv_e4Xw93vMwWCtEytWDV1kxFXCtbk,31609
177
+ nucliadb/ingest/orm/processor/processor.py,sha256=S7myNucnK8v_5y4gZxS_5nL-glGx_fZe2l8gSzDy2CU,31808
178
178
  nucliadb/ingest/orm/processor/sequence_manager.py,sha256=kUH0bCuM6NqpA0xSwfyb9igig3Btu57pc8VYnKggqx4,1693
179
179
  nucliadb/ingest/service/__init__.py,sha256=LHQFUkdmNBOWqBG0Md9sMMI7g5TQZ-hLAnhw6ZblrJg,2002
180
180
  nucliadb/ingest/service/exceptions.py,sha256=cp15ZcFnHvpcu_5-aK2A4uUyvuZVV_MJn4bIXMa20ks,835
@@ -193,7 +193,7 @@ nucliadb/models/__init__.py,sha256=cp15ZcFnHvpcu_5-aK2A4uUyvuZVV_MJn4bIXMa20ks,8
193
193
  nucliadb/models/responses.py,sha256=qnuOoc7TrVSUnpikfTwHLKez47_DE4mSFzpxrwtqijA,1599
194
194
  nucliadb/models/internal/__init__.py,sha256=cp15ZcFnHvpcu_5-aK2A4uUyvuZVV_MJn4bIXMa20ks,835
195
195
  nucliadb/models/internal/processing.py,sha256=F_WpDItHsBWuMNeLMxXOKqpZoRYdjPuB2l0s29PVcfI,4213
196
- nucliadb/purge/__init__.py,sha256=FWqY2ln2ecGgVcZaMhfs8nTt4HzpprubkCsurSrIrC8,13070
196
+ nucliadb/purge/__init__.py,sha256=RFqMj3JRML2sGrQtmRm2fzRg350f0PhkBpZh-KRWnlc,12954
197
197
  nucliadb/purge/orphan_shards.py,sha256=5QvTBoYJ2h14TUvJmZxphHJPf_WB-VVC9453n-zjSrU,7747
198
198
  nucliadb/reader/__init__.py,sha256=C5Efic7WlGm2U2C5WOyquMFbIj2Pojwe_8mwzVYnOzE,1304
199
199
  nucliadb/reader/app.py,sha256=Se-BFTE6d1v1msLzQn4q5XIhjnSxa2ckDSHdvm7NRf8,3096
@@ -385,8 +385,8 @@ nucliadb/writer/tus/local.py,sha256=7jYa_w9b-N90jWgN2sQKkNcomqn6JMVBOVeDOVYJHto,
385
385
  nucliadb/writer/tus/s3.py,sha256=vu1BGg4VqJ_x2P1u2BxqPKlSfw5orT_a3R-Ln5oPUpU,8483
386
386
  nucliadb/writer/tus/storage.py,sha256=ToqwjoYnjI4oIcwzkhha_MPxi-k4Jk3Lt55zRwaC1SM,2903
387
387
  nucliadb/writer/tus/utils.py,sha256=MSdVbRsRSZVdkaum69_0wku7X3p5wlZf4nr6E0GMKbw,2556
388
- nucliadb-6.9.1.post5192.dist-info/METADATA,sha256=dS74zqOkgFw6qgTfAPpcIj1yfslFWNp1X9233WPKPMk,4158
389
- nucliadb-6.9.1.post5192.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
390
- nucliadb-6.9.1.post5192.dist-info/entry_points.txt,sha256=XqGfgFDuY3zXQc8ewXM2TRVjTModIq851zOsgrmaXx4,1268
391
- nucliadb-6.9.1.post5192.dist-info/top_level.txt,sha256=hwYhTVnX7jkQ9gJCkVrbqEG1M4lT2F_iPQND1fCzF80,20
392
- nucliadb-6.9.1.post5192.dist-info/RECORD,,
388
+ nucliadb-6.9.1.post5208.dist-info/METADATA,sha256=SyOTfLr01715WH-X7Zg6ejg73H-4ImjqnB8hwg9QpPs,4158
389
+ nucliadb-6.9.1.post5208.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
390
+ nucliadb-6.9.1.post5208.dist-info/entry_points.txt,sha256=XqGfgFDuY3zXQc8ewXM2TRVjTModIq851zOsgrmaXx4,1268
391
+ nucliadb-6.9.1.post5208.dist-info/top_level.txt,sha256=hwYhTVnX7jkQ9gJCkVrbqEG1M4lT2F_iPQND1fCzF80,20
392
+ nucliadb-6.9.1.post5208.dist-info/RECORD,,