Skip to content

GH-3837: Stop SERVICE materialization when query cancellation is observed - #4223

Open
goutamadwant wants to merge 2 commits into
apache:mainfrom
goutamadwant:fix-service-query-cancellation
Open

goutamadwant wants to merge 2 commits into
apache:mainfrom
goutamadwant:fix-service-query-cancellation

Conversation

@goutamadwant

@goutamadwant goutamadwant commented Sep 14, 2026

Copy link
Copy Markdown

Refs #3837

Pull request Description:

Check the outer query's cancellation signal before executing a SERVICE request and while materializing its results. When cancellation is observed, abort the HTTP query and close its response without starting another potentially blocking read.

Successful responses remain fully materialized, preserving SERVICE SILENT error handling and variable remapping. The existing HTTP timeout configuration is unchanged.

Tests cover cancellation before a request, during result consumption, after a blocked read returns, and at the end of results; repeated abort/close; detached results with and without a cancellation signal; SILENT behavior; variable remapping; and timeout propagation.

Validation: all eleven cancellation regression cases, the full ARQ reactor, 97 HTTP integration tests, and 12 Fuseki-main service access tests passed. The integration build skipped UI tooling/tests, Javadocs, and RAT; this is not a full-project verification claim.

This does not interrupt a network read already in progress or address all the Fuseki thread and connection exhaustion factors discussed in #3837. A local execution of the reported workload still stalled after client timeouts. This is a partial cancellation improvement, not a fix for the entire incident, and is not intended to close the issue.


  • Tests are included.
  • Documentation change and updates are provided for the Apache Jena website
  • Commits have been squashed to remove intermediate development commit messages.
  • Key commit messages start with the issue number (GH-xxxx)

By submitting this pull request, I acknowledge that I am making a contribution to the Apache Software Foundation under the terms and conditions of the Contributor's Agreement.


See the Apache Jena "Contributing" guide.

Check the outer cancellation signal before execution and during result materialization. Avoid the extra response read on aborted close while preserving detached results and SERVICE SILENT behavior.

Add cancellation and compatibility regressions. This partial improvement does not interrupt an already-blocked network read or resolve the entire reported Fuseki stall.

@rvesse rvesse left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for the contribution, this does look to improve the situation for this bug

Couple of comments about potentially simplifying the logic

Comment thread jena-arq/src/test/java/org/apache/jena/sparql/exec/TS_ExecSPARQL.java Outdated
for (;;) {
checkCancelled(cancelSignal, qExec);
boolean hasNext = rowSet.hasNext();
checkCancelled(cancelSignal, qExec);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I am always a little wary of doing checks like this twice inside a loop although I can understand the reasoning (checking both before and after the network read which could block), might it make more sense to make this a while loop conditional on the cancel signal? Only other change that would necessitate would be a final cancellation check post-loop as hasNext could be false leading to break out of the loop before the next cancellation check

For the case when no cancel signal is available it might also be simpler to preserve existing code path from deleted lines then the actual cancellation check doesn't need to make a null check as well

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks @rvesse. restoredc the original materialization path when no cancel signal is available and moved the cancellation guards into a while condition. The final check also catches cancellation when hasNext() returns false. Kept the guards around the potentially blocking read and added coverage for cancellation at the end of results and successful results with and without a signal. let me know if this looks good

Preserve the original materialization path when no cancellation signal
is available. Use a guarded while loop otherwise, with a final check
that also handles cancellation when the result iterator is exhausted.

Cover cancellation at the end of results and successful detachment with
and without a signal. Use an import in the execution test suite.

The existing limitation for already-blocked network reads is unchanged.

Validation: full ARQ reactor and scoped HTTP/Fuseki integration tests pass.
UI tooling/tests, Javadocs and RAT were skipped in the integration build.
@Aklakan

Aklakan commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Hi, My feeling is that it would be better to defer materialization into its own iterator.
This way, the existing cancellation/closing machinery should be reused, and cancellation might/should even disconnect from the HTTP connection (at the cost of a stale file descriptor on the server's side). I have not yet tested this code against @goutamadwant tests though.

As a sketch:

        // Service.java
        QueryExec qExec = ...; // remove try-with-resources block

        // Build and access the rowSet here because
        // some tests expect Service.exec to raise a QueryExceptionHTTP immediately.
        // If the tests could be relaxed then this preflight check could be removed.
        RowSet rowSet;
        try {
            rowSet = qExec.select();
            rowSet.hasNext(); // Will "hang" waiting for connection and the first result.
        } catch (Throwable t) {
            try {
                if (t instanceof HttpException ex) {
                    throw QueryExceptionHTTP.rewrap(ex);
                }
                throw t;
            } finally {
                qExec.close();
            }
        }

        QueryIterator qIter = new QueryIterMaterializeQueryExec(execCxt, qExec, rowSet);
        if (requiresRemapping)
            qIter = QueryIter.map(qIter, varMapping);
// QueryIterMaterializeQueryExec.java
/** QueryIter backed by a QueryExec. The RowSet is materialized on the first call to next(). */
public class QueryIterMaterializeQueryExec
    extends QueryIter
{
    protected final QueryExec queryExec;
    protected boolean queryExecClosed = false;
    protected RowSet baseRowSet;
    protected RowSet materializeRowSet;

    /**
     * Constructor.
     *
     * @param execCxt ExecutionContext. May be null, however cancellation signals are passed via contexts.
     * @param queryExec QueryIter. The backing query execution. Must not be null.
     * @param baseRowSet The backing row set. Must not be null.
     */
    public QueryIterMaterializeQueryExec(ExecutionContext execCxt, QueryExec queryExec, RowSet baseRowSet) {
        super(execCxt);
        this.queryExec = Objects.requireNonNull(queryExec);
        this.baseRowSet = Objects.requireNonNull(baseRowSet);
    }

    @Override
    protected boolean hasNextBinding() {
        try {
            if (materializeRowSet == null) {
                return baseRowSet.hasNext();
            }
            return materializeRowSet.hasNext();
        } catch (HttpException ex) {
            throw QueryExceptionHTTP.rewrap(ex);
        }
    }

    @Override
    protected Binding moveToNextBinding() {
        try {
            if (materializeRowSet == null) {
                materializeRowSet = baseRowSet.materialize();
                queryExecClosed = true;
                queryExec.close(); // Close immediately to possibly return a HTTP connection to its pool.
            }
            return materializeRowSet.next();
        } catch (HttpException ex) {
            throw QueryExceptionHTTP.rewrap(ex);
        }
    }

    @Override
    protected void closeIterator() {
        if (!queryExecClosed) {
            queryExec.close();
            queryExecClosed = true; // Actually not needed because base class prevents calling closeIterator twice.
        }
    }

    @Override
    protected void requestCancel() {
        queryExec.abort();
    }
}

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