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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -162,10 +162,10 @@ jobs:
services:
coordinode:
# Pinned by digest, not by tag: the proto submodule pins a server
# version, and a tag can be re-pushed, so `:0.7.0` alone does not name
# one fixed server. The digest below is 0.7.0; bump both together with
# version, and a tag can be re-pushed, so `:0.7.2` alone does not name
# one fixed server. The digest below is 0.7.2; bump both together with
# the submodule.
image: ghcr.io/structured-world/coordinode@sha256:2e76528dde460fd715549a8ff06e52c5187b12ff03cdbca59b371f46fa664829
image: ghcr.io/structured-world/coordinode@sha256:3a3657306cbb050f9ab9562965e7645f332d63a30b360813128434559c6cfae7
ports:
- 7080:7080
- 7084:7084
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,10 @@ with CoordinodeClient("localhost:7080") as db:
raise
```

Requires a CoordiNode server of **v0.7.0 or newer** — the release this client
is integration-tested against; schema management uses the v2 schema service
that release introduced. `health()` exercises a different service, so a
Requires a CoordiNode server of **v0.7.2 or newer**, the release this client
is integration-tested against. Schema management uses the v2 schema service
0.7 introduced, and a TIMESTAMP property arrives as signed epoch microseconds
only from 0.7.2 on. `health()` exercises a different service, so a
server without the transaction RPCs passes the health check and then refuses
`transaction()`.

Expand Down
58 changes: 41 additions & 17 deletions coordinode-embedded/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions coordinode-embedded/python/coordinode_embedded/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,6 @@
"""

from ._coordinode_embedded import Hnsw, LocalClient
from ._types import MultiVector, Path
from ._types import MultiVector, Path, Timestamp

__all__ = ["Hnsw", "LocalClient", "MultiVector", "Path"]
__all__ = ["Hnsw", "LocalClient", "MultiVector", "Path", "Timestamp"]
28 changes: 26 additions & 2 deletions coordinode-embedded/python/coordinode_embedded/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@

from __future__ import annotations

from datetime import UTC, datetime, timedelta

# Both packages ship from this repository at one version: the tags come from
# `coordinode` whole.
try: # pragma: no cover - exercised by whichever install shape is in use
from coordinode._types import MultiVector, Path
from coordinode._types import MultiVector, Path, Timestamp
Comment thread
polaz marked this conversation as resolved.
except ImportError:

class MultiVector(list): # type: ignore[no-redef]
Expand All @@ -29,5 +33,25 @@ class Path(dict): # type: ignore[no-redef]

__slots__ = ()

_EPOCH = datetime(1970, 1, 1, tzinfo=UTC)

class Timestamp(int): # type: ignore[no-redef]
"""An instant: microseconds since the Unix epoch, negative before 1970."""

__slots__ = ()

@classmethod
def from_datetime(cls, moment: datetime) -> Timestamp:
"""The instant a timezone-aware datetime names. A naive one names none."""
if moment.tzinfo is None or moment.utcoffset() is None:
raise ValueError("a naive datetime names no instant; attach a timezone")
# Integer arithmetic on the timedelta: a float of seconds loses
# microseconds this far from the epoch.
return cls((moment - _EPOCH) // timedelta(microseconds=1))

def to_datetime(self) -> datetime:
"""This instant as a UTC datetime. Raises OverflowError outside years 1..9999."""
return _EPOCH + timedelta(microseconds=int(self))


__all__ = ["MultiVector", "Path"]
__all__ = ["MultiVector", "Path", "Timestamp"]
79 changes: 76 additions & 3 deletions coordinode-embedded/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use coordinode_embed::{Database, DatabaseError};
use pyo3::exceptions::{PyRuntimeError, PyValueError};
use pyo3::prelude::*;
use pyo3::sync::PyOnceLock;
use pyo3::types::{PyBytes, PyDict, PyList};
use pyo3::types::{PyBytes, PyDict, PyFloat, PyInt, PyList, PyString};
use rmpv::Value as MsgpackValue;

// ── Value → PyObject conversion ──────────────────────────────────────────────
Expand Down Expand Up @@ -63,6 +63,70 @@ fn path_type(py: Python<'_>) -> Option<Bound<'_, PyAny>> {
tag_type(py, &PATH_TYPE, "Path")
}

/// The `Timestamp` tag, from this package's own `_types` module.
///
/// An int subclass, for the same reason as [`multi_vector_type`]: without the
/// tag a timestamp read back is an ordinary int, and writing it out again
/// would store an integer. It also turns a timezone-aware `datetime` into the
/// instant it names. Resolved and remembered on the same terms as
/// [`MULTI_VECTOR_TYPE`].
static TIMESTAMP_TYPE: PyOnceLock<Py<PyAny>> = PyOnceLock::new();

fn timestamp_type(py: Python<'_>) -> Option<Bound<'_, PyAny>> {
tag_type(py, &TIMESTAMP_TYPE, "Timestamp")
}

/// `datetime.datetime`, remembered on the same terms as the tags. Looked up
/// through the module rather than `PyDateTime`, which the stable ABI this
/// extension is built for does not expose.
static DATETIME_TYPE: PyOnceLock<Py<PyAny>> = PyOnceLock::new();

fn datetime_type(py: Python<'_>) -> Option<Bound<'_, PyAny>> {
if let Some(cls) = DATETIME_TYPE.get(py) {
return Some(cls.bind(py).clone());
}
let cls = py
.import("datetime")
.and_then(|module| module.getattr("datetime"))
.ok()?;
// A racing thread may have set it first; either value is the same class.
let _ = DATETIME_TYPE.set(py, cls.clone().unbind());
Some(cls)
}

/// The instant a tagged timestamp or a timezone-aware `datetime` names, in
/// microseconds since the epoch; `None` for any other value. A naive
/// `datetime` is refused: it names no instant until a zone is chosen for it.
fn py_timestamp(obj: &Bound<'_, PyAny>) -> PyResult<Option<i64>> {
// The common parameters are none of these and are known by their exact
// type, without the tag lookup and two instance checks below.
if obj.is_exact_instance_of::<PyInt>()
|| obj.is_exact_instance_of::<PyFloat>()
|| obj.is_exact_instance_of::<PyString>()
{
return Ok(None);
}
let py = obj.py();
let Some(cls) = timestamp_type(py) else {
return Ok(None);
};
if obj.is_instance(&cls)? {
return obj.extract::<i64>().map(Some);
}
let Some(datetime) = datetime_type(py) else {
return Ok(None);
};
if obj.is_instance(&datetime)? {
// The tag's own conversion: one definition of the epoch arithmetic,
// and the same refusal of a naive datetime the gRPC client gives.
return cls
.call_method1("from_datetime", (obj,))?
.extract::<i64>()
.map(Some);
}
Ok(None)
}

/// Fetch a tag class from this package's `_types`, remembering it in `cell`.
///
/// The cell holds successes only, so a lookup that fails is retried on the
Expand Down Expand Up @@ -93,8 +157,12 @@ fn value_to_py(py: Python<'_>, v: Value) -> PyResult<Py<PyAny>> {
Value::Int(i) => Ok(i.into_pyobject(py)?.into_any().unbind()),
Value::Float(f) => Ok(f.into_pyobject(py)?.into_any().unbind()),
Value::String(s) => Ok(s.into_pyobject(py)?.into_any().unbind()),
// Timestamp: expose as raw microseconds; callers use datetime.fromtimestamp(ts/1e6)
Value::Timestamp(ts) => Ok(ts.into_pyobject(py)?.into_any().unbind()),
// Microseconds since the epoch, tagged so a value read here and written
// straight back stays a timestamp instead of becoming an integer.
Value::Timestamp(ts) => match timestamp_type(py) {
Some(cls) => Ok(cls.call1((ts,))?.unbind()),
None => Ok(ts.into_pyobject(py)?.into_any().unbind()),
},
// Sized from the vector's length in one go. Growing by append costs a
// reallocation every time the list outgrows its capacity, and an
// embedding is the one value here that routinely runs to hundreds or
Expand Down Expand Up @@ -272,6 +340,11 @@ fn py_to_value(obj: &Bound<'_, PyAny>) -> PyResult<Value> {
// bool before int — Python bool is a subclass of int
return Ok(Value::Bool(b));
}
// Before int as well: a tagged timestamp is an int subclass, and the int
// branch would store it as an integer.
if let Some(micros) = py_timestamp(obj)? {
return Ok(Value::Timestamp(micros));
}
if let Ok(i) = obj.extract::<i64>() {
return Ok(Value::Int(i));
}
Expand Down
2 changes: 1 addition & 1 deletion coordinode-rs
Submodule coordinode-rs updated 416 files
Loading
Loading