Skip to content

feat(api): support bulk elasticsearch operations#470

Open
cccs-hxp wants to merge 24 commits into
developfrom
bulk_create_hits
Open

feat(api): support bulk elasticsearch operations#470
cccs-hxp wants to merge 24 commits into
developfrom
bulk_create_hits

Conversation

@cccs-hxp

@cccs-hxp cccs-hxp commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Add functionality to our interface to elasticsearch to support bulk operations through the ElasticBulkPlan class and use a bulk request for endpoints that load or update multiple hits.

Changes:

  • update typing in ElasticBulkPlan
  • copy a modified version of ESCollection.bulk and ESCollection.get_bulk_plan from assemblyline, updated to use Howler's collection's index list property
  • update the analytic_service save_from_hits to use a bulk request
  • add bulk create and overwrite functions to the hit_service
  • update tests
    • Remove the test that refresh is forwarded in all write endpoints. The test can easily break if the request spec changes for any of the endpoints and the value of ensuring future changes don't forget to pass the refresh parameter along can be verified through reviewers.
    • Add tests for the endpoints which perform multiple writes or writes to multiple indexes and ensure that refresh is properly forwarded to each one

Copilot AI review requested due to automatic review settings July 3, 2026 12:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds bulk-write support to Howler’s Elasticsearch datastore layer and updates hit/analytic write paths to use bulk operations for multi-document workflows, with integration tests validating refresh forwarding behavior.

Changes:

  • Introduce ElasticBulkPlan and add ESCollection.bulk() / ESCollection.get_bulk_plan() to execute bulk plans.
  • Update hit and tool APIs/services to create/overwrite multiple hits via bulk requests and update analytics via a single bulk request.
  • Expand/adjust integration tests to cover bulk behavior and ensure refresh is forwarded correctly across multi-write endpoints.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
api/howler/datastore/bulk.py Adds typing improvements to ElasticBulkPlan used to build bulk NDJSON operations.
api/howler/datastore/collection.py Adds bulk() execution and get_bulk_plan() factory on ESCollection.
api/howler/services/analytic_service.py Switches analytic updates from per-doc saves to a bulk upsert plan.
api/howler/services/hit_service.py Adds bulk create_hits and overwrite_hits helpers.
api/howler/api/v1/hit.py Uses bulk hit creation and bulk overwrite for bundle-related multi-hit writes; adds debug refresh forcing.
api/howler/api/v1/tool.py Parses refresh and uses bulk hit creation for tool hit ingestion; adds debug refresh forcing.
api/howler/utils/constants.py Introduces DEBUG_FORCE_REFRESH toggle (currently defaulting to enabled).
api/test/integration/test_datastore.py Adds an integration test exercising bulk delete/insert plans.
api/test/integration/api/test_wait_for_refresh.py Refactors refresh-forwarding tests using spy wrappers to validate refresh across multi-index/multi-write endpoints.

Comment thread api/howler/utils/constants.py Outdated
Comment thread api/howler/services/hit_service.py
Comment thread api/howler/services/analytic_service.py
Comment thread api/howler/datastore/collection.py Outdated
Comment thread api/test/integration/api/test_wait_for_refresh.py Outdated
Comment thread api/howler/api/v1/hit.py Outdated
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Static Badge

Howler API - Coverage Results

Static Badge Static Badge

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • api/howler/api/v1/hit.py (100%)
  • api/howler/api/v1/tool.py (100%)
  • api/howler/datastore/bulk.py (74.1%): Missing lines 27,36,39,55,71-72,74,77,79,82,91,93,96,101,143
  • api/howler/datastore/collection.py (100%)
  • api/howler/odm/base.py (100%)
  • api/howler/services/analytic_service.py (100%)
  • api/howler/services/hit_service.py (68.4%): Missing lines 447,498-499,501-502,504
  • api/howler/utils/constants.py (100%)

Summary

  • Total: 106 lines
  • Missing: 21 lines
  • Coverage: 80%
api/howler/datastore/bulk.py

Lines 23-31

  23         return len(self.operations) == 0
  24 
  25     def add_delete_operation(self, doc_id, index=None):
  26         if index:
! 27             self.operations.append((json.dumps({"delete": {"_index": index, "_id": doc_id}}),))
  28         else:
  29             for cur_index in self.indexes:
  30                 self.operations.append((json.dumps({"delete": {"_index": cur_index, "_id": doc_id}}),))

Lines 32-43

  32     def add_insert_operation(self, doc_id: str, doc, index=None):
  33         if self.model and isinstance(doc, self.model):
  34             saved_doc = doc.as_primitives(hidden_fields=True)
  35         elif self.model:
! 36             saved_doc = self.model(doc).as_primitives(hidden_fields=True)
  37         else:
  38             if not isinstance(doc, dict):
! 39                 saved_doc = {"__non_doc_raw__": doc}
  40             else:
  41                 saved_doc = deepcopy(doc)
  42         saved_doc["id"] = doc_id

Lines 51-59

  51     def add_index_operation(self, doc_id, doc, index=None):
  52         if self.model and isinstance(doc, self.model):
  53             saved_doc = doc.as_primitives(hidden_fields=True)
  54         elif self.model:
! 55             saved_doc = self.model(doc).as_primitives(hidden_fields=True)
  56         else:
  57             if not isinstance(doc, dict):
  58                 saved_doc = {"__non_doc_raw__": doc}
  59             else:

Lines 67-86

  67             )
  68         )
  69 
  70     def add_upsert_operation(self, doc_id, doc, index=None):
! 71         if self.model and isinstance(doc, self.model):
! 72             saved_doc = doc.as_primitives(hidden_fields=True)
  73         elif self.model:
! 74             saved_doc = self.model(doc).as_primitives(hidden_fields=True)
  75         else:
  76             if not isinstance(doc, dict):
! 77                 saved_doc = {"__non_doc_raw__": doc}
  78             else:
! 79                 saved_doc = deepcopy(doc)
  80         saved_doc["id"] = doc_id
  81 
! 82         self.operations.append(
  83             (
  84                 json.dumps({"update": {"_index": index or self.indexes[0], "_id": doc_id}}),
  85                 json.dumps({"doc": saved_doc, "doc_as_upsert": True}),
  86             )

Lines 87-105

   87         )
   88 
   89     def add_update_operation(self, doc_id, doc, index=None):
   90         if self.model and isinstance(doc, self.model):
!  91             saved_doc = doc.as_primitives(hidden_fields=True)
   92         elif self.model:
!  93             saved_doc = self.model(doc, mask=list(doc.keys())).as_primitives(hidden_fields=True)
   94         else:
   95             if not isinstance(doc, dict):
!  96                 saved_doc = {"__non_doc_raw__": doc}
   97             else:
   98                 saved_doc = deepcopy(doc)
   99 
  100         if index:
! 101             self.operations.append(
  102                 (
  103                     json.dumps({"update": {"_index": index, "_id": doc_id}}),
  104                     json.dumps({"doc": saved_doc}),
  105                 )

Lines 139-147

  139         """Get the bulk plan string for a batch or the full list of operations if no batch provided"""
  140         plan = "\n".join(self._flatten_operations(batch)) + "\n"
  141 
  142         if ELASTIC_MAX_REQUEST_SIZE and len(plan.encode("utf-8")) > ELASTIC_MAX_REQUEST_SIZE:
! 143             warnings.warn(
  144                 f"Bulk plan exceeds maximum request size of {ELASTIC_MAX_REQUEST_SIZE} bytes. "
  145                 f"Current size: {len(plan.encode('utf-8'))} bytes."
  146             )
api/howler/services/hit_service.py

Lines 443-451

  443     bulk_plan = storage.hit.get_bulk_plan()
  444 
  445     for hit in hits:
  446         if not overwrite and storage.hit.exists(hit.howler.id):
! 447             raise ResourceExists("Hit %s already exists in datastore" % hit.howler.id)
  448 
  449         if user:
  450             hit.howler.log = [Log({"timestamp": "NOW", "explanation": "Created hit", "user": user})]

Lines 494-508

  494     """Bulk save multiple hits to the datastore.
  495 
  496     Similar to save_hit for batch without versioning. Will overwrite existing hits with the same ID.
  497     """
! 498     storage = datastore()
! 499     bulk_plan = storage.hit.get_bulk_plan()
  500 
! 501     for hit in hits:
! 502         bulk_plan.add_index_operation(hit.howler.id, hit)
  503 
! 504     return storage.hit.bulk(bulk_plan, refresh=refresh)
  505 
  506 
  507 @typing.no_type_check
  508 @tracer.start_as_current_span(f"{__name__}.save_hit")

Full Coverage Report

Expand
Name                                            Stmts   Miss Branch BrPart  Cover
---------------------------------------------------------------------------------
howler/__init__.py                                  0      0      0      0   100%
howler/actions/__init__.py                         83      2     36      3    96%
howler/actions/add_label.py                        30      2      6      0    94%
howler/actions/add_to_bundle.py                    50      3     16      2    92%
howler/actions/add_to_case.py                      40      0     12      0   100%
howler/actions/change_field.py                     18      2      2      0    90%
howler/actions/demote.py                           35      2      8      0    95%
howler/actions/example_plugin.py                   13      8      2      0    33%
howler/actions/prioritization.py                   24      2      2      0    92%
howler/actions/promote.py                          35      2      8      0    95%
howler/actions/remove_from_bundle.py               52      0     16      0   100%
howler/actions/remove_label.py                     30      2      6      0    94%
howler/actions/transition.py                       66      4     24      4    91%
howler/api/__init__.py                            130     46     26      2    62%
howler/api/base.py                                 36      0     12      0   100%
howler/api/socket.py                               77      7     12      4    88%
howler/api/v1/__init__.py                          12      0      0      0   100%
howler/api/v1/action.py                           132     40     32      6    65%
howler/api/v1/analytic.py                         252     77     76     25    63%
howler/api/v1/auth.py                             164     25     56     16    80%
howler/api/v1/clue.py                              47     14     12      0    66%
howler/api/v1/configs.py                           14      0      0      0   100%
howler/api/v1/dossier.py                           96     27     10      4    69%
howler/api/v1/help.py                              13      0      0      0   100%
howler/api/v1/hit.py                              435     57    124     40    82%
howler/api/v1/notebook.py                          33      5      6      1    85%
howler/api/v1/overview.py                          84     11     18      7    82%
howler/api/v1/search.py                           291     78     86     17    70%
howler/api/v1/template.py                          93     13     26      9    82%
howler/api/v1/tool.py                             131      6     52      5    93%
howler/api/v1/user.py                             182     56     58     23    62%
howler/api/v1/utils/__init__.py                     0      0      0      0   100%
howler/api/v1/utils/etag.py                        30      0     14      0   100%
howler/api/v1/utils/params.py                      30      3     10      1    85%
howler/api/v1/view.py                             134     27     36     13    76%
howler/api/v2/__init__.py                          12      0      0      0   100%
howler/api/v2/case.py                             204      1     44      0    99%
howler/api/v2/fuzzy.py                             49      0     16      0   100%
howler/api/v2/ingest.py                           161      3     44      2    98%
howler/api/v2/search.py                           114      3     14      0    98%
howler/app.py                                     141     15     36     12    84%
howler/common/__init__.py                           0      0      0      0   100%
howler/common/classification.py                   597     77    342     73    83%
howler/common/exceptions.py                        74      8      2      0    89%
howler/common/loader.py                            85     24     38     10    66%
howler/common/logging/__init__.py                 141     60     64     13    51%
howler/common/logging/audit.py                     44      5      8      4    83%
howler/common/logging/format.py                    14      2      0      0    86%
howler/common/net.py                               55      2     28      2    95%
howler/common/net_static.py                         1      0      0      0   100%
howler/common/random_user.py                       10      0      2      0   100%
howler/common/swagger.py                           69     10     28      5    82%
howler/config.py                                   27      0      0      0   100%
howler/cronjobs/__init__.py                        19      2      4      0    91%
howler/cronjobs/action_queue_worker.py             22      2      6      1    89%
howler/cronjobs/correlation.py                     14      0      2      0   100%
howler/cronjobs/retention.py                       76     16     18      2    79%
howler/cronjobs/view_cleanup.py                    47      6     22      4    83%
howler/datastore/__init__.py                        0      0      0      0   100%
howler/datastore/bulk.py                           83     20     42     10    71%
howler/datastore/collection.py                   1286    265    572    110    76%
howler/datastore/constants.py                       7      0      0      0   100%
howler/datastore/exceptions.py                     23      1      0      0    96%
howler/datastore/howler_store.py                   84      8     14      2    90%
howler/datastore/operations.py                     45      5     16      5    84%
howler/datastore/schemas.py                         4      4      0      0     0%
howler/datastore/store.py                         155     34     36      8    74%
howler/datastore/support/__init__.py                0      0      0      0   100%
howler/datastore/support/build.py                 101     26     68      9    71%
howler/datastore/support/schemas.py                 4      0      0      0   100%
howler/datastore/types.py                          11      0      0      0   100%
howler/error.py                                    52     31     12      0    33%
howler/healthz.py                                  47     31     12      0    27%
howler/helper/__init__.py                           0      0      0      0   100%
howler/helper/azure.py                             20      0      8      0   100%
howler/helper/discover.py                          31      6     12      3    79%
howler/helper/hit.py                               66     16     32     12    69%
howler/helper/oauth.py                            155     68    100     19    52%
howler/helper/search.py                            24      2      6      2    87%
howler/helper/workflow.py                          47      3     22      3    91%
howler/odm/__init__.py                              1      0      0      0   100%
howler/odm/base.py                                922    164    418     45    78%
howler/odm/constants.py                            10      0      0      0   100%
howler/odm/helper.py                              283     99    100      6    60%
howler/odm/howler_enum.py                          15      0      0      0   100%
howler/odm/mixins.py                               26      0      2      0   100%
howler/odm/randomizer.py                          223     44    120     18    80%
howler/plugins/__init__.py                         17      0      4      0   100%
howler/plugins/config.py                           78      0     40      0   100%
howler/remote/__init__.py                           0      0      0      0   100%
howler/remote/datatypes/__init__.py               105      2     40      2    97%
howler/remote/datatypes/counters.py                47      5     16      0    92%
howler/remote/datatypes/events.py                  35      0      6      2    95%
howler/remote/datatypes/hash.py                   114     17     22      7    82%
howler/remote/datatypes/lock.py                    19      0      2      0   100%
howler/remote/datatypes/queues/__init__.py          0      0      0      0   100%
howler/remote/datatypes/queues/comms.py            47     10     14      3    75%
howler/remote/datatypes/queues/multi.py            22      3      8      3    80%
howler/remote/datatypes/queues/named.py            66      6     24      6    87%
howler/remote/datatypes/queues/priority.py        107     20     38     12    75%
howler/remote/datatypes/set.py                     74     12      8      2    80%
howler/remote/datatypes/user_quota_tracker.py      28     10      4      0    56%
howler/security/__init__.py                       128     19     40      9    83%
howler/security/socket.py                          60     10     12      5    76%
howler/security/utils.py                           81     37     36      2    48%
howler/services/__init__.py                         0      0      0      0   100%
howler/services/action_service.py                 101      7     52      5    91%
howler/services/analytic_service.py                79      9     32      2    90%
howler/services/auth_service.py                   140     13     52     12    87%
howler/services/bundle_compat_service.py          115      1     48      5    96%
howler/services/case_service.py                   449      6    234     13    97%
howler/services/comms_service.py                   61      0     22      1    99%
howler/services/config_service.py                  51      8      8      2    83%
howler/services/correlation_service.py             91      0     30      1    99%
howler/services/docs_service.py                    28      1     14      2    93%
howler/services/dossier_service.py                 79      7     40      2    89%
howler/services/event_service.py                   58      1     20      1    97%
howler/services/fuzzy_service.py                  187      0     88      6    98%
howler/services/hit_service.py                    245     12    100      8    94%
howler/services/jwt_service.py                     71     20     18      3    74%
howler/services/lucene_service.py                 157     12     48      8    90%
howler/services/notebook_service.py                58      3     18      3    92%
howler/services/overview_service.py                21      5      6      2    74%
howler/services/search_service.py                 167      8     74      5    95%
howler/services/template_service.py                22      5      6      2    75%
howler/services/user_service.py                   150     32     66     17    72%
howler/services/viewer_service.py                  20      0      0      0   100%
howler/telemetry.py                                33     28      6      0    13%
howler/utils/__init__.py                            0      0      0      0   100%
howler/utils/annotations.py                         0      0      0      0   100%
howler/utils/chunk.py                               8      0      2      0   100%
howler/utils/compat.py                             11      6      2      1    46%
howler/utils/constants.py                           4      0      0      0   100%
howler/utils/dict_utils.py                        123     11     86     10    88%
howler/utils/isotime.py                             8      1      2      1    80%
howler/utils/list_utils.py                          7      0      4      0   100%
howler/utils/lucene.py                             52      1     18      1    97%
howler/utils/net_utils.py                          12      0      4      0   100%
howler/utils/path.py                               16     16      2      0     0%
howler/utils/socket_utils.py                       20      1     12      2    91%
howler/utils/str_utils.py                         115     21     38      4    80%
howler/utils/uid.py                                20      2      4      2    83%
---------------------------------------------------------------------------------
TOTAL                                           12400   1972   4584    736    81%

@cccs-hxp
cccs-hxp requested review from a team and cccs-mdr July 6, 2026 12:40
Comment thread api/howler/datastore/bulk.py Outdated

@cccs-mdr cccs-mdr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly LGTM, a few comments.

Comment thread api/howler/datastore/bulk.py Outdated
Comment thread api/howler/services/hit_service.py Outdated
Comment thread api/howler/utils/constants.py Outdated
@cccs-hxp

Copy link
Copy Markdown
Contributor Author

@cccs-mdr I think I can add bulk versions for each of the current case append functions (extend_ ?) but it wouldn't use the mixin save method. What do you think? Do we want the ability to update a case's items in bulk?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants