Skip to content

Commit 977ec9d

Browse files
committed
impl timeout for SyncReasoner.instances
1 parent 28e23c3 commit 977ec9d

1 file changed

Lines changed: 113 additions & 2 deletions

File tree

owlapy/owl_reasoner.py

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import json
77
import subprocess
88
import sys
9+
import jpype
910

1011
from collections import defaultdict, Counter
1112
from functools import singledispatchmethod, reduce, cached_property
@@ -1237,10 +1238,68 @@ def __init__(self, ontology: Union[SyncOntology, str], reasoner="HermiT"):
12371238
self.mapper = self.ontology.mapper
12381239
self.inference_types_mapping = import_and_include_axioms_generators()
12391240
self._owlapi_reasoner = initialize_reasoner(reasoner, self._owlapi_ontology)
1241+
# A single-threaded Java executor shared across all instances() calls.
1242+
# Using one reusable executor ensures that reasoning tasks are serialized,
1243+
# preventing "ExtensionManager is not reentrant" errors that occur when
1244+
# two Java threads concurrently access a non-thread-safe reasoner (e.g. HermiT).
1245+
# We use a daemon-thread factory so that lingering reasoning threads
1246+
# (e.g. ones that ignored an interrupt after a timeout) do not prevent
1247+
# the JVM / Python process from exiting.
1248+
Executors = JClass("java.util.concurrent.Executors")
1249+
ThreadFactory = JClass("java.util.concurrent.ThreadFactory")
1250+
1251+
@jpype.JImplements(ThreadFactory)
1252+
class _DaemonThreadFactory:
1253+
@jpype.JOverride
1254+
def newThread(self_, r):
1255+
t = jpype.JClass("java.lang.Thread")(r)
1256+
t.setDaemon(True)
1257+
t.setName("owlapy-reasoner-worker")
1258+
return t
1259+
1260+
self._reasoning_executor = Executors.newSingleThreadExecutor(_DaemonThreadFactory())
1261+
1262+
def __del__(self):
1263+
"""Shut down the shared Java executor and dispose the reasoner when garbage-collected."""
1264+
try:
1265+
self.close()
1266+
except Exception:
1267+
pass
1268+
1269+
def close(self):
1270+
"""Explicitly shut down the reasoner and the shared Java executor.
1271+
1272+
Call this when done with the reasoner to release all Java resources
1273+
and allow the process to exit cleanly.
1274+
"""
1275+
# 1. Dispose the OWL API reasoner (releases internal threads & caches).
1276+
if hasattr(self, "_owlapi_reasoner") and self._owlapi_reasoner is not None:
1277+
try:
1278+
self._owlapi_reasoner.dispose()
1279+
except Exception:
1280+
pass
1281+
self._owlapi_reasoner = None
1282+
1283+
# 2. Shut down the executor and wait briefly for its thread to finish.
1284+
if hasattr(self, "_reasoning_executor") and self._reasoning_executor is not None:
1285+
self._reasoning_executor.shutdownNow()
1286+
try:
1287+
TimeUnit = JClass("java.util.concurrent.TimeUnit")
1288+
self._reasoning_executor.awaitTermination(5, TimeUnit.SECONDS)
1289+
except Exception:
1290+
pass
1291+
self._reasoning_executor = None
1292+
1293+
def __enter__(self):
1294+
return self
1295+
1296+
def __exit__(self, exc_type, exc_val, exc_tb):
1297+
self.close()
1298+
return False
12401299

12411300
def _instances(self, ce: OWLClassExpression, direct=False) -> Set[OWLNamedIndividual]:
12421301
"""
1243-
Get the instances for a given class expression using HermiT.
1302+
Get the instances for a given class expression.
12441303
12451304
Args:
12461305
ce (OWLClassExpression): The class expression in OWLAPY format.
@@ -1256,7 +1315,59 @@ def _instances(self, ce: OWLClassExpression, direct=False) -> Set[OWLNamedIndivi
12561315
return {self.mapper.map_(ind) for ind in flattened_instances}
12571316

12581317
def instances(self, ce: OWLClassExpression, direct: bool = False, timeout: int = 1000):
1259-
return run_with_timeout(self._instances, timeout, (ce, direct))
1318+
"""
1319+
Get the instances for a given class expression with a Java-level timeout.
1320+
1321+
Uses Java's ExecutorService to run the reasoning task in a separate Java thread,
1322+
enabling proper timeout and interruption of the underlying Java reasoner (e.g. HermiT,
1323+
Pellet). This overcomes the limitation of Python's threading which cannot interrupt
1324+
JPype Java calls.
1325+
1326+
Args:
1327+
ce (OWLClassExpression): The class expression in OWLAPY format.
1328+
direct (bool): Whether to get direct instances or not. Defaults to False.
1329+
timeout (int): Timeout in seconds for the reasoning task. Defaults to 1000.
1330+
1331+
Returns:
1332+
set: A set of individuals classified by the given class expression.
1333+
Returns an empty set if the timeout is exceeded.
1334+
"""
1335+
mapped_ce = self.mapper.map_(ce)
1336+
1337+
TimeUnit = JClass("java.util.concurrent.TimeUnit")
1338+
1339+
# Submit the reasoning task to the shared single-threaded executor.
1340+
# Using a shared executor (created once in __init__) ensures that all
1341+
# instances() calls are serialized on one Java thread, preventing the
1342+
# "ExtensionManager is not reentrant" error that arises when a new
1343+
# executor is created per call and two threads overlap during shutdown.
1344+
owlapi_reasoner = self._owlapi_reasoner
1345+
1346+
@jpype.JImplements("java.util.concurrent.Callable")
1347+
class _ReasonerCallable:
1348+
@jpype.JOverride
1349+
def call(self_):
1350+
return owlapi_reasoner.getInstances(mapped_ce, direct)
1351+
1352+
future = self._reasoning_executor.submit(_ReasonerCallable())
1353+
try:
1354+
instances = future.get(timeout, TimeUnit.SECONDS)
1355+
flattened_instances = instances.getFlattened()
1356+
return {self.mapper.map_(ind) for ind in flattened_instances}
1357+
except jpype.JException as e:
1358+
# java.util.concurrent.TimeoutException when reasoning exceeds the timeout
1359+
if "TimeoutException" in type(e).__name__:
1360+
future.cancel(True) # Interrupt the Java reasoning thread
1361+
logger.warning(f"Reasoner timed out after {timeout} seconds for instances query on: {ce}")
1362+
return set()
1363+
# java.util.concurrent.ExecutionException wraps exceptions from the Callable
1364+
elif "ExecutionException" in type(e).__name__:
1365+
cause = e.getCause()
1366+
raise RuntimeError(
1367+
f"Reasoning failed for class expression '{ce}': {cause}"
1368+
) from None
1369+
else:
1370+
raise
12601371

12611372
def equivalent_classes(self, ce: OWLClassExpression):
12621373
"""

0 commit comments

Comments
 (0)