-
Notifications
You must be signed in to change notification settings - Fork 12
feat(api): support bulk elasticsearch operations #470
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
cccs-hxp
wants to merge
24
commits into
develop
Choose a base branch
from
bulk_create_hits
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 16 commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
fecaa00
copy bulk operation from assemblyline
cccs-hxp 0cd4857
update hit and analytic services to use bulk
cccs-hxp 4ae82b5
fix hit.id -> hit.howler.id and add bulk overwrite in remove_bundle_c…
cccs-hxp f221cfd
replace blanket forwarding tests with checks on all written indexes f…
cccs-hxp 1c5e8d6
move search outside loop when creating hits to reduce setup time
cccs-hxp e3d079c
update bundle hit with child hits in bulk in remove_bundle_children
cccs-hxp 2d96752
return success from bulk function instead of entire response
cccs-hxp 9a735ea
add index operation to bulk plan and use wherever save was used
cccs-hxp b8ddd64
terminate plan data in newline character
cccs-hxp d5054cf
ruff format file
cccs-hxp 4c01fed
seed and reset random state as per function fixture
cccs-hxp a19c9cb
fix typing
cccs-hxp cec62e4
add batch support to bulk plan
cccs-hxp e1299a9
use batch iterator in bulk function and update docstring
cccs-hxp 3f7490f
fix typing
cccs-hxp a41debf
change cases to test both when ops divisible and not divisible by bat…
cccs-hxp 90acc96
move constants from bulk plan class to config
cccs-hxp 54be7ed
change debug constant to use env and replace does_hit_exist
cccs-hxp db8c5c3
ruff format file
cccs-hxp 7373bda
move max size and batch size configs from host to datastore
cccs-hxp ff1c651
update docstring for refresh support in hit endpoints
cccs-hxp 2afaf19
Merge remote-tracking branch 'origin/develop' into bulk_create_hits
cccs-hxp 62285fe
ignore __index as allowed extra field in model constructor
cccs-hxp 35d28af
fix merge pass refresh to save_from_hits in tool
cccs-hxp File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,72 +1,145 @@ | ||
| import json | ||
| import warnings | ||
| from copy import deepcopy | ||
| from typing import List, Optional | ||
|
|
||
| from howler import odm | ||
|
|
||
| _OPERATION_GROUP = tuple[str] | tuple[str, str] | ||
|
|
||
|
|
||
| class ElasticBulkPlan(object): | ||
| def __init__(self, indexes, model=None): | ||
| ELASTIC_MAX_REQUEST_SIZE = 100_000_000 # 100MB | ||
|
cccs-mdr marked this conversation as resolved.
Outdated
|
||
| DEFAULT_BATCH_SIZE = 500 | ||
|
|
||
| def __init__(self, indexes: List[str], model: Optional[type[odm.Model]] = None): | ||
| self.indexes = indexes | ||
| self.model = model | ||
| self.operations = [] | ||
| self.operations: list[_OPERATION_GROUP] = [] | ||
|
|
||
| @property | ||
| def empty(self): | ||
| return len(self.operations) == 0 | ||
|
|
||
| def add_delete_operation(self, doc_id, index=None): | ||
| if index: | ||
| self.operations.append(json.dumps({"delete": {"_index": index, "_id": doc_id}})) | ||
| self.operations.append((json.dumps({"delete": {"_index": index, "_id": doc_id}}),)) | ||
| else: | ||
| for cur_index in self.indexes: | ||
| self.operations.append(json.dumps({"delete": {"_index": cur_index, "_id": doc_id}})) | ||
| self.operations.append((json.dumps({"delete": {"_index": cur_index, "_id": doc_id}}),)) | ||
|
|
||
| def add_insert_operation(self, doc_id: str, doc, index=None): | ||
| if self.model and isinstance(doc, self.model): | ||
| saved_doc = doc.as_primitives(hidden_fields=True) | ||
| elif self.model: | ||
| saved_doc = self.model(doc).as_primitives(hidden_fields=True) | ||
| else: | ||
| if not isinstance(doc, dict): | ||
| saved_doc = {"__non_doc_raw__": doc} | ||
| else: | ||
| saved_doc = deepcopy(doc) | ||
| saved_doc["id"] = doc_id | ||
|
|
||
| def add_insert_operation(self, doc_id, doc, index=None): | ||
| if isinstance(doc, self.model): | ||
| saved_doc = doc.as_primitives(hidden_fields=True) # type: ignore | ||
| self.operations.append( | ||
| ( | ||
| json.dumps({"create": {"_index": index or self.indexes[0], "_id": doc_id}}), | ||
| json.dumps(saved_doc), | ||
| ) | ||
| ) | ||
|
|
||
| def add_index_operation(self, doc_id, doc, index=None): | ||
| if self.model and isinstance(doc, self.model): | ||
| saved_doc = doc.as_primitives(hidden_fields=True) | ||
| elif self.model: | ||
| saved_doc = self.model(doc).as_primitives(hidden_fields=True) # type: ignore | ||
| saved_doc = self.model(doc).as_primitives(hidden_fields=True) | ||
| else: | ||
| if not isinstance(doc, dict): | ||
| saved_doc = {"__non_doc_raw__": doc} | ||
| else: | ||
| saved_doc = deepcopy(doc) # type: ignore | ||
| saved_doc = deepcopy(doc) | ||
| saved_doc["id"] = doc_id | ||
|
|
||
| self.operations.append(json.dumps({"create": {"_index": index or self.indexes[0], "_id": doc_id}})) | ||
| self.operations.append(json.dumps(saved_doc)) | ||
| self.operations.append( | ||
| ( | ||
| json.dumps({"index": {"_index": index or self.indexes[0], "_id": doc_id}}), | ||
| json.dumps(saved_doc), | ||
| ) | ||
| ) | ||
|
|
||
| def add_upsert_operation(self, doc_id, doc, index=None): | ||
| if isinstance(doc, self.model): | ||
| saved_doc = doc.as_primitives(hidden_fields=True) # type: ignore | ||
| if self.model and isinstance(doc, self.model): | ||
| saved_doc = doc.as_primitives(hidden_fields=True) | ||
| elif self.model: | ||
| saved_doc = self.model(doc).as_primitives(hidden_fields=True) # type: ignore | ||
| saved_doc = self.model(doc).as_primitives(hidden_fields=True) | ||
| else: | ||
| if not isinstance(doc, dict): | ||
| saved_doc = {"__non_doc_raw__": doc} # type: ignore | ||
| saved_doc = {"__non_doc_raw__": doc} | ||
| else: | ||
| saved_doc = deepcopy(doc) # type: ignore | ||
| saved_doc = deepcopy(doc) | ||
| saved_doc["id"] = doc_id | ||
|
|
||
| self.operations.append(json.dumps({"update": {"_index": index or self.indexes[0], "_id": doc_id}})) | ||
| self.operations.append(json.dumps({"doc": saved_doc, "doc_as_upsert": True})) | ||
| self.operations.append( | ||
| ( | ||
| json.dumps({"update": {"_index": index or self.indexes[0], "_id": doc_id}}), | ||
| json.dumps({"doc": saved_doc, "doc_as_upsert": True}), | ||
| ) | ||
| ) | ||
|
|
||
| def add_update_operation(self, doc_id, doc, index=None): | ||
| if isinstance(doc, self.model): | ||
| saved_doc = doc.as_primitives(hidden_fields=True) # type: ignore | ||
| if self.model and isinstance(doc, self.model): | ||
| saved_doc = doc.as_primitives(hidden_fields=True) | ||
| elif self.model: | ||
| saved_doc = self.model(doc, mask=list(doc.keys())).as_primitives(hidden_fields=True) # type: ignore | ||
| saved_doc = self.model(doc, mask=list(doc.keys())).as_primitives(hidden_fields=True) | ||
| else: | ||
| if not isinstance(doc, dict): | ||
| saved_doc = {"__non_doc_raw__": doc} # type: ignore | ||
| saved_doc = {"__non_doc_raw__": doc} | ||
| else: | ||
| saved_doc = deepcopy(doc) # type: ignore | ||
| saved_doc = deepcopy(doc) | ||
|
|
||
| if index: | ||
| self.operations.append(json.dumps({"update": {"_index": index, "_id": doc_id}})) | ||
| self.operations.append(json.dumps({"doc": saved_doc})) | ||
| self.operations.append( | ||
| ( | ||
| json.dumps({"update": {"_index": index, "_id": doc_id}}), | ||
| json.dumps({"doc": saved_doc}), | ||
| ) | ||
| ) | ||
| else: | ||
| for cur_index in self.indexes: | ||
| self.operations.append(json.dumps({"update": {"_index": cur_index, "_id": doc_id}})) | ||
| self.operations.append(json.dumps({"doc": saved_doc})) | ||
| self.operations.append( | ||
| ( | ||
| json.dumps({"update": {"_index": cur_index, "_id": doc_id}}), | ||
| json.dumps({"doc": saved_doc}), | ||
| ) | ||
| ) | ||
|
|
||
| def get_plan_data(self): | ||
| return "\n".join(self.operations) | ||
| """Construct the bulk request from the current operations""" | ||
| return self._get_plan_for_operations() | ||
|
|
||
| def get_plan_batches(self, batch_size: int = DEFAULT_BATCH_SIZE): | ||
| """Yield plan data in batches""" | ||
| for ptr in range(0, len(self.operations), batch_size): | ||
| yield self._get_plan_for_operations(self.operations[ptr : ptr + batch_size]) | ||
|
|
||
| def _flatten_operations(self, batch: List[_OPERATION_GROUP] | None = None) -> List[str]: | ||
| """Flatten the (operation, data) tuples for a batch or the full list of operations if no batch provided""" | ||
| if not batch: | ||
| batch = self.operations | ||
|
|
||
| flattened: list[str] = [] | ||
| for op in batch: | ||
| flattened.extend(op) | ||
|
|
||
| return flattened | ||
|
|
||
| def _get_plan_for_operations(self, batch: List[_OPERATION_GROUP] | None = None) -> str: | ||
| """Get the bulk plan string for a batch or the full list of operations if no batch provided""" | ||
| plan = "\n".join(self._flatten_operations(batch)) + "\n" | ||
|
|
||
| if len(plan.encode("utf-8")) > self.ELASTIC_MAX_REQUEST_SIZE: | ||
| warnings.warn( | ||
| f"Bulk plan exceeds maximum request size of {self.ELASTIC_MAX_REQUEST_SIZE} bytes. " | ||
| f"Current size: {len(plan.encode('utf-8'))} bytes." | ||
| ) | ||
|
|
||
| return plan | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.