Repository navigation
[backend/client] add ruleApplyAsync method (#11626) - #14034
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #14034 +/- ##
==========================================
- Coverage 32.06% 32.03% -0.03%
==========================================
Files 3006 3011 +5
Lines 205960 208976 +3016
Branches 37676 37977 +301
==========================================
+ Hits 66039 66955 +916
- Misses 139921 142021 +2100
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
7ccc65d to
8451465
Compare
8451465 to
a635db7
Compare
There was a problem hiding this comment.
Pull request overview
This pull request adds a new ruleApplyAsync GraphQL mutation to handle long-running rule application operations asynchronously. The implementation creates a Work object to track the operation's progress, uses a Promise.race pattern with a 1-second timeout to determine whether to wait for completion or return immediately with the ongoing work, and updates both the backend GraphQL API and the Python client to support this new functionality.
Changes:
- Added
ruleApplyAsyncmutation to GraphQL schema that returns a Work object instead of a boolean - Implemented async rule application with timeout-based decision logic in the backend rule manager
- Updated Python client to include
rule_apply_asyncmethod and modified the existingrule_applyto use the async endpoint - Enhanced Python work API to properly initialize work status before polling
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| opencti-platform/opencti-graphql/src/resolvers/rule.js | Adds resolver mapping for the new ruleApplyAsync mutation |
| opencti-platform/opencti-graphql/src/manager/ruleManager.ts | Implements ruleApplyAsync with Work tracking, timeout logic, and background execution handling |
| opencti-platform/opencti-graphql/src/generated/graphql.ts | Generated TypeScript types for the new GraphQL mutation and its arguments |
| opencti-platform/opencti-graphql/config/schema/opencti.graphql | GraphQL schema definition for ruleApplyAsync mutation returning Work object |
| opencti-platform/opencti-front/src/schema/relay.schema.graphql | Frontend Relay schema update to include the new mutation |
| client-python/pycti/utils/opencti_stix2.py | Changes rule_apply method to call rule_apply_async instead of rule_apply |
| client-python/pycti/entities/opencti_stix_core_object.py | Adds new rule_apply_async method that creates work and waits for completion |
| client-python/pycti/api/opencti_api_work.py | Fixes work status initialization in wait_for_work_to_finish to properly handle initial state |
Comments suppressed due to low confidence (1)
client-python/pycti/api/opencti_api_work.py:261
- The infinite while loop lacks any timeout or maximum iteration mechanism. If a work gets stuck in a non-complete status (e.g., due to a backend issue), this will loop forever, potentially hanging the client indefinitely. Consider adding a timeout parameter or maximum retry count to prevent infinite loops.
while status != "complete":
state = self.get_work(work_id=work_id)
if len(state) > 0:
status = state["status"]
if state["errors"]:
self.api.app_logger.error(
"Unexpected connector error", {"state_errors": state["errors"]}
)
return ""
time.sleep(1)
| "Apply rule stix_core_object", {"id": element_id} | ||
| ) | ||
| query = """ | ||
| mutation StixCoreApplyRule($elementId: ID!, $ruleId: ID!) { |
There was a problem hiding this comment.
The GraphQL mutation name 'StixCoreApplyRule' doesn't match the actual mutation being called ('ruleApplyAsync'). While this is just a query name and doesn't affect functionality, it could cause confusion during debugging or when reading GraphQL operation logs. Consider renaming it to 'StixCoreApplyRuleAsync' for consistency.
| mutation StixCoreApplyRule($elementId: ID!, $ruleId: ID!) { | |
| mutation StixCoreApplyRuleAsync($elementId: ID!, $ruleId: ID!) { |
| state = self.get_work(work_id=work_id) | ||
| status = "" | ||
| if len(state) > 0: | ||
| status = state["status"] |
There was a problem hiding this comment.
The initial status check (line 246) retrieves the work state but immediately overwrites the status variable with an empty string on line 247, making the initial fetch pointless. This is redundant and could be simplified by initializing status based on the initial state directly, or starting with the loop if the first check isn't needed.
08b015f to
b08e831
Compare
| while not rule_apply_complete: | ||
| self.opencti.app_logger.info( | ||
| "Apply rule async stix_core_object", {"id": element_id} | ||
| ) | ||
| query = """ | ||
| mutation StixCoreApplyRule($elementId: ID!, $ruleId: ID!, $executionId: ID!) { | ||
| ruleApplyAsync(elementId: $elementId, ruleId: $ruleId, executionId: $executionId) | ||
| } | ||
| """ | ||
| result = self.opencti.query( | ||
| query, | ||
| { | ||
| "elementId": element_id, | ||
| "ruleId": rule_id, | ||
| "executionId": execution_id, | ||
| }, | ||
| ) | ||
| rule_apply_complete = result["data"]["ruleApplyAsync"] | ||
| self.opencti.app_logger.info( | ||
| "Apply rule async stix_core_object complete", {"id": element_id} | ||
| ) | ||
| if not rule_apply_complete: | ||
| time.sleep(10) |
There was a problem hiding this comment.
The infinite while loop has no maximum retry count or total timeout. If the backend continuously returns false due to an issue, this will loop forever, making repeated GraphQL calls every 10 seconds. Consider adding a maximum retry count or a total timeout duration to prevent infinite loops and resource exhaustion.
| export const ruleApplyAsync = async (context: AuthContext, user: AuthUser, elementId: string, ruleId: string, executionId: string) => { | ||
| const rule = await getRule(context, user, ruleId) as RuleRuntime; | ||
| if (!rule) { | ||
| throw FunctionalError('Cant find rule to scan', { id: ruleId }); |
There was a problem hiding this comment.
Spelling error: "Cant" should be "Cannot" to match the error message style used in the Python client and elsewhere in the codebase.
| export const ruleApplyAsync = async (context: AuthContext, user: AuthUser, elementId: string, ruleId: string, executionId: string) => { | ||
| const rule = await getRule(context, user, ruleId) as RuleRuntime; | ||
| if (!rule) { | ||
| throw FunctionalError('Cant find rule to scan', { id: ruleId }); | ||
| } | ||
| const dataCanonicalize = jsonCanonicalize({ elementId, ruleId, executionId }) as string; | ||
| const ruleApplyId = uuidv5(dataCanonicalize, OPENCTI_NAMESPACE); | ||
| const currentAsyncCall = await redisGetAsyncCall(ruleApplyId); | ||
| if (currentAsyncCall) { | ||
| return currentAsyncCall !== '0'; | ||
| } | ||
| await redisInitializeAsyncCall(ruleApplyId); | ||
| const timeoutPromise = new Promise((_, reject) => | ||
| setTimeout(() => reject(new Error('Timeout')), 10000), | ||
| ); | ||
| const ruleApplyPromise = ruleApply(context, user, elementId, ruleId); | ||
| try { | ||
| await Promise.race([ruleApplyPromise, timeoutPromise]); | ||
| // If rule apply promise is the first to finish, we move the work to complete here | ||
| await redisFinishAsyncCall(ruleApplyId); | ||
| return true; | ||
| } catch { | ||
| // If timeout promise is the first to finish, we do not await the rule apply but instead we return the ongoing work | ||
| // The work will be moved to complete when the rule apply is finished | ||
| ruleApplyPromise.catch((err) => { | ||
| logApp.error('[OPENCTI] Error during rule apply', { cause: err }); | ||
| }).finally(async () => { | ||
| await redisFinishAsyncCall(ruleApplyId); | ||
| }); | ||
| } | ||
|
|
||
| return false; | ||
| }; |
There was a problem hiding this comment.
The new ruleApplyAsync functionality lacks test coverage. The repository has comprehensive test suites in tests/20-rules/ and tests/03-integration/, and similar async operations would typically have unit and integration tests. Consider adding tests to verify: 1) the async behavior with timeouts, 2) the Redis tracking mechanism, 3) concurrent calls with the same parameters, 4) error handling scenarios, and 5) the Python client polling logic.
| } catch { | ||
| // If timeout promise is the first to finish, we do not await the rule apply but instead we return the ongoing work | ||
| // The work will be moved to complete when the rule apply is finished | ||
| ruleApplyPromise.catch((err) => { | ||
| logApp.error('[OPENCTI] Error during rule apply', { cause: err }); | ||
| }).finally(async () => { | ||
| await redisFinishAsyncCall(ruleApplyId); | ||
| }); | ||
| } |
There was a problem hiding this comment.
The catch block here doesn't distinguish between timeout errors and actual rule execution errors. When the timeout occurs, the ruleApplyPromise continues executing in the background, but if it then fails with an actual error (not a timeout), that error is logged but the function still returns false, which could be misleading. The client will interpret false as "still processing" when it might actually mean "failed". Consider returning an error state or throwing an exception for non-timeout errors to provide clearer feedback to the caller.
| const timeoutPromise = new Promise((_, reject) => | ||
| setTimeout(() => reject(new Error('Timeout')), 10000), |
There was a problem hiding this comment.
The timeout value of 10000ms (10 seconds) is hardcoded. Since this is part of a system designed to handle long-running operations, the timeout should be configurable through the configuration system (similar to other timeouts in the codebase like SCHEDULE_TIME which uses conf.get). Consider adding a configuration option like 'rule_engine:async_timeout' with 10000 as a default value.
| self.opencti.app_logger.info( | ||
| "Apply rule async stix_core_object complete", {"id": element_id} | ||
| ) | ||
| if not rule_apply_complete: | ||
| time.sleep(10) |
There was a problem hiding this comment.
The log message "Apply rule async stix_core_object complete" is misleading because it's logged immediately after receiving the response from the API, before checking whether rule_apply_complete is actually True. If the API returns False, this message will still log "complete" even though the operation is not complete. Move this log statement inside a conditional check or change the message to reflect the actual status returned.
| self.opencti.app_logger.info( | |
| "Apply rule async stix_core_object complete", {"id": element_id} | |
| ) | |
| if not rule_apply_complete: | |
| time.sleep(10) | |
| if not rule_apply_complete: | |
| time.sleep(10) | |
| self.opencti.app_logger.info( | |
| "Apply rule async stix_core_object complete", {"id": element_id} | |
| ) |
| result = self.opencti.query( | ||
| query, | ||
| { | ||
| "elementId": element_id, | ||
| "ruleId": rule_id, | ||
| "executionId": execution_id, | ||
| }, | ||
| ) |
There was a problem hiding this comment.
The polling loop doesn't handle potential GraphQL query errors. If the query call at lines 1900-1907 throws an exception (e.g., network error, authentication failure, or backend error), the entire function will fail without any retry logic or graceful degradation. Consider wrapping the query in a try-except block with appropriate error handling, logging, and potentially a backoff strategy.
| "Apply rule async stix_core_object complete", {"id": element_id} | ||
| ) | ||
| if not rule_apply_complete: | ||
| time.sleep(10) |
There was a problem hiding this comment.
The function doesn't return a value when rule_apply_complete is True. The function should return a value (presumably True or some status) to indicate successful completion, otherwise it implicitly returns None which could be confusing for callers.
| time.sleep(10) | |
| time.sleep(10) | |
| return rule_apply_complete |
3359987 to
1973420
Compare
| const currentAsyncCall = await redisGetAsyncCall(ruleApplyId); | ||
| // If a call is already processing, return the call status | ||
| if (currentAsyncCall) { | ||
| result = currentAsyncCall !== '0'; |
There was a problem hiding this comment.
if call is in progress, we should also wait some time before returning the result
There was a problem hiding this comment.
I don't fully get what you mean. Do you want the ruleApplyAsync function to always wait the same amount of time after the first call ? Can't we instead return directly and let the caller decide on the wait time between each call to ruleApplyAsync to check if it is done?
| ruleApplyPromise.catch((err) => { | ||
| logApp.error('[OPENCTI] Error during rule apply', { cause: err }); | ||
| }).finally(async () => { | ||
| await redisFinishAsyncCall(ruleApplyId); | ||
| }); |
There was a problem hiding this comment.
mixing promise style and try/catch/finally is a bit complicated, you could stick to try/catch/finally everywhere
| @@ -9951,6 +9951,7 @@ type Mutation { | |||
| ruleSetActivation(id: ID!, enable: Boolean!): Rule! | |||
| ruleManagerClean(eventId: ID): RuleManager! | |||
| ruleApply(elementId: ID!, ruleId: ID!): Boolean | |||
There was a problem hiding this comment.
i wonder if we could merge ruleApply and ruleApplyAsync by specifing an optional asyncExecutionId: ID parameter ?
| if not rule_apply_complete: | ||
| time.sleep(10) |
There was a problem hiding this comment.
we should rather have an active wait in the backend
| return executeRuleApply(context, user, rule, elementId); | ||
| }; | ||
|
|
||
| export const ruleApplyAsync = async (context: AuthContext, user: AuthUser, elementId: string, ruleId: string, executionId: string) => { |
There was a problem hiding this comment.
In case the node executing the rule apply fail, it won't be able to resume before 1h (in worst case)
The challenges are:
- being able to detect a 'dead' ruleApply due to node failure
- being able to make active wait, even from a node that is not executing this ruleApply
I propose to rely on redis status + redis lock + local map of async task promise:
- keep the lock during all the ruleApply execution
- keep the lock alive with a // async task
Draft algo : when receiving a request
- check the local promise map -> if present go to step 3.
- try to get the lock with timeout
2.1. if lock cannot be acquired during the timeout, task is still in progress, then return false
2.2. if lock can be acquired, check status
2.2.1. status = done, task is terminated, return true
2.2.2. no status or status = in progress (that mean dead task) then create the async call promise - wait the call promise with race timeout, if timeout win the race then return false, else true
There was a problem hiding this comment.
I pushed a version of the draft algo. I'm not sure we need the local map of async tasks though, can't we just always try to get the lock?
0aaa808 to
e4ecc45
Compare
Proposed changes
Related issues
Checklist
Further comments