Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion examples/01_simple_document_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,9 @@
print(" - Task_0.1.65536.v0.bucket - Data bucket with Task documents")
print(" - dictionary.0.327680.v0.dict - String compression dictionary")
print(" - statistics.json - Database statistics")
print(" • ./log/ - ArcadeDB and JVM log files")
print(
" • ARCADEDB_LOG_DIR (default: a per-user state directory) - ArcadeDB and JVM log files"
)
print()
print("💡 Next steps:")
print(" • See docs/examples/ for detailed explanations")
Expand Down
6 changes: 3 additions & 3 deletions examples/04_csv_import_documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -963,7 +963,7 @@ def check_dataset_exists(data_dir):
"--heap-size",
type=str,
default=None,
help="Set JVM max heap size (e.g. 8g, 4096m). Overrides default 4g.",
help="Set JVM max heap size (e.g. 8g, 4096m). Default: 50 percent of the RAM the JVM sees.",
)
parser.add_argument(
"--db-name",
Expand Down Expand Up @@ -1017,8 +1017,8 @@ def check_dataset_exists(data_dir):
if args.heap_size:
print(f"💡 JVM Max Heap: {args.heap_size} (from --heap-size)")
else:
print("💡 JVM Max Heap: 4g (default)")
print(" ℹ️ Using default JVM heap (4g)")
print("💡 JVM Max Heap: 50 percent of RAM (default)")
print(" ℹ️ Using default JVM heap (50 percent of RAM)")
if args.dataset == "movielens-large":
print(" 💡 For large datasets, you can increase it:")
print(" Use --heap-size 8g (or higher)")
Expand Down
2 changes: 1 addition & 1 deletion examples/06_vector_search_recommendations.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,7 @@ def main():
"--heap-size",
type=str,
default=None,
help="Set JVM max heap size (e.g. 8g, 4096m). Overrides default 4g.",
help="Set JVM max heap size (e.g. 8g, 4096m). Default: 50 percent of the RAM the JVM sees.",
)

args = parser.parse_args()
Expand Down
27 changes: 16 additions & 11 deletions src/arcadedb_embedded/async_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,8 @@ def set_transaction_use_wal(self, use_wal: bool) -> "AsyncExecutor":
Enable/disable Write-Ahead Log for async operations.

Disabling WAL is faster but less durable (data loss on crash).
Only disable for bulk imports where data can be re-imported.
Only disable for bulk imports where data can be re-imported: with the
WAL off a batch that ``on_ok`` reported can still be lost in a crash.

Args:
use_wal: True to use WAL (default), False to disable
Expand Down Expand Up @@ -806,8 +807,10 @@ def wait_completion(self, timeout_ms: Optional[int] = None):
"""
Wait for all async operations to complete.

Blocks until all pending operations finish. If timeout is reached,
raises TimeoutError.
Blocks until every operation queued before the call is processed: its
batch is committed, or the failure went to an error callback. This is
the point at which async writes are stored (with the WAL on); a
per-record callback is not. If timeout is reached, raises TimeoutError.

Args:
timeout_ms: Optional timeout in milliseconds.
Expand Down Expand Up @@ -897,14 +900,16 @@ def close(self):

def on_ok(self, callback: Callable[[], None]) -> "AsyncExecutor":
"""
Set global success callback for all operations.

**Note:** Global callbacks have JPype proxy compatibility issues.
Prefer per-operation callbacks on async SQL/Cypher commands:

async_exec.command(
"sql", "INSERT INTO Log SET id = :id", callback=on_success, id=1
)
Set a global callback that runs after a batch commits.

This is the signal that the records applied so far are stored. The
per-record ``callback`` of ``command()`` or ``create_record()`` is not:
it runs when the record is applied to the current batch, before the
commit. With the WAL off (``set_transaction_use_wal(False)``) a batch
that ``on_ok`` reported can still be lost in a crash. The callback takes
no arguments, does not say how many records the batch held and can fire
for an empty commit: count what you submitted, and use
``wait_completion()`` to know that all of it is done.

Args:
callback: Success callback, no args
Expand Down
48 changes: 44 additions & 4 deletions src/arcadedb_embedded/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,20 @@ def get_name(self) -> str:
except Exception as e:
raise ArcadeDBError(f"Failed to get database name: {e}") from e

def is_read_only(self) -> bool:
"""Return True if the database was opened read-only.

Example:
>>> db = arcadedb.open_database("./mydb", read_only=True)
>>> db.is_read_only()
True
"""
self._check_not_closed()
try:
return str(self._java_db.getMode()) == "READ_ONLY"
except Exception as e:
raise ArcadeDBError(f"Failed to read the database mode: {e}") from e

def get_database_path(self) -> str:
"""Get the database path."""
self._check_not_closed()
Expand Down Expand Up @@ -1675,6 +1689,7 @@ def import_documents(
trim_text: Optional[bool] = None,
on_row_error: Optional[str] = None,
extra_settings: Optional[Mapping[str, Any]] = None,
disable_xml_limits: bool = False,
) -> ImportResult:
"""
Import document-shaped data through ArcadeDB's Java importer framework.
Expand Down Expand Up @@ -1706,6 +1721,10 @@ def import_documents(
value raises `ValueError`, because the engine treats everything
that is not `"skip"` as `"abort"`.
extra_settings: Additional raw importer settings.
disable_xml_limits: Lift the JDK's XML entity limits for this call only, for an
XML file with a very large text node or many entity references. Default
False. The limits are restored when the call ends. An import that hits one
raises ``ArcadeDBError`` whose message names this option.

Returns:
ImportResult with normalized metadata and importer statistics.
Expand All @@ -1729,6 +1748,7 @@ def import_documents(
trim_text=trim_text,
on_row_error=on_row_error,
extra_settings=extra_settings,
disable_xml_limits=disable_xml_limits,
)

@property
Expand Down Expand Up @@ -1921,10 +1941,22 @@ def create(self) -> Database:
except Exception as e:
raise ArcadeDBError(f"Failed to create database: {e}") from e

def open(self) -> Database:
"""Open an existing database."""
def open(self, read_only: bool = False) -> Database:
"""Open an existing database.

Args:
read_only: Open in ``ComponentFile.MODE.READ_ONLY``. Reads, queries
and scans work; every write and schema change raises
``ArcadeDBError``. ``begin()`` does not raise: the first write
does, and so does the commit. Use it to inspect a database that
must not change, such as a backup restored for audit.
"""
try:
java_db = self._java_factory.open()
if read_only:
mode = _java_class("com.arcadedb.engine.ComponentFile$MODE").READ_ONLY
java_db = self._java_factory.open(mode)
else:
java_db = self._java_factory.open()
return Database(java_db)
except Exception as e:
raise ArcadeDBError(f"Failed to open database: {e}") from e
Expand Down Expand Up @@ -1959,19 +1991,27 @@ def create_database(
def open_database(
path: str,
jvm_kwargs: Optional[dict] = None,
read_only: bool = False,
) -> Database:
"""Open an existing database at the given path.

Args:
path: Database path
jvm_kwargs: Optional JVM args passed to start_jvm()
Example: {"heap_size": "8g"}
read_only: Open the database read-only. Writes, schema changes and
transactions raise ``ArcadeDBError``.

Example:
>>> db = arcadedb.open_database("./mydb", read_only=True)
>>> db.is_read_only()
True
"""
factory = DatabaseFactory(
path,
jvm_kwargs=jvm_kwargs,
)
return factory.open()
return factory.open(read_only=read_only)


def database_exists(path: str) -> bool:
Expand Down
70 changes: 62 additions & 8 deletions src/arcadedb_embedded/importer.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from __future__ import annotations

import re
import threading
from contextlib import contextmanager
from dataclasses import dataclass, field
from os import PathLike, fspath
from pathlib import Path
Expand All @@ -14,6 +17,52 @@
# means "abort". Keep this list in step with that method, not with the docs.
_VALID_ON_ROW_ERROR = frozenset({"abort", "skip"})

# The JDK's XML entity limits, as system properties. The parser factory reads
# them when it is created, which the XML importer does once per import, so
# setting them around one import relaxes the limits for that import only.
_XML_LIMIT_PROPERTIES = (
"jdk.xml.maxGeneralEntitySizeLimit",
"jdk.xml.entityExpansionLimit",
"jdk.xml.totalEntitySizeLimit",
)
_xml_limits_lock = threading.Lock()

# JAXP00010001 entity expansion, JAXP00010003 general entity size, JAXP00010004
# total entity size: the three limits the properties above lift.
_XML_LIMIT_ERROR = re.compile(r"JAXP0001000[134]")
_XML_LIMIT_HINT = (
" The JDK's XML entity limits stopped this import. Pass"
" disable_xml_limits=True to import_documents to lift them for this call"
" only, or to start_jvm to lift them for the whole process (needed for SQL"
" IMPORT DATABASE of XML)."
)


@contextmanager
def _xml_limits_lifted(enabled: bool):
"""Lift the JDK XML entity limits for the duration of the block.

The previous values are restored afterwards. The lock serializes lifted
imports with each other; an XML parse started by another thread inside the
block sees the lifted limits too, because JDK system properties are global.
"""
if not enabled:
yield
return
System = jpype.JClass("java.lang.System")
with _xml_limits_lock:
saved = {name: System.getProperty(name) for name in _XML_LIMIT_PROPERTIES}
for name in _XML_LIMIT_PROPERTIES:
System.setProperty(name, "0")
try:
yield
finally:
for name, value in saved.items():
if value is None:
System.clearProperty(name)
else:
System.setProperty(name, str(value))


@dataclass(slots=True)
class ImportResult:
Expand Down Expand Up @@ -188,6 +237,7 @@ def import_documents(
trim_text: bool | None = None,
on_row_error: str | None = None,
extra_settings: Mapping[str, Any] | None = None,
disable_xml_limits: bool = False,
) -> ImportResult:
source_url = normalize_import_source(source)
settings = _build_import_settings(
Expand All @@ -214,13 +264,14 @@ def import_documents(

importer = Importer(java_database, None)
importer.setSettings(_to_java_settings_map(settings))
java_stats = _run_import_with_runtime_settings(
java_database,
importer,
parallel=parallel,
commit_every=commit_every,
wal=wal,
)
with _xml_limits_lifted(disable_xml_limits):
java_stats = _run_import_with_runtime_settings(
java_database,
importer,
parallel=parallel,
commit_every=commit_every,
wal=wal,
)
statistics = _java_value_to_python(java_stats) if java_stats is not None else {}

return ImportResult(
Expand All @@ -230,7 +281,10 @@ def import_documents(
statistics=statistics,
)
except Exception as e:
raise ArcadeDBError(f"Document import failed: {e}") from e
message = f"Document import failed: {e}"
if not disable_xml_limits and _XML_LIMIT_ERROR.search(str(e)):
message += _XML_LIMIT_HINT
raise ArcadeDBError(message) from e


def _run_import_with_runtime_settings(
Expand Down
Loading
Loading