Merge pull request #2258 from danielaskdd/pipeline-cancelllation
Feat: Add Pipeline Cancellation Feature with Enhanced Reliability
This commit is contained in:
commit
e2ec1cdcd4
15 changed files with 735 additions and 219 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
from .lightrag import LightRAG as LightRAG, QueryParam as QueryParam
|
from .lightrag import LightRAG as LightRAG, QueryParam as QueryParam
|
||||||
|
|
||||||
__version__ = "1.4.9.4"
|
__version__ = "1.4.9.5"
|
||||||
__author__ = "Zirui Guo"
|
__author__ = "Zirui Guo"
|
||||||
__url__ = "https://github.com/HKUDS/LightRAG"
|
__url__ = "https://github.com/HKUDS/LightRAG"
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
__api_version__ = "0244"
|
__api_version__ = "0245"
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,28 @@ class ReprocessResponse(BaseModel):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CancelPipelineResponse(BaseModel):
|
||||||
|
"""Response model for pipeline cancellation operation
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
status: Status of the cancellation request
|
||||||
|
message: Message describing the operation result
|
||||||
|
"""
|
||||||
|
|
||||||
|
status: Literal["cancellation_requested", "not_busy"] = Field(
|
||||||
|
description="Status of the cancellation request"
|
||||||
|
)
|
||||||
|
message: str = Field(description="Human-readable message describing the operation")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"status": "cancellation_requested",
|
||||||
|
"message": "Pipeline cancellation has been requested. Documents will be marked as FAILED.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class InsertTextRequest(BaseModel):
|
class InsertTextRequest(BaseModel):
|
||||||
"""Request model for inserting a single text document
|
"""Request model for inserting a single text document
|
||||||
|
|
||||||
|
|
@ -1534,7 +1556,19 @@ async def background_delete_documents(
|
||||||
try:
|
try:
|
||||||
# Loop through each document ID and delete them one by one
|
# Loop through each document ID and delete them one by one
|
||||||
for i, doc_id in enumerate(doc_ids, 1):
|
for i, doc_id in enumerate(doc_ids, 1):
|
||||||
|
# Check for cancellation at the start of each document deletion
|
||||||
async with pipeline_status_lock:
|
async with pipeline_status_lock:
|
||||||
|
if pipeline_status.get("cancellation_requested", False):
|
||||||
|
cancel_msg = f"Deletion cancelled by user at document {i}/{total_docs}. {len(successful_deletions)} deleted, {total_docs - i + 1} remaining."
|
||||||
|
logger.info(cancel_msg)
|
||||||
|
pipeline_status["latest_message"] = cancel_msg
|
||||||
|
pipeline_status["history_messages"].append(cancel_msg)
|
||||||
|
# Add remaining documents to failed list with cancellation reason
|
||||||
|
failed_deletions.extend(
|
||||||
|
doc_ids[i - 1 :]
|
||||||
|
) # i-1 because enumerate starts at 1
|
||||||
|
break # Exit the loop, remaining documents unchanged
|
||||||
|
|
||||||
start_msg = f"Deleting document {i}/{total_docs}: {doc_id}"
|
start_msg = f"Deleting document {i}/{total_docs}: {doc_id}"
|
||||||
logger.info(start_msg)
|
logger.info(start_msg)
|
||||||
pipeline_status["cur_batch"] = i
|
pipeline_status["cur_batch"] = i
|
||||||
|
|
@ -1697,6 +1731,10 @@ async def background_delete_documents(
|
||||||
# Final summary and check for pending requests
|
# Final summary and check for pending requests
|
||||||
async with pipeline_status_lock:
|
async with pipeline_status_lock:
|
||||||
pipeline_status["busy"] = False
|
pipeline_status["busy"] = False
|
||||||
|
pipeline_status["pending_requests"] = False # Reset pending requests flag
|
||||||
|
pipeline_status["cancellation_requested"] = (
|
||||||
|
False # Always reset cancellation flag
|
||||||
|
)
|
||||||
completion_msg = f"Deletion completed: {len(successful_deletions)} successful, {len(failed_deletions)} failed"
|
completion_msg = f"Deletion completed: {len(successful_deletions)} successful, {len(failed_deletions)} failed"
|
||||||
pipeline_status["latest_message"] = completion_msg
|
pipeline_status["latest_message"] = completion_msg
|
||||||
pipeline_status["history_messages"].append(completion_msg)
|
pipeline_status["history_messages"].append(completion_msg)
|
||||||
|
|
@ -2230,7 +2268,7 @@ def create_document_routes(
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
# TODO: Deprecated
|
# TODO: Deprecated, use /documents/paginated instead
|
||||||
@router.get(
|
@router.get(
|
||||||
"", response_model=DocsStatusesResponse, dependencies=[Depends(combined_auth)]
|
"", response_model=DocsStatusesResponse, dependencies=[Depends(combined_auth)]
|
||||||
)
|
)
|
||||||
|
|
@ -2754,4 +2792,63 @@ def create_document_routes(
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/cancel_pipeline",
|
||||||
|
response_model=CancelPipelineResponse,
|
||||||
|
dependencies=[Depends(combined_auth)],
|
||||||
|
)
|
||||||
|
async def cancel_pipeline():
|
||||||
|
"""
|
||||||
|
Request cancellation of the currently running pipeline.
|
||||||
|
|
||||||
|
This endpoint sets a cancellation flag in the pipeline status. The pipeline will:
|
||||||
|
1. Check this flag at key processing points
|
||||||
|
2. Stop processing new documents
|
||||||
|
3. Cancel all running document processing tasks
|
||||||
|
4. Mark all PROCESSING documents as FAILED with reason "User cancelled"
|
||||||
|
|
||||||
|
The cancellation is graceful and ensures data consistency. Documents that have
|
||||||
|
completed processing will remain in PROCESSED status.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CancelPipelineResponse: Response with status and message
|
||||||
|
- status="cancellation_requested": Cancellation flag has been set
|
||||||
|
- status="not_busy": Pipeline is not currently running
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If an error occurs while setting cancellation flag (500).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from lightrag.kg.shared_storage import (
|
||||||
|
get_namespace_data,
|
||||||
|
get_pipeline_status_lock,
|
||||||
|
)
|
||||||
|
|
||||||
|
pipeline_status = await get_namespace_data("pipeline_status")
|
||||||
|
pipeline_status_lock = get_pipeline_status_lock()
|
||||||
|
|
||||||
|
async with pipeline_status_lock:
|
||||||
|
if not pipeline_status.get("busy", False):
|
||||||
|
return CancelPipelineResponse(
|
||||||
|
status="not_busy",
|
||||||
|
message="Pipeline is not currently running. No cancellation needed.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set cancellation flag
|
||||||
|
pipeline_status["cancellation_requested"] = True
|
||||||
|
cancel_msg = "Pipeline cancellation requested by user"
|
||||||
|
logger.info(cancel_msg)
|
||||||
|
pipeline_status["latest_message"] = cancel_msg
|
||||||
|
pipeline_status["history_messages"].append(cancel_msg)
|
||||||
|
|
||||||
|
return CancelPipelineResponse(
|
||||||
|
status="cancellation_requested",
|
||||||
|
message="Pipeline cancellation has been requested. Documents will be marked as FAILED.",
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error requesting pipeline cancellation: {str(e)}")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|
|
||||||
|
|
@ -96,3 +96,11 @@ class PipelineNotInitializedError(KeyError):
|
||||||
f" await initialize_pipeline_status()"
|
f" await initialize_pipeline_status()"
|
||||||
)
|
)
|
||||||
super().__init__(msg)
|
super().__init__(msg)
|
||||||
|
|
||||||
|
|
||||||
|
class PipelineCancelledException(Exception):
|
||||||
|
"""Raised when pipeline processing is cancelled by user request."""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "User cancelled"):
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from lightrag.exceptions import PipelineNotInitializedError
|
||||||
|
|
||||||
|
|
||||||
# Define a direct print function for critical logs that must be visible in all processes
|
# Define a direct print function for critical logs that must be visible in all processes
|
||||||
def direct_log(message, enable_output: bool = False, level: str = "DEBUG"):
|
def direct_log(message, enable_output: bool = False, level: str = "INFO"):
|
||||||
"""
|
"""
|
||||||
Log a message directly to stderr to ensure visibility in all processes,
|
Log a message directly to stderr to ensure visibility in all processes,
|
||||||
including the Gunicorn master process.
|
including the Gunicorn master process.
|
||||||
|
|
@ -44,7 +44,6 @@ def direct_log(message, enable_output: bool = False, level: str = "DEBUG"):
|
||||||
}
|
}
|
||||||
message_level = level_mapping.get(level.upper(), logging.DEBUG)
|
message_level = level_mapping.get(level.upper(), logging.DEBUG)
|
||||||
|
|
||||||
# print(f"Diret_log: {level.upper()} {message_level} ? {current_level}", file=sys.stderr, flush=True)
|
|
||||||
if message_level >= current_level:
|
if message_level >= current_level:
|
||||||
print(f"{level}: {message}", file=sys.stderr, flush=True)
|
print(f"{level}: {message}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
@ -168,7 +167,7 @@ class UnifiedLock(Generic[T]):
|
||||||
direct_log(
|
direct_log(
|
||||||
f"== Lock == Process {self._pid}: Failed to acquire lock '{self._name}': {e}",
|
f"== Lock == Process {self._pid}: Failed to acquire lock '{self._name}': {e}",
|
||||||
level="ERROR",
|
level="ERROR",
|
||||||
enable_output=self._enable_logging,
|
enable_output=True,
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
@ -199,7 +198,7 @@ class UnifiedLock(Generic[T]):
|
||||||
direct_log(
|
direct_log(
|
||||||
f"== Lock == Process {self._pid}: Failed to release lock '{self._name}': {e}",
|
f"== Lock == Process {self._pid}: Failed to release lock '{self._name}': {e}",
|
||||||
level="ERROR",
|
level="ERROR",
|
||||||
enable_output=self._enable_logging,
|
enable_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# If main lock release failed but async lock hasn't been released, try to release it
|
# If main lock release failed but async lock hasn't been released, try to release it
|
||||||
|
|
@ -223,7 +222,7 @@ class UnifiedLock(Generic[T]):
|
||||||
direct_log(
|
direct_log(
|
||||||
f"== Lock == Process {self._pid}: Failed to release async lock after main lock failure: {inner_e}",
|
f"== Lock == Process {self._pid}: Failed to release async lock after main lock failure: {inner_e}",
|
||||||
level="ERROR",
|
level="ERROR",
|
||||||
enable_output=self._enable_logging,
|
enable_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
raise
|
raise
|
||||||
|
|
@ -247,7 +246,7 @@ class UnifiedLock(Generic[T]):
|
||||||
direct_log(
|
direct_log(
|
||||||
f"== Lock == Process {self._pid}: Failed to acquire lock '{self._name}' (sync): {e}",
|
f"== Lock == Process {self._pid}: Failed to acquire lock '{self._name}' (sync): {e}",
|
||||||
level="ERROR",
|
level="ERROR",
|
||||||
enable_output=self._enable_logging,
|
enable_output=True,
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
@ -269,7 +268,7 @@ class UnifiedLock(Generic[T]):
|
||||||
direct_log(
|
direct_log(
|
||||||
f"== Lock == Process {self._pid}: Failed to release lock '{self._name}' (sync): {e}",
|
f"== Lock == Process {self._pid}: Failed to release lock '{self._name}' (sync): {e}",
|
||||||
level="ERROR",
|
level="ERROR",
|
||||||
enable_output=self._enable_logging,
|
enable_output=True,
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
@ -401,7 +400,7 @@ def _perform_lock_cleanup(
|
||||||
direct_log(
|
direct_log(
|
||||||
f"== {lock_type} Lock == Cleanup failed: {e}",
|
f"== {lock_type} Lock == Cleanup failed: {e}",
|
||||||
level="ERROR",
|
level="ERROR",
|
||||||
enable_output=False,
|
enable_output=True,
|
||||||
)
|
)
|
||||||
return 0, earliest_cleanup_time, last_cleanup_time
|
return 0, earliest_cleanup_time, last_cleanup_time
|
||||||
|
|
||||||
|
|
@ -689,7 +688,7 @@ class KeyedUnifiedLock:
|
||||||
direct_log(
|
direct_log(
|
||||||
f"Error during multiprocess lock cleanup: {e}",
|
f"Error during multiprocess lock cleanup: {e}",
|
||||||
level="ERROR",
|
level="ERROR",
|
||||||
enable_output=False,
|
enable_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Cleanup async locks using generic function
|
# 2. Cleanup async locks using generic function
|
||||||
|
|
@ -718,7 +717,7 @@ class KeyedUnifiedLock:
|
||||||
direct_log(
|
direct_log(
|
||||||
f"Error during async lock cleanup: {e}",
|
f"Error during async lock cleanup: {e}",
|
||||||
level="ERROR",
|
level="ERROR",
|
||||||
enable_output=False,
|
enable_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. Get current status after cleanup
|
# 3. Get current status after cleanup
|
||||||
|
|
@ -772,7 +771,7 @@ class KeyedUnifiedLock:
|
||||||
direct_log(
|
direct_log(
|
||||||
f"Error getting keyed lock status: {e}",
|
f"Error getting keyed lock status: {e}",
|
||||||
level="ERROR",
|
level="ERROR",
|
||||||
enable_output=False,
|
enable_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
return status
|
return status
|
||||||
|
|
@ -797,32 +796,239 @@ class _KeyedLockContext:
|
||||||
if enable_logging is not None
|
if enable_logging is not None
|
||||||
else parent._default_enable_logging
|
else parent._default_enable_logging
|
||||||
)
|
)
|
||||||
self._ul: Optional[List["UnifiedLock"]] = None # set in __aenter__
|
self._ul: Optional[List[Dict[str, Any]]] = None # set in __aenter__
|
||||||
|
|
||||||
# ----- enter -----
|
# ----- enter -----
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
if self._ul is not None:
|
if self._ul is not None:
|
||||||
raise RuntimeError("KeyedUnifiedLock already acquired in current context")
|
raise RuntimeError("KeyedUnifiedLock already acquired in current context")
|
||||||
|
|
||||||
# acquire locks for all keys in the namespace
|
|
||||||
self._ul = []
|
self._ul = []
|
||||||
for key in self._keys:
|
|
||||||
lock = self._parent._get_lock_for_key(
|
try:
|
||||||
self._namespace, key, enable_logging=self._enable_logging
|
# Acquire locks for all keys in the namespace
|
||||||
)
|
for key in self._keys:
|
||||||
await lock.__aenter__()
|
lock = None
|
||||||
inc_debug_n_locks_acquired()
|
entry = None
|
||||||
self._ul.append(lock)
|
|
||||||
return self
|
try:
|
||||||
|
# 1. Get lock object (reference count is incremented here)
|
||||||
|
lock = self._parent._get_lock_for_key(
|
||||||
|
self._namespace, key, enable_logging=self._enable_logging
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Immediately create and add entry to list (critical for rollback to work)
|
||||||
|
entry = {
|
||||||
|
"key": key,
|
||||||
|
"lock": lock,
|
||||||
|
"entered": False,
|
||||||
|
"debug_inc": False,
|
||||||
|
"ref_incremented": True, # Mark that reference count has been incremented
|
||||||
|
}
|
||||||
|
self._ul.append(
|
||||||
|
entry
|
||||||
|
) # Add immediately after _get_lock_for_key for rollback to work
|
||||||
|
|
||||||
|
# 3. Try to acquire the lock
|
||||||
|
# Use try-finally to ensure state is updated atomically
|
||||||
|
lock_acquired = False
|
||||||
|
try:
|
||||||
|
await lock.__aenter__()
|
||||||
|
lock_acquired = True # Lock successfully acquired
|
||||||
|
finally:
|
||||||
|
if lock_acquired:
|
||||||
|
entry["entered"] = True
|
||||||
|
inc_debug_n_locks_acquired()
|
||||||
|
entry["debug_inc"] = True
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# Lock acquisition was cancelled
|
||||||
|
# The finally block above ensures entry["entered"] is correct
|
||||||
|
direct_log(
|
||||||
|
f"Lock acquisition cancelled for key {key}",
|
||||||
|
level="WARNING",
|
||||||
|
enable_output=self._enable_logging,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
# Other exceptions, log and re-raise
|
||||||
|
direct_log(
|
||||||
|
f"Lock acquisition failed for key {key}: {e}",
|
||||||
|
level="ERROR",
|
||||||
|
enable_output=True,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
except BaseException:
|
||||||
|
# Critical: if any exception occurs (including CancelledError) during lock acquisition,
|
||||||
|
# we must rollback all already acquired locks to prevent lock leaks
|
||||||
|
# Use shield to ensure rollback completes
|
||||||
|
await asyncio.shield(self._rollback_acquired_locks())
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _rollback_acquired_locks(self):
|
||||||
|
"""Rollback all acquired locks in case of exception during __aenter__"""
|
||||||
|
if not self._ul:
|
||||||
|
return
|
||||||
|
|
||||||
|
async def rollback_single_entry(entry):
|
||||||
|
"""Rollback a single lock acquisition"""
|
||||||
|
key = entry["key"]
|
||||||
|
lock = entry["lock"]
|
||||||
|
debug_inc = entry["debug_inc"]
|
||||||
|
entered = entry["entered"]
|
||||||
|
ref_incremented = entry.get(
|
||||||
|
"ref_incremented", True
|
||||||
|
) # Default to True for safety
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# 1. If lock was acquired, release it
|
||||||
|
if entered:
|
||||||
|
try:
|
||||||
|
await lock.__aexit__(None, None, None)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(("lock_exit", e))
|
||||||
|
direct_log(
|
||||||
|
f"Lock rollback error for key {key}: {e}",
|
||||||
|
level="ERROR",
|
||||||
|
enable_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Release reference count (if it was incremented)
|
||||||
|
if ref_incremented:
|
||||||
|
try:
|
||||||
|
self._parent._release_lock_for_key(self._namespace, key)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(("ref_release", e))
|
||||||
|
direct_log(
|
||||||
|
f"Lock rollback reference release error for key {key}: {e}",
|
||||||
|
level="ERROR",
|
||||||
|
enable_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Decrement debug counter
|
||||||
|
if debug_inc:
|
||||||
|
try:
|
||||||
|
dec_debug_n_locks_acquired()
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(("debug_dec", e))
|
||||||
|
direct_log(
|
||||||
|
f"Lock rollback counter decrementing error for key {key}: {e}",
|
||||||
|
level="ERROR",
|
||||||
|
enable_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
# Release already acquired locks in reverse order
|
||||||
|
for entry in reversed(self._ul):
|
||||||
|
# Use shield to protect each lock's rollback
|
||||||
|
try:
|
||||||
|
await asyncio.shield(rollback_single_entry(entry))
|
||||||
|
except Exception as e:
|
||||||
|
# Log but continue rolling back other locks
|
||||||
|
direct_log(
|
||||||
|
f"Lock rollback unexpected error for {entry['key']}: {e}",
|
||||||
|
level="ERROR",
|
||||||
|
enable_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._ul = None
|
||||||
|
|
||||||
# ----- exit -----
|
# ----- exit -----
|
||||||
async def __aexit__(self, exc_type, exc, tb):
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
# The UnifiedLock takes care of proper release order
|
if self._ul is None:
|
||||||
for ul, key in zip(reversed(self._ul), reversed(self._keys)):
|
return
|
||||||
await ul.__aexit__(exc_type, exc, tb)
|
|
||||||
self._parent._release_lock_for_key(self._namespace, key)
|
async def release_all_locks():
|
||||||
dec_debug_n_locks_acquired()
|
"""Release all locks with comprehensive error handling, protected from cancellation"""
|
||||||
self._ul = None
|
|
||||||
|
async def release_single_entry(entry, exc_type, exc, tb):
|
||||||
|
"""Release a single lock with full protection"""
|
||||||
|
key = entry["key"]
|
||||||
|
lock = entry["lock"]
|
||||||
|
debug_inc = entry["debug_inc"]
|
||||||
|
entered = entry["entered"]
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# 1. Release the lock
|
||||||
|
if entered:
|
||||||
|
try:
|
||||||
|
await lock.__aexit__(exc_type, exc, tb)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(("lock_exit", e))
|
||||||
|
direct_log(
|
||||||
|
f"Lock release error for key {key}: {e}",
|
||||||
|
level="ERROR",
|
||||||
|
enable_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Release reference count
|
||||||
|
try:
|
||||||
|
self._parent._release_lock_for_key(self._namespace, key)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(("ref_release", e))
|
||||||
|
direct_log(
|
||||||
|
f"Lock release reference error for key {key}: {e}",
|
||||||
|
level="ERROR",
|
||||||
|
enable_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Decrement debug counter
|
||||||
|
if debug_inc:
|
||||||
|
try:
|
||||||
|
dec_debug_n_locks_acquired()
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(("debug_dec", e))
|
||||||
|
direct_log(
|
||||||
|
f"Lock release counter decrementing error for key {key}: {e}",
|
||||||
|
level="ERROR",
|
||||||
|
enable_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
all_errors = []
|
||||||
|
|
||||||
|
# Release locks in reverse order
|
||||||
|
# This entire loop is protected by the outer shield
|
||||||
|
for entry in reversed(self._ul):
|
||||||
|
try:
|
||||||
|
errors = await release_single_entry(entry, exc_type, exc, tb)
|
||||||
|
for error_type, error in errors:
|
||||||
|
all_errors.append((entry["key"], error_type, error))
|
||||||
|
except Exception as e:
|
||||||
|
all_errors.append((entry["key"], "unexpected", e))
|
||||||
|
direct_log(
|
||||||
|
f"Lock release unexpected error for {entry['key']}: {e}",
|
||||||
|
level="ERROR",
|
||||||
|
enable_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return all_errors
|
||||||
|
|
||||||
|
# CRITICAL: Protect the entire release process with shield
|
||||||
|
# This ensures that even if cancellation occurs, all locks are released
|
||||||
|
try:
|
||||||
|
all_errors = await asyncio.shield(release_all_locks())
|
||||||
|
except Exception as e:
|
||||||
|
direct_log(
|
||||||
|
f"Critical error during __aexit__ cleanup: {e}",
|
||||||
|
level="ERROR",
|
||||||
|
enable_output=True,
|
||||||
|
)
|
||||||
|
all_errors = []
|
||||||
|
finally:
|
||||||
|
# Always clear the lock list, even if shield was cancelled
|
||||||
|
self._ul = None
|
||||||
|
|
||||||
|
# If there were release errors and no other exception, raise the first release error
|
||||||
|
if all_errors and exc_type is None:
|
||||||
|
raise all_errors[0][2] # (key, error_type, error)
|
||||||
|
|
||||||
|
|
||||||
def get_internal_lock(enable_logging: bool = False) -> UnifiedLock:
|
def get_internal_lock(enable_logging: bool = False) -> UnifiedLock:
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ from typing import (
|
||||||
Dict,
|
Dict,
|
||||||
)
|
)
|
||||||
from lightrag.prompt import PROMPTS
|
from lightrag.prompt import PROMPTS
|
||||||
|
from lightrag.exceptions import PipelineCancelledException
|
||||||
from lightrag.constants import (
|
from lightrag.constants import (
|
||||||
DEFAULT_MAX_GLEANING,
|
DEFAULT_MAX_GLEANING,
|
||||||
DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE,
|
DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE,
|
||||||
|
|
@ -1603,6 +1604,7 @@ class LightRAG:
|
||||||
"batchs": 0, # Total number of files to be processed
|
"batchs": 0, # Total number of files to be processed
|
||||||
"cur_batch": 0, # Number of files already processed
|
"cur_batch": 0, # Number of files already processed
|
||||||
"request_pending": False, # Clear any previous request
|
"request_pending": False, # Clear any previous request
|
||||||
|
"cancellation_requested": False, # Initialize cancellation flag
|
||||||
"latest_message": "",
|
"latest_message": "",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -1619,6 +1621,22 @@ class LightRAG:
|
||||||
try:
|
try:
|
||||||
# Process documents until no more documents or requests
|
# Process documents until no more documents or requests
|
||||||
while True:
|
while True:
|
||||||
|
# Check for cancellation request at the start of main loop
|
||||||
|
async with pipeline_status_lock:
|
||||||
|
if pipeline_status.get("cancellation_requested", False):
|
||||||
|
# Clear pending request
|
||||||
|
pipeline_status["request_pending"] = False
|
||||||
|
# Celar cancellation flag
|
||||||
|
pipeline_status["cancellation_requested"] = False
|
||||||
|
|
||||||
|
log_message = "Pipeline cancelled by user"
|
||||||
|
logger.info(log_message)
|
||||||
|
pipeline_status["latest_message"] = log_message
|
||||||
|
pipeline_status["history_messages"].append(log_message)
|
||||||
|
|
||||||
|
# Exit directly, skipping request_pending check
|
||||||
|
return
|
||||||
|
|
||||||
if not to_process_docs:
|
if not to_process_docs:
|
||||||
log_message = "All enqueued documents have been processed"
|
log_message = "All enqueued documents have been processed"
|
||||||
logger.info(log_message)
|
logger.info(log_message)
|
||||||
|
|
@ -1681,14 +1699,25 @@ class LightRAG:
|
||||||
semaphore: asyncio.Semaphore,
|
semaphore: asyncio.Semaphore,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Process single document"""
|
"""Process single document"""
|
||||||
|
# Initialize variables at the start to prevent UnboundLocalError in error handling
|
||||||
|
file_path = "unknown_source"
|
||||||
|
current_file_number = 0
|
||||||
file_extraction_stage_ok = False
|
file_extraction_stage_ok = False
|
||||||
|
processing_start_time = int(time.time())
|
||||||
|
first_stage_tasks = []
|
||||||
|
entity_relation_task = None
|
||||||
|
|
||||||
async with semaphore:
|
async with semaphore:
|
||||||
nonlocal processed_count
|
nonlocal processed_count
|
||||||
current_file_number = 0
|
|
||||||
# Initialize to prevent UnboundLocalError in error handling
|
# Initialize to prevent UnboundLocalError in error handling
|
||||||
first_stage_tasks = []
|
first_stage_tasks = []
|
||||||
entity_relation_task = None
|
entity_relation_task = None
|
||||||
try:
|
try:
|
||||||
|
# Check for cancellation before starting document processing
|
||||||
|
async with pipeline_status_lock:
|
||||||
|
if pipeline_status.get("cancellation_requested", False):
|
||||||
|
raise PipelineCancelledException("User cancelled")
|
||||||
|
|
||||||
# Get file path from status document
|
# Get file path from status document
|
||||||
file_path = getattr(
|
file_path = getattr(
|
||||||
status_doc, "file_path", "unknown_source"
|
status_doc, "file_path", "unknown_source"
|
||||||
|
|
@ -1751,6 +1780,11 @@ class LightRAG:
|
||||||
# Record processing start time
|
# Record processing start time
|
||||||
processing_start_time = int(time.time())
|
processing_start_time = int(time.time())
|
||||||
|
|
||||||
|
# Check for cancellation before entity extraction
|
||||||
|
async with pipeline_status_lock:
|
||||||
|
if pipeline_status.get("cancellation_requested", False):
|
||||||
|
raise PipelineCancelledException("User cancelled")
|
||||||
|
|
||||||
# Process document in two stages
|
# Process document in two stages
|
||||||
# Stage 1: Process text chunks and docs (parallel execution)
|
# Stage 1: Process text chunks and docs (parallel execution)
|
||||||
doc_status_task = asyncio.create_task(
|
doc_status_task = asyncio.create_task(
|
||||||
|
|
@ -1805,16 +1839,29 @@ class LightRAG:
|
||||||
file_extraction_stage_ok = True
|
file_extraction_stage_ok = True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log error and update pipeline status
|
# Check if this is a user cancellation
|
||||||
logger.error(traceback.format_exc())
|
if isinstance(e, PipelineCancelledException):
|
||||||
error_msg = f"Failed to extract document {current_file_number}/{total_files}: {file_path}"
|
# User cancellation - log brief message only, no traceback
|
||||||
logger.error(error_msg)
|
error_msg = f"User cancelled {current_file_number}/{total_files}: {file_path}"
|
||||||
async with pipeline_status_lock:
|
logger.warning(error_msg)
|
||||||
pipeline_status["latest_message"] = error_msg
|
async with pipeline_status_lock:
|
||||||
pipeline_status["history_messages"].append(
|
pipeline_status["latest_message"] = error_msg
|
||||||
traceback.format_exc()
|
pipeline_status["history_messages"].append(
|
||||||
)
|
error_msg
|
||||||
pipeline_status["history_messages"].append(error_msg)
|
)
|
||||||
|
else:
|
||||||
|
# Other exceptions - log with traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
error_msg = f"Failed to extract document {current_file_number}/{total_files}: {file_path}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
async with pipeline_status_lock:
|
||||||
|
pipeline_status["latest_message"] = error_msg
|
||||||
|
pipeline_status["history_messages"].append(
|
||||||
|
traceback.format_exc()
|
||||||
|
)
|
||||||
|
pipeline_status["history_messages"].append(
|
||||||
|
error_msg
|
||||||
|
)
|
||||||
|
|
||||||
# Cancel tasks that are not yet completed
|
# Cancel tasks that are not yet completed
|
||||||
all_tasks = first_stage_tasks + (
|
all_tasks = first_stage_tasks + (
|
||||||
|
|
@ -1824,9 +1871,14 @@ class LightRAG:
|
||||||
if task and not task.done():
|
if task and not task.done():
|
||||||
task.cancel()
|
task.cancel()
|
||||||
|
|
||||||
# Persistent llm cache
|
# Persistent llm cache with error handling
|
||||||
if self.llm_response_cache:
|
if self.llm_response_cache:
|
||||||
await self.llm_response_cache.index_done_callback()
|
try:
|
||||||
|
await self.llm_response_cache.index_done_callback()
|
||||||
|
except Exception as persist_error:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to persist LLM cache: {persist_error}"
|
||||||
|
)
|
||||||
|
|
||||||
# Record processing end time for failed case
|
# Record processing end time for failed case
|
||||||
processing_end_time = int(time.time())
|
processing_end_time = int(time.time())
|
||||||
|
|
@ -1856,6 +1908,15 @@ class LightRAG:
|
||||||
# Concurrency is controlled by keyed lock for individual entities and relationships
|
# Concurrency is controlled by keyed lock for individual entities and relationships
|
||||||
if file_extraction_stage_ok:
|
if file_extraction_stage_ok:
|
||||||
try:
|
try:
|
||||||
|
# Check for cancellation before merge
|
||||||
|
async with pipeline_status_lock:
|
||||||
|
if pipeline_status.get(
|
||||||
|
"cancellation_requested", False
|
||||||
|
):
|
||||||
|
raise PipelineCancelledException(
|
||||||
|
"User cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
# Get chunk_results from entity_relation_task
|
# Get chunk_results from entity_relation_task
|
||||||
chunk_results = await entity_relation_task
|
chunk_results = await entity_relation_task
|
||||||
await merge_nodes_and_edges(
|
await merge_nodes_and_edges(
|
||||||
|
|
@ -1914,22 +1975,38 @@ class LightRAG:
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log error and update pipeline status
|
# Check if this is a user cancellation
|
||||||
logger.error(traceback.format_exc())
|
if isinstance(e, PipelineCancelledException):
|
||||||
error_msg = f"Merging stage failed in document {current_file_number}/{total_files}: {file_path}"
|
# User cancellation - log brief message only, no traceback
|
||||||
logger.error(error_msg)
|
error_msg = f"User cancelled during merge {current_file_number}/{total_files}: {file_path}"
|
||||||
async with pipeline_status_lock:
|
logger.warning(error_msg)
|
||||||
pipeline_status["latest_message"] = error_msg
|
async with pipeline_status_lock:
|
||||||
pipeline_status["history_messages"].append(
|
pipeline_status["latest_message"] = error_msg
|
||||||
traceback.format_exc()
|
pipeline_status["history_messages"].append(
|
||||||
)
|
error_msg
|
||||||
pipeline_status["history_messages"].append(
|
)
|
||||||
error_msg
|
else:
|
||||||
)
|
# Other exceptions - log with traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
error_msg = f"Merging stage failed in document {current_file_number}/{total_files}: {file_path}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
async with pipeline_status_lock:
|
||||||
|
pipeline_status["latest_message"] = error_msg
|
||||||
|
pipeline_status["history_messages"].append(
|
||||||
|
traceback.format_exc()
|
||||||
|
)
|
||||||
|
pipeline_status["history_messages"].append(
|
||||||
|
error_msg
|
||||||
|
)
|
||||||
|
|
||||||
# Persistent llm cache
|
# Persistent llm cache with error handling
|
||||||
if self.llm_response_cache:
|
if self.llm_response_cache:
|
||||||
await self.llm_response_cache.index_done_callback()
|
try:
|
||||||
|
await self.llm_response_cache.index_done_callback()
|
||||||
|
except Exception as persist_error:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to persist LLM cache: {persist_error}"
|
||||||
|
)
|
||||||
|
|
||||||
# Record processing end time for failed case
|
# Record processing end time for failed case
|
||||||
processing_end_time = int(time.time())
|
processing_end_time = int(time.time())
|
||||||
|
|
@ -1970,7 +2047,19 @@ class LightRAG:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Wait for all document processing to complete
|
# Wait for all document processing to complete
|
||||||
await asyncio.gather(*doc_tasks)
|
try:
|
||||||
|
await asyncio.gather(*doc_tasks)
|
||||||
|
except PipelineCancelledException:
|
||||||
|
# Cancel all remaining tasks
|
||||||
|
for task in doc_tasks:
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
# Wait for all tasks to complete cancellation
|
||||||
|
await asyncio.wait(doc_tasks, return_when=asyncio.ALL_COMPLETED)
|
||||||
|
|
||||||
|
# Exit directly (document statuses already updated in process_document)
|
||||||
|
return
|
||||||
|
|
||||||
# Check if there's a pending request to process more documents (with lock)
|
# Check if there's a pending request to process more documents (with lock)
|
||||||
has_pending_request = False
|
has_pending_request = False
|
||||||
|
|
@ -2001,11 +2090,14 @@ class LightRAG:
|
||||||
to_process_docs.update(pending_docs)
|
to_process_docs.update(pending_docs)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
log_message = "Enqueued document processing pipeline stoped"
|
log_message = "Enqueued document processing pipeline stopped"
|
||||||
logger.info(log_message)
|
logger.info(log_message)
|
||||||
# Always reset busy status when done or if an exception occurs (with lock)
|
# Always reset busy status and cancellation flag when done or if an exception occurs (with lock)
|
||||||
async with pipeline_status_lock:
|
async with pipeline_status_lock:
|
||||||
pipeline_status["busy"] = False
|
pipeline_status["busy"] = False
|
||||||
|
pipeline_status["cancellation_requested"] = (
|
||||||
|
False # Always reset cancellation flag
|
||||||
|
)
|
||||||
pipeline_status["latest_message"] = log_message
|
pipeline_status["latest_message"] = log_message
|
||||||
pipeline_status["history_messages"].append(log_message)
|
pipeline_status["history_messages"].append(log_message)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
|
@ -7,6 +8,7 @@ import json_repair
|
||||||
from typing import Any, AsyncIterator, overload, Literal
|
from typing import Any, AsyncIterator, overload, Literal
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
|
|
||||||
|
from lightrag.exceptions import PipelineCancelledException
|
||||||
from lightrag.utils import (
|
from lightrag.utils import (
|
||||||
logger,
|
logger,
|
||||||
compute_mdhash_id,
|
compute_mdhash_id,
|
||||||
|
|
@ -67,7 +69,7 @@ from dotenv import load_dotenv
|
||||||
# use the .env that is inside the current folder
|
# use the .env that is inside the current folder
|
||||||
# allows to use different .env file for each lightrag instance
|
# allows to use different .env file for each lightrag instance
|
||||||
# the OS environment variables take precedence over the .env file
|
# the OS environment variables take precedence over the .env file
|
||||||
load_dotenv(dotenv_path=".env", override=False)
|
load_dotenv(dotenv_path=Path(__file__).resolve().parent / ".env", override=False)
|
||||||
|
|
||||||
|
|
||||||
def _truncate_entity_identifier(
|
def _truncate_entity_identifier(
|
||||||
|
|
@ -1637,6 +1639,12 @@ async def _merge_nodes_then_upsert(
|
||||||
logger.error(f"Entity {entity_name} has no description")
|
logger.error(f"Entity {entity_name} has no description")
|
||||||
raise ValueError(f"Entity {entity_name} has no description")
|
raise ValueError(f"Entity {entity_name} has no description")
|
||||||
|
|
||||||
|
# Check for cancellation before LLM summary
|
||||||
|
if pipeline_status is not None and pipeline_status_lock is not None:
|
||||||
|
async with pipeline_status_lock:
|
||||||
|
if pipeline_status.get("cancellation_requested", False):
|
||||||
|
raise PipelineCancelledException("User cancelled during entity summary")
|
||||||
|
|
||||||
# 8. Get summary description an LLM usage status
|
# 8. Get summary description an LLM usage status
|
||||||
description, llm_was_used = await _handle_entity_relation_summary(
|
description, llm_was_used = await _handle_entity_relation_summary(
|
||||||
"Entity",
|
"Entity",
|
||||||
|
|
@ -1957,6 +1965,14 @@ async def _merge_edges_then_upsert(
|
||||||
logger.error(f"Relation {src_id}~{tgt_id} has no description")
|
logger.error(f"Relation {src_id}~{tgt_id} has no description")
|
||||||
raise ValueError(f"Relation {src_id}~{tgt_id} has no description")
|
raise ValueError(f"Relation {src_id}~{tgt_id} has no description")
|
||||||
|
|
||||||
|
# Check for cancellation before LLM summary
|
||||||
|
if pipeline_status is not None and pipeline_status_lock is not None:
|
||||||
|
async with pipeline_status_lock:
|
||||||
|
if pipeline_status.get("cancellation_requested", False):
|
||||||
|
raise PipelineCancelledException(
|
||||||
|
"User cancelled during relation summary"
|
||||||
|
)
|
||||||
|
|
||||||
# 8. Get summary description an LLM usage status
|
# 8. Get summary description an LLM usage status
|
||||||
description, llm_was_used = await _handle_entity_relation_summary(
|
description, llm_was_used = await _handle_entity_relation_summary(
|
||||||
"Relation",
|
"Relation",
|
||||||
|
|
@ -2214,6 +2230,12 @@ async def merge_nodes_and_edges(
|
||||||
file_path: File path for logging
|
file_path: File path for logging
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Check for cancellation at the start of merge
|
||||||
|
if pipeline_status is not None and pipeline_status_lock is not None:
|
||||||
|
async with pipeline_status_lock:
|
||||||
|
if pipeline_status.get("cancellation_requested", False):
|
||||||
|
raise PipelineCancelledException("User cancelled during merge phase")
|
||||||
|
|
||||||
# Collect all nodes and edges from all chunks
|
# Collect all nodes and edges from all chunks
|
||||||
all_nodes = defaultdict(list)
|
all_nodes = defaultdict(list)
|
||||||
all_edges = defaultdict(list)
|
all_edges = defaultdict(list)
|
||||||
|
|
@ -2250,6 +2272,14 @@ async def merge_nodes_and_edges(
|
||||||
|
|
||||||
async def _locked_process_entity_name(entity_name, entities):
|
async def _locked_process_entity_name(entity_name, entities):
|
||||||
async with semaphore:
|
async with semaphore:
|
||||||
|
# Check for cancellation before processing entity
|
||||||
|
if pipeline_status is not None and pipeline_status_lock is not None:
|
||||||
|
async with pipeline_status_lock:
|
||||||
|
if pipeline_status.get("cancellation_requested", False):
|
||||||
|
raise PipelineCancelledException(
|
||||||
|
"User cancelled during entity merge"
|
||||||
|
)
|
||||||
|
|
||||||
workspace = global_config.get("workspace", "")
|
workspace = global_config.get("workspace", "")
|
||||||
namespace = f"{workspace}:GraphDB" if workspace else "GraphDB"
|
namespace = f"{workspace}:GraphDB" if workspace else "GraphDB"
|
||||||
async with get_storage_keyed_lock(
|
async with get_storage_keyed_lock(
|
||||||
|
|
@ -2272,9 +2302,7 @@ async def merge_nodes_and_edges(
|
||||||
return entity_data
|
return entity_data
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = (
|
error_msg = f"Error processing entity `{entity_name}`: {e}"
|
||||||
f"Critical error in entity processing for `{entity_name}`: {e}"
|
|
||||||
)
|
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
|
|
||||||
# Try to update pipeline status, but don't let status update failure affect main exception
|
# Try to update pipeline status, but don't let status update failure affect main exception
|
||||||
|
|
@ -2310,36 +2338,32 @@ async def merge_nodes_and_edges(
|
||||||
entity_tasks, return_when=asyncio.FIRST_EXCEPTION
|
entity_tasks, return_when=asyncio.FIRST_EXCEPTION
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if any task raised an exception and ensure all exceptions are retrieved
|
|
||||||
first_exception = None
|
first_exception = None
|
||||||
successful_results = []
|
processed_entities = []
|
||||||
|
|
||||||
for task in done:
|
for task in done:
|
||||||
try:
|
try:
|
||||||
exception = task.exception()
|
result = task.result()
|
||||||
if exception is not None:
|
except BaseException as e:
|
||||||
if first_exception is None:
|
|
||||||
first_exception = exception
|
|
||||||
else:
|
|
||||||
successful_results.append(task.result())
|
|
||||||
except Exception as e:
|
|
||||||
if first_exception is None:
|
if first_exception is None:
|
||||||
first_exception = e
|
first_exception = e
|
||||||
|
else:
|
||||||
|
processed_entities.append(result)
|
||||||
|
|
||||||
|
if pending:
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
pending_results = await asyncio.gather(*pending, return_exceptions=True)
|
||||||
|
for result in pending_results:
|
||||||
|
if isinstance(result, BaseException):
|
||||||
|
if first_exception is None:
|
||||||
|
first_exception = result
|
||||||
|
else:
|
||||||
|
processed_entities.append(result)
|
||||||
|
|
||||||
# If any task failed, cancel all pending tasks and raise the first exception
|
|
||||||
if first_exception is not None:
|
if first_exception is not None:
|
||||||
# Cancel all pending tasks
|
|
||||||
for pending_task in pending:
|
|
||||||
pending_task.cancel()
|
|
||||||
# Wait for cancellation to complete
|
|
||||||
if pending:
|
|
||||||
await asyncio.wait(pending)
|
|
||||||
# Re-raise the first exception to notify the caller
|
|
||||||
raise first_exception
|
raise first_exception
|
||||||
|
|
||||||
# If all tasks completed successfully, collect results
|
|
||||||
processed_entities = [task.result() for task in entity_tasks]
|
|
||||||
|
|
||||||
# ===== Phase 2: Process all relationships concurrently =====
|
# ===== Phase 2: Process all relationships concurrently =====
|
||||||
log_message = f"Phase 2: Processing {total_relations_count} relations from {doc_id} (async: {graph_max_async})"
|
log_message = f"Phase 2: Processing {total_relations_count} relations from {doc_id} (async: {graph_max_async})"
|
||||||
logger.info(log_message)
|
logger.info(log_message)
|
||||||
|
|
@ -2349,6 +2373,14 @@ async def merge_nodes_and_edges(
|
||||||
|
|
||||||
async def _locked_process_edges(edge_key, edges):
|
async def _locked_process_edges(edge_key, edges):
|
||||||
async with semaphore:
|
async with semaphore:
|
||||||
|
# Check for cancellation before processing edges
|
||||||
|
if pipeline_status is not None and pipeline_status_lock is not None:
|
||||||
|
async with pipeline_status_lock:
|
||||||
|
if pipeline_status.get("cancellation_requested", False):
|
||||||
|
raise PipelineCancelledException(
|
||||||
|
"User cancelled during relation merge"
|
||||||
|
)
|
||||||
|
|
||||||
workspace = global_config.get("workspace", "")
|
workspace = global_config.get("workspace", "")
|
||||||
namespace = f"{workspace}:GraphDB" if workspace else "GraphDB"
|
namespace = f"{workspace}:GraphDB" if workspace else "GraphDB"
|
||||||
sorted_edge_key = sorted([edge_key[0], edge_key[1]])
|
sorted_edge_key = sorted([edge_key[0], edge_key[1]])
|
||||||
|
|
@ -2383,7 +2415,7 @@ async def merge_nodes_and_edges(
|
||||||
return edge_data, added_entities
|
return edge_data, added_entities
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Critical error in relationship processing for `{sorted_edge_key}`: {e}"
|
error_msg = f"Error processing relation `{sorted_edge_key}`: {e}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
|
|
||||||
# Try to update pipeline status, but don't let status update failure affect main exception
|
# Try to update pipeline status, but don't let status update failure affect main exception
|
||||||
|
|
@ -2421,40 +2453,36 @@ async def merge_nodes_and_edges(
|
||||||
edge_tasks, return_when=asyncio.FIRST_EXCEPTION
|
edge_tasks, return_when=asyncio.FIRST_EXCEPTION
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if any task raised an exception and ensure all exceptions are retrieved
|
|
||||||
first_exception = None
|
first_exception = None
|
||||||
successful_results = []
|
|
||||||
|
|
||||||
for task in done:
|
for task in done:
|
||||||
try:
|
try:
|
||||||
exception = task.exception()
|
edge_data, added_entities = task.result()
|
||||||
if exception is not None:
|
except BaseException as e:
|
||||||
if first_exception is None:
|
|
||||||
first_exception = exception
|
|
||||||
else:
|
|
||||||
successful_results.append(task.result())
|
|
||||||
except Exception as e:
|
|
||||||
if first_exception is None:
|
if first_exception is None:
|
||||||
first_exception = e
|
first_exception = e
|
||||||
|
else:
|
||||||
|
if edge_data is not None:
|
||||||
|
processed_edges.append(edge_data)
|
||||||
|
all_added_entities.extend(added_entities)
|
||||||
|
|
||||||
|
if pending:
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
pending_results = await asyncio.gather(*pending, return_exceptions=True)
|
||||||
|
for result in pending_results:
|
||||||
|
if isinstance(result, BaseException):
|
||||||
|
if first_exception is None:
|
||||||
|
first_exception = result
|
||||||
|
else:
|
||||||
|
edge_data, added_entities = result
|
||||||
|
if edge_data is not None:
|
||||||
|
processed_edges.append(edge_data)
|
||||||
|
all_added_entities.extend(added_entities)
|
||||||
|
|
||||||
# If any task failed, cancel all pending tasks and raise the first exception
|
|
||||||
if first_exception is not None:
|
if first_exception is not None:
|
||||||
# Cancel all pending tasks
|
|
||||||
for pending_task in pending:
|
|
||||||
pending_task.cancel()
|
|
||||||
# Wait for cancellation to complete
|
|
||||||
if pending:
|
|
||||||
await asyncio.wait(pending)
|
|
||||||
# Re-raise the first exception to notify the caller
|
|
||||||
raise first_exception
|
raise first_exception
|
||||||
|
|
||||||
# If all tasks completed successfully, collect results
|
|
||||||
for task in edge_tasks:
|
|
||||||
edge_data, added_entities = task.result()
|
|
||||||
if edge_data is not None:
|
|
||||||
processed_edges.append(edge_data)
|
|
||||||
all_added_entities.extend(added_entities)
|
|
||||||
|
|
||||||
# ===== Phase 3: Update full_entities and full_relations storage =====
|
# ===== Phase 3: Update full_entities and full_relations storage =====
|
||||||
if full_entities_storage and full_relations_storage and doc_id:
|
if full_entities_storage and full_relations_storage and doc_id:
|
||||||
try:
|
try:
|
||||||
|
|
@ -2535,6 +2563,14 @@ async def extract_entities(
|
||||||
llm_response_cache: BaseKVStorage | None = None,
|
llm_response_cache: BaseKVStorage | None = None,
|
||||||
text_chunks_storage: BaseKVStorage | None = None,
|
text_chunks_storage: BaseKVStorage | None = None,
|
||||||
) -> list:
|
) -> list:
|
||||||
|
# Check for cancellation at the start of entity extraction
|
||||||
|
if pipeline_status is not None and pipeline_status_lock is not None:
|
||||||
|
async with pipeline_status_lock:
|
||||||
|
if pipeline_status.get("cancellation_requested", False):
|
||||||
|
raise PipelineCancelledException(
|
||||||
|
"User cancelled during entity extraction"
|
||||||
|
)
|
||||||
|
|
||||||
use_llm_func: callable = global_config["llm_model_func"]
|
use_llm_func: callable = global_config["llm_model_func"]
|
||||||
entity_extract_max_gleaning = global_config["entity_extract_max_gleaning"]
|
entity_extract_max_gleaning = global_config["entity_extract_max_gleaning"]
|
||||||
|
|
||||||
|
|
@ -2702,6 +2738,14 @@ async def extract_entities(
|
||||||
|
|
||||||
async def _process_with_semaphore(chunk):
|
async def _process_with_semaphore(chunk):
|
||||||
async with semaphore:
|
async with semaphore:
|
||||||
|
# Check for cancellation before processing chunk
|
||||||
|
if pipeline_status is not None and pipeline_status_lock is not None:
|
||||||
|
async with pipeline_status_lock:
|
||||||
|
if pipeline_status.get("cancellation_requested", False):
|
||||||
|
raise PipelineCancelledException(
|
||||||
|
"User cancelled during chunk processing"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return await _process_single_content(chunk)
|
return await _process_single_content(chunk)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,7 @@ export type PipelineStatusResponse = {
|
||||||
batchs: number
|
batchs: number
|
||||||
cur_batch: number
|
cur_batch: number
|
||||||
request_pending: boolean
|
request_pending: boolean
|
||||||
|
cancellation_requested?: boolean
|
||||||
latest_message: string
|
latest_message: string
|
||||||
history_messages?: string[]
|
history_messages?: string[]
|
||||||
update_status?: Record<string, any>
|
update_status?: Record<string, any>
|
||||||
|
|
@ -691,6 +692,14 @@ export const getPipelineStatus = async (): Promise<PipelineStatusResponse> => {
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const cancelPipeline = async (): Promise<{
|
||||||
|
status: 'cancellation_requested' | 'not_busy'
|
||||||
|
message: string
|
||||||
|
}> => {
|
||||||
|
const response = await axiosInstance.post('/documents/cancel_pipeline')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
export const loginToServer = async (username: string, password: string): Promise<LoginResponse> => {
|
export const loginToServer = async (username: string, password: string): Promise<LoginResponse> => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('username', username);
|
formData.append('username', username);
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
DialogDescription
|
DialogDescription
|
||||||
} from '@/components/ui/Dialog'
|
} from '@/components/ui/Dialog'
|
||||||
import Button from '@/components/ui/Button'
|
import Button from '@/components/ui/Button'
|
||||||
import { getPipelineStatus, PipelineStatusResponse } from '@/api/lightrag'
|
import { getPipelineStatus, cancelPipeline, PipelineStatusResponse } from '@/api/lightrag'
|
||||||
import { errorMessage } from '@/lib/utils'
|
import { errorMessage } from '@/lib/utils'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
|
@ -30,6 +30,7 @@ export default function PipelineStatusDialog({
|
||||||
const [status, setStatus] = useState<PipelineStatusResponse | null>(null)
|
const [status, setStatus] = useState<PipelineStatusResponse | null>(null)
|
||||||
const [position, setPosition] = useState<DialogPosition>('center')
|
const [position, setPosition] = useState<DialogPosition>('center')
|
||||||
const [isUserScrolled, setIsUserScrolled] = useState(false)
|
const [isUserScrolled, setIsUserScrolled] = useState(false)
|
||||||
|
const [showCancelConfirm, setShowCancelConfirm] = useState(false)
|
||||||
const historyRef = useRef<HTMLDivElement>(null)
|
const historyRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// Reset position when dialog opens
|
// Reset position when dialog opens
|
||||||
|
|
@ -37,6 +38,9 @@ export default function PipelineStatusDialog({
|
||||||
if (open) {
|
if (open) {
|
||||||
setPosition('center')
|
setPosition('center')
|
||||||
setIsUserScrolled(false)
|
setIsUserScrolled(false)
|
||||||
|
} else {
|
||||||
|
// Reset confirmation dialog state when main dialog closes
|
||||||
|
setShowCancelConfirm(false)
|
||||||
}
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
|
|
@ -81,6 +85,24 @@ export default function PipelineStatusDialog({
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [open, t])
|
}, [open, t])
|
||||||
|
|
||||||
|
// Handle cancel pipeline confirmation
|
||||||
|
const handleConfirmCancel = async () => {
|
||||||
|
setShowCancelConfirm(false)
|
||||||
|
try {
|
||||||
|
const result = await cancelPipeline()
|
||||||
|
if (result.status === 'cancellation_requested') {
|
||||||
|
toast.success(t('documentPanel.pipelineStatus.cancelSuccess'))
|
||||||
|
} else if (result.status === 'not_busy') {
|
||||||
|
toast.info(t('documentPanel.pipelineStatus.cancelNotBusy'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t('documentPanel.pipelineStatus.cancelFailed', { error: errorMessage(err) }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if cancel button should be enabled
|
||||||
|
const canCancel = status?.busy === true && !status?.cancellation_requested
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
|
|
@ -142,16 +164,43 @@ export default function PipelineStatusDialog({
|
||||||
|
|
||||||
{/* Status Content */}
|
{/* Status Content */}
|
||||||
<div className="space-y-4 pt-4">
|
<div className="space-y-4 pt-4">
|
||||||
{/* Pipeline Status */}
|
{/* Pipeline Status - with cancel button */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-2">
|
{/* Left side: Status indicators */}
|
||||||
<div className="text-sm font-medium">{t('documentPanel.pipelineStatus.busy')}:</div>
|
<div className="flex items-center gap-4">
|
||||||
<div className={`h-2 w-2 rounded-full ${status?.busy ? 'bg-green-500' : 'bg-gray-300'}`} />
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<div className="text-sm font-medium">{t('documentPanel.pipelineStatus.busy')}:</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className={`h-2 w-2 rounded-full ${status?.busy ? 'bg-green-500' : 'bg-gray-300'}`} />
|
||||||
<div className="text-sm font-medium">{t('documentPanel.pipelineStatus.requestPending')}:</div>
|
</div>
|
||||||
<div className={`h-2 w-2 rounded-full ${status?.request_pending ? 'bg-green-500' : 'bg-gray-300'}`} />
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-sm font-medium">{t('documentPanel.pipelineStatus.requestPending')}:</div>
|
||||||
|
<div className={`h-2 w-2 rounded-full ${status?.request_pending ? 'bg-green-500' : 'bg-gray-300'}`} />
|
||||||
|
</div>
|
||||||
|
{/* Only show cancellation status when it's requested */}
|
||||||
|
{status?.cancellation_requested && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-sm font-medium">{t('documentPanel.pipelineStatus.cancellationRequested')}:</div>
|
||||||
|
<div className="h-2 w-2 rounded-full bg-red-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Right side: Cancel button - only show when pipeline is busy */}
|
||||||
|
{status?.busy && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={!canCancel}
|
||||||
|
onClick={() => setShowCancelConfirm(true)}
|
||||||
|
title={
|
||||||
|
status?.cancellation_requested
|
||||||
|
? t('documentPanel.pipelineStatus.cancelInProgress')
|
||||||
|
: t('documentPanel.pipelineStatus.cancelTooltip')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('documentPanel.pipelineStatus.cancelButton')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Job Information */}
|
{/* Job Information */}
|
||||||
|
|
@ -172,31 +221,49 @@ export default function PipelineStatusDialog({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Latest Message */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-sm font-medium">{t('documentPanel.pipelineStatus.latestMessage')}:</div>
|
|
||||||
<div className="font-mono text-xs rounded-md bg-zinc-800 text-zinc-100 p-3 whitespace-pre-wrap break-words">
|
|
||||||
{status?.latest_message || '-'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* History Messages */}
|
{/* History Messages */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-sm font-medium">{t('documentPanel.pipelineStatus.historyMessages')}:</div>
|
<div className="text-sm font-medium">{t('documentPanel.pipelineStatus.pipelineMessages')}:</div>
|
||||||
<div
|
<div
|
||||||
ref={historyRef}
|
ref={historyRef}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
className="font-mono text-xs rounded-md bg-zinc-800 text-zinc-100 p-3 overflow-y-auto min-h-[7.5em] max-h-[40vh]"
|
className="font-mono text-xs rounded-md bg-zinc-800 text-zinc-100 p-3 overflow-y-auto overflow-x-hidden min-h-[7.5em] max-h-[40vh]"
|
||||||
>
|
>
|
||||||
{status?.history_messages?.length ? (
|
{status?.history_messages?.length ? (
|
||||||
status.history_messages.map((msg, idx) => (
|
status.history_messages.map((msg, idx) => (
|
||||||
<div key={idx} className="whitespace-pre-wrap break-words">{msg}</div>
|
<div key={idx} className="whitespace-pre-wrap break-all">{msg}</div>
|
||||||
))
|
))
|
||||||
) : '-'}
|
) : '-'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
|
{/* Cancel Confirmation Dialog */}
|
||||||
|
<Dialog open={showCancelConfirm} onOpenChange={setShowCancelConfirm}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('documentPanel.pipelineStatus.cancelConfirmTitle')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('documentPanel.pipelineStatus.cancelConfirmDescription')}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex justify-end gap-3 mt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowCancelConfirm(false)}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleConfirmCancel}
|
||||||
|
>
|
||||||
|
{t('documentPanel.pipelineStatus.cancelConfirmButton')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ import PaginationControls from '@/components/ui/PaginationControls'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
scanNewDocuments,
|
scanNewDocuments,
|
||||||
reprocessFailedDocuments,
|
|
||||||
getDocumentsPaginated,
|
getDocumentsPaginated,
|
||||||
DocsStatusesResponse,
|
DocsStatusesResponse,
|
||||||
DocStatus,
|
DocStatus,
|
||||||
|
|
@ -868,42 +867,6 @@ export default function DocumentManager() {
|
||||||
}
|
}
|
||||||
}, [t, startPollingInterval, currentTab, health, statusCounts])
|
}, [t, startPollingInterval, currentTab, health, statusCounts])
|
||||||
|
|
||||||
const retryFailedDocuments = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
// Check if component is still mounted before starting the request
|
|
||||||
if (!isMountedRef.current) return;
|
|
||||||
|
|
||||||
const { status, message, track_id: _track_id } = await reprocessFailedDocuments(); // eslint-disable-line @typescript-eslint/no-unused-vars
|
|
||||||
|
|
||||||
// Check again if component is still mounted after the request completes
|
|
||||||
if (!isMountedRef.current) return;
|
|
||||||
|
|
||||||
// Note: _track_id is available for future use (e.g., progress tracking)
|
|
||||||
toast.message(message || status);
|
|
||||||
|
|
||||||
// Reset health check timer with 1 second delay to avoid race condition
|
|
||||||
useBackendState.getState().resetHealthCheckTimerDelayed(1000);
|
|
||||||
|
|
||||||
// Start fast refresh with 2-second interval immediately after retry
|
|
||||||
startPollingInterval(2000);
|
|
||||||
|
|
||||||
// Set recovery timer to restore normal polling interval after 15 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
if (isMountedRef.current && currentTab === 'documents' && health) {
|
|
||||||
// Restore intelligent polling interval based on document status
|
|
||||||
const hasActiveDocuments = hasActiveDocumentsStatus(statusCounts);
|
|
||||||
const normalInterval = hasActiveDocuments ? 5000 : 30000;
|
|
||||||
startPollingInterval(normalInterval);
|
|
||||||
}
|
|
||||||
}, 15000); // Restore after 15 seconds
|
|
||||||
} catch (err) {
|
|
||||||
// Only show error if component is still mounted
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
toast.error(errorMessage(err));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [startPollingInterval, currentTab, health, statusCounts])
|
|
||||||
|
|
||||||
// Handle page size change - update state and save to store
|
// Handle page size change - update state and save to store
|
||||||
const handlePageSizeChange = useCallback((newPageSize: number) => {
|
const handlePageSizeChange = useCallback((newPageSize: number) => {
|
||||||
if (newPageSize === pagination.page_size) return;
|
if (newPageSize === pagination.page_size) return;
|
||||||
|
|
@ -1166,16 +1129,6 @@ export default function DocumentManager() {
|
||||||
>
|
>
|
||||||
<RefreshCwIcon /> {t('documentPanel.documentManager.scanButton')}
|
<RefreshCwIcon /> {t('documentPanel.documentManager.scanButton')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={retryFailedDocuments}
|
|
||||||
side="bottom"
|
|
||||||
tooltip={t('documentPanel.documentManager.retryFailedTooltip')}
|
|
||||||
size="sm"
|
|
||||||
disabled={pipelineBusy}
|
|
||||||
>
|
|
||||||
<RotateCcwIcon /> {t('documentPanel.documentManager.retryFailedButton')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowPipelineStatus(true)}
|
onClick={() => setShowPipelineStatus(true)}
|
||||||
|
|
|
||||||
|
|
@ -114,10 +114,8 @@
|
||||||
},
|
},
|
||||||
"documentManager": {
|
"documentManager": {
|
||||||
"title": "إدارة المستندات",
|
"title": "إدارة المستندات",
|
||||||
"scanButton": "مسح ضوئي",
|
"scanButton": "مسح/إعادة محاولة",
|
||||||
"scanTooltip": "مسح ومعالجة المستندات في مجلد الإدخال، وإعادة معالجة جميع المستندات الفاشلة أيضًا",
|
"scanTooltip": "مسح ومعالجة المستندات في مجلد الإدخال، وإعادة معالجة جميع المستندات الفاشلة أيضًا",
|
||||||
"retryFailedButton": "إعادة المحاولة",
|
|
||||||
"retryFailedTooltip": "إعادة معالجة جميع المستندات الفاشلة",
|
|
||||||
"refreshTooltip": "إعادة تعيين قائمة المستندات",
|
"refreshTooltip": "إعادة تعيين قائمة المستندات",
|
||||||
"pipelineStatusButton": "خط المعالجة",
|
"pipelineStatusButton": "خط المعالجة",
|
||||||
"pipelineStatusTooltip": "عرض حالة خط معالجة المستندات",
|
"pipelineStatusTooltip": "عرض حالة خط معالجة المستندات",
|
||||||
|
|
@ -157,17 +155,27 @@
|
||||||
"hideFileNameTooltip": "إخفاء اسم الملف"
|
"hideFileNameTooltip": "إخفاء اسم الملف"
|
||||||
},
|
},
|
||||||
"pipelineStatus": {
|
"pipelineStatus": {
|
||||||
"title": "حالة خط المعالجة",
|
"title": "حالة خط الأنابيب",
|
||||||
"busy": "خط المعالجة مشغول",
|
"busy": "خط الأنابيب مشغول",
|
||||||
"requestPending": "الطلب معلق",
|
"requestPending": "طلب معلق",
|
||||||
|
"cancellationRequested": "طلب الإلغاء",
|
||||||
"jobName": "اسم المهمة",
|
"jobName": "اسم المهمة",
|
||||||
"startTime": "وقت البدء",
|
"startTime": "وقت البدء",
|
||||||
"progress": "التقدم",
|
"progress": "التقدم",
|
||||||
"unit": "دفعة",
|
"unit": "دفعة",
|
||||||
"latestMessage": "آخر رسالة",
|
"pipelineMessages": "رسائل خط الأنابيب",
|
||||||
"historyMessages": "سجل الرسائل",
|
"cancelButton": "إلغاء",
|
||||||
|
"cancelTooltip": "إلغاء معالجة خط الأنابيب",
|
||||||
|
"cancelConfirmTitle": "تأكيد إلغاء خط الأنابيب",
|
||||||
|
"cancelConfirmDescription": "سيؤدي هذا الإجراء إلى إيقاف معالجة خط الأنابيب الجارية. هل أنت متأكد من أنك تريد المتابعة؟",
|
||||||
|
"cancelConfirmButton": "تأكيد الإلغاء",
|
||||||
|
"cancelInProgress": "الإلغاء قيد التقدم...",
|
||||||
|
"pipelineNotRunning": "خط الأنابيب غير قيد التشغيل",
|
||||||
|
"cancelSuccess": "تم طلب إلغاء خط الأنابيب",
|
||||||
|
"cancelFailed": "فشل إلغاء خط الأنابيب\n{{error}}",
|
||||||
|
"cancelNotBusy": "خط الأنابيب غير قيد التشغيل، لا حاجة للإلغاء",
|
||||||
"errors": {
|
"errors": {
|
||||||
"fetchFailed": "فشل في جلب حالة خط المعالجة\n{{error}}"
|
"fetchFailed": "فشل في جلب حالة خط الأنابيب\n{{error}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -114,10 +114,8 @@
|
||||||
},
|
},
|
||||||
"documentManager": {
|
"documentManager": {
|
||||||
"title": "Document Management",
|
"title": "Document Management",
|
||||||
"scanButton": "Scan",
|
"scanButton": "Scan/Retry",
|
||||||
"scanTooltip": "Scan and process documents in input folder, and also reprocess all failed documents",
|
"scanTooltip": "Scan and process documents in input folder, and also reprocess all failed documents",
|
||||||
"retryFailedButton": "Retry",
|
|
||||||
"retryFailedTooltip": "Retry processing all failed documents",
|
|
||||||
"refreshTooltip": "Reset document list",
|
"refreshTooltip": "Reset document list",
|
||||||
"pipelineStatusButton": "Pipeline",
|
"pipelineStatusButton": "Pipeline",
|
||||||
"pipelineStatusTooltip": "View document processing pipeline status",
|
"pipelineStatusTooltip": "View document processing pipeline status",
|
||||||
|
|
@ -160,14 +158,24 @@
|
||||||
"title": "Pipeline Status",
|
"title": "Pipeline Status",
|
||||||
"busy": "Pipeline Busy",
|
"busy": "Pipeline Busy",
|
||||||
"requestPending": "Request Pending",
|
"requestPending": "Request Pending",
|
||||||
|
"cancellationRequested": "Cancellation Requested",
|
||||||
"jobName": "Job Name",
|
"jobName": "Job Name",
|
||||||
"startTime": "Start Time",
|
"startTime": "Start Time",
|
||||||
"progress": "Progress",
|
"progress": "Progress",
|
||||||
"unit": "batch",
|
"unit": "Batch",
|
||||||
"latestMessage": "Latest Message",
|
"pipelineMessages": "Pipeline Messages",
|
||||||
"historyMessages": "History Messages",
|
"cancelButton": "Cancel",
|
||||||
|
"cancelTooltip": "Cancel pipeline processing",
|
||||||
|
"cancelConfirmTitle": "Confirm Pipeline Cancellation",
|
||||||
|
"cancelConfirmDescription": "This will interrupt the ongoing pipeline processing. Are you sure you want to continue?",
|
||||||
|
"cancelConfirmButton": "Confirm Cancellation",
|
||||||
|
"cancelInProgress": "Cancellation in progress...",
|
||||||
|
"pipelineNotRunning": "Pipeline not running",
|
||||||
|
"cancelSuccess": "Pipeline cancellation requested",
|
||||||
|
"cancelFailed": "Failed to cancel pipeline\n{{error}}",
|
||||||
|
"cancelNotBusy": "Pipeline is not running, no need to cancel",
|
||||||
"errors": {
|
"errors": {
|
||||||
"fetchFailed": "Failed to get pipeline status\n{{error}}"
|
"fetchFailed": "Failed to fetch pipeline status\n{{error}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -114,10 +114,8 @@
|
||||||
},
|
},
|
||||||
"documentManager": {
|
"documentManager": {
|
||||||
"title": "Gestion des documents",
|
"title": "Gestion des documents",
|
||||||
"scanButton": "Scanner",
|
"scanButton": "Scanner/Retraiter",
|
||||||
"scanTooltip": "Scanner et traiter les documents dans le dossier d'entrée, et retraiter également tous les documents échoués",
|
"scanTooltip": "Scanner et traiter les documents dans le dossier d'entrée, et retraiter également tous les documents échoués",
|
||||||
"retryFailedButton": "Réessayer",
|
|
||||||
"retryFailedTooltip": "Réessayer le traitement de tous les documents échoués",
|
|
||||||
"refreshTooltip": "Réinitialiser la liste des documents",
|
"refreshTooltip": "Réinitialiser la liste des documents",
|
||||||
"pipelineStatusButton": "Pipeline",
|
"pipelineStatusButton": "Pipeline",
|
||||||
"pipelineStatusTooltip": "Voir l'état du pipeline de traitement des documents",
|
"pipelineStatusTooltip": "Voir l'état du pipeline de traitement des documents",
|
||||||
|
|
@ -158,14 +156,24 @@
|
||||||
},
|
},
|
||||||
"pipelineStatus": {
|
"pipelineStatus": {
|
||||||
"title": "État du Pipeline",
|
"title": "État du Pipeline",
|
||||||
"busy": "Pipeline occupé",
|
"busy": "Pipeline Occupé",
|
||||||
"requestPending": "Requête en attente",
|
"requestPending": "Demande en Attente",
|
||||||
"jobName": "Nom du travail",
|
"cancellationRequested": "Annulation Demandée",
|
||||||
"startTime": "Heure de début",
|
"jobName": "Nom du Travail",
|
||||||
"progress": "Progression",
|
"startTime": "Heure de Début",
|
||||||
"unit": "lot",
|
"progress": "Progrès",
|
||||||
"latestMessage": "Dernier message",
|
"unit": "Lot",
|
||||||
"historyMessages": "Historique des messages",
|
"pipelineMessages": "Messages de Pipeline",
|
||||||
|
"cancelButton": "Annuler",
|
||||||
|
"cancelTooltip": "Annuler le traitement du pipeline",
|
||||||
|
"cancelConfirmTitle": "Confirmer l'Annulation du Pipeline",
|
||||||
|
"cancelConfirmDescription": "Cette action interrompra le traitement du pipeline en cours. Êtes-vous sûr de vouloir continuer ?",
|
||||||
|
"cancelConfirmButton": "Confirmer l'Annulation",
|
||||||
|
"cancelInProgress": "Annulation en cours...",
|
||||||
|
"pipelineNotRunning": "Le pipeline n'est pas en cours d'exécution",
|
||||||
|
"cancelSuccess": "Annulation du pipeline demandée",
|
||||||
|
"cancelFailed": "Échec de l'annulation du pipeline\n{{error}}",
|
||||||
|
"cancelNotBusy": "Le pipeline n'est pas en cours d'exécution, pas besoin d'annuler",
|
||||||
"errors": {
|
"errors": {
|
||||||
"fetchFailed": "Échec de la récupération de l'état du pipeline\n{{error}}"
|
"fetchFailed": "Échec de la récupération de l'état du pipeline\n{{error}}"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -114,10 +114,8 @@
|
||||||
},
|
},
|
||||||
"documentManager": {
|
"documentManager": {
|
||||||
"title": "文档管理",
|
"title": "文档管理",
|
||||||
"scanButton": "扫描",
|
"scanButton": "扫描/重试",
|
||||||
"scanTooltip": "扫描处理输入目录中的文档,同时重新处理所有失败的文档",
|
"scanTooltip": "扫描处理输入目录中的文档,同时重新处理所有失败的文档",
|
||||||
"retryFailedButton": "重试",
|
|
||||||
"retryFailedTooltip": "重新处理所有失败的文档",
|
|
||||||
"refreshTooltip": "复位文档清单",
|
"refreshTooltip": "复位文档清单",
|
||||||
"pipelineStatusButton": "流水线",
|
"pipelineStatusButton": "流水线",
|
||||||
"pipelineStatusTooltip": "查看文档处理流水线状态",
|
"pipelineStatusTooltip": "查看文档处理流水线状态",
|
||||||
|
|
@ -160,12 +158,22 @@
|
||||||
"title": "流水线状态",
|
"title": "流水线状态",
|
||||||
"busy": "流水线忙碌",
|
"busy": "流水线忙碌",
|
||||||
"requestPending": "待处理请求",
|
"requestPending": "待处理请求",
|
||||||
|
"cancellationRequested": "取消请求",
|
||||||
"jobName": "作业名称",
|
"jobName": "作业名称",
|
||||||
"startTime": "开始时间",
|
"startTime": "开始时间",
|
||||||
"progress": "进度",
|
"progress": "进度",
|
||||||
"unit": "批",
|
"unit": "批",
|
||||||
"latestMessage": "最新消息",
|
"pipelineMessages": "流水线消息",
|
||||||
"historyMessages": "历史消息",
|
"cancelButton": "中断",
|
||||||
|
"cancelTooltip": "中断流水线处理",
|
||||||
|
"cancelConfirmTitle": "确认中断流水线",
|
||||||
|
"cancelConfirmDescription": "此操作将中断正在进行的流水线处理。确定要继续吗?",
|
||||||
|
"cancelConfirmButton": "确认中断",
|
||||||
|
"cancelInProgress": "取消请求进行中...",
|
||||||
|
"pipelineNotRunning": "流水线未运行",
|
||||||
|
"cancelSuccess": "流水线中断请求已发送",
|
||||||
|
"cancelFailed": "中断流水线失败\n{{error}}",
|
||||||
|
"cancelNotBusy": "流水线未运行,无需中断",
|
||||||
"errors": {
|
"errors": {
|
||||||
"fetchFailed": "获取流水线状态失败\n{{error}}"
|
"fetchFailed": "获取流水线状态失败\n{{error}}"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -114,10 +114,8 @@
|
||||||
},
|
},
|
||||||
"documentManager": {
|
"documentManager": {
|
||||||
"title": "文件管理",
|
"title": "文件管理",
|
||||||
"scanButton": "掃描",
|
"scanButton": "掃描/重試",
|
||||||
"scanTooltip": "掃描處理輸入目錄中的文件,同時重新處理所有失敗的文件",
|
"scanTooltip": "掃描處理輸入目錄中的文件,同時重新處理所有失敗的文件",
|
||||||
"retryFailedButton": "重試",
|
|
||||||
"retryFailedTooltip": "重新處理所有失敗的文件",
|
|
||||||
"refreshTooltip": "重設文件清單",
|
"refreshTooltip": "重設文件清單",
|
||||||
"pipelineStatusButton": "管線狀態",
|
"pipelineStatusButton": "管線狀態",
|
||||||
"pipelineStatusTooltip": "查看文件處理管線狀態",
|
"pipelineStatusTooltip": "查看文件處理管線狀態",
|
||||||
|
|
@ -157,17 +155,27 @@
|
||||||
"hideFileNameTooltip": "隱藏檔案名稱"
|
"hideFileNameTooltip": "隱藏檔案名稱"
|
||||||
},
|
},
|
||||||
"pipelineStatus": {
|
"pipelineStatus": {
|
||||||
"title": "pipeline 狀態",
|
"title": "流水線狀態",
|
||||||
"busy": "pipeline 忙碌中",
|
"busy": "流水線忙碌",
|
||||||
"requestPending": "待處理請求",
|
"requestPending": "待處理請求",
|
||||||
"jobName": "工作名稱",
|
"cancellationRequested": "取消請求",
|
||||||
|
"jobName": "作業名稱",
|
||||||
"startTime": "開始時間",
|
"startTime": "開始時間",
|
||||||
"progress": "進度",
|
"progress": "進度",
|
||||||
"unit": "梯次",
|
"unit": "批",
|
||||||
"latestMessage": "最新訊息",
|
"pipelineMessages": "流水線消息",
|
||||||
"historyMessages": "歷史訊息",
|
"cancelButton": "中斷",
|
||||||
|
"cancelTooltip": "中斷流水線處理",
|
||||||
|
"cancelConfirmTitle": "確認中斷流水線",
|
||||||
|
"cancelConfirmDescription": "此操作將中斷正在進行的流水線處理。確定要繼續嗎?",
|
||||||
|
"cancelConfirmButton": "確認中斷",
|
||||||
|
"cancelInProgress": "取消請求進行中...",
|
||||||
|
"pipelineNotRunning": "流水線未運行",
|
||||||
|
"cancelSuccess": "流水線中斷請求已發送",
|
||||||
|
"cancelFailed": "中斷流水線失敗\n{{error}}",
|
||||||
|
"cancelNotBusy": "流水線未運行,無需中斷",
|
||||||
"errors": {
|
"errors": {
|
||||||
"fetchFailed": "取得pipeline 狀態失敗\n{{error}}"
|
"fetchFailed": "獲取流水線狀態失敗\n{{error}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue