Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
9657de8
feat(embedding): add support for embedding module
senamakel Aug 12, 2026
445c364
fix(embedding_test): correct test assertion for embedding dimension
senamakel Aug 12, 2026
509f148
fix(tinymemory-module): handle edge case in memory allocation
senamakel Aug 12, 2026
527a5c9
fix(module): handle empty memory region in allocation
senamakel Aug 12, 2026
3aad158
fix(tinymemory-module): correct memory alignment for atomic operations
senamakel Aug 12, 2026
a640650
fix(tinymemory-module): correct memory alignment for atomic operations
senamakel Aug 12, 2026
bbc558e
fix(ci): update release workflow to use latest actions
senamakel Aug 12, 2026
129c469
fix(ci): update release workflow to use latest actions
senamakel Aug 12, 2026
4e9e6cd
fix(ci): update release workflow to use correct artifact path
senamakel Aug 12, 2026
eb39e31
fix(test): correct test assertion for memory module service
senamakel Aug 12, 2026
3250d25
fix(tests): add e2e test for module memory operations
senamakel Aug 12, 2026
f9b9991
fix(tests): correct module e2e test to verify memory isolation
senamakel Aug 12, 2026
6edff39
fix(test): add e2e test for module memory operations
senamakel Aug 12, 2026
6b1e52f
fix(tests): correct module e2e test to verify memory isolation
senamakel Aug 12, 2026
fcc0f3f
fix(test): add e2e test for module memory operations
senamakel Aug 12, 2026
acd73da
fix(test): add e2e test for module memory operations
senamakel Aug 12, 2026
6f5cecd
fix(test): add e2e test for module memory operations
senamakel Aug 12, 2026
8149250
fix(service): handle empty input in memory module
senamakel Aug 12, 2026
17d91a0
fix(service): handle empty memory list in memory retrieval
senamakel Aug 12, 2026
40e980d
fix(service): handle empty memory list in memory retrieval
senamakel Aug 12, 2026
48b86e5
fix(service): handle empty memory list in memory retrieval
senamakel Aug 12, 2026
12de158
test(service): add tests for response size enforcement
senamakel Aug 12, 2026
8208fff
docs(specs): document boundedness of list-returning methods
senamakel Aug 12, 2026
8ac125a
chore(tests): reformat chained iterator calls and inline proxy call
senamakel Aug 12, 2026
185c89d
fix(test): add e2e test for module memory operations
senamakel Aug 12, 2026
d32826a
fix(service): restrict MAX_RESPONSE_BYTES visibility to crate scope
senamakel Aug 12, 2026
5d35404
fix(tests): correct module e2e test to verify memory isolation
senamakel Aug 12, 2026
32bc41c
chore(tests): remove unused import in module e2e test
senamakel Aug 12, 2026
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
34 changes: 26 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ jobs:
# their `checksum.toml` — that is what a host pins and verifies.
tag:
name: Tag release
if: ${{ github.ref == 'refs/heads/main' }}
# `existing_tag` means "re-cut artifacts for a tag that already exists", so
# this job must not run: it would bump the version and cut a *second*, newer
# tag, and `release-target` would then build the older tag the caller asked
# for while `main` had silently moved on.
if: ${{ github.ref == 'refs/heads/main' && inputs.existing_tag == '' }}
# Consumed by `release-target`. The `version` step already writes both to
# `$GITHUB_OUTPUT`; without this block they stop at the job boundary and the
# module bundles resolve an empty tag.
Expand Down Expand Up @@ -303,10 +307,14 @@ jobs:
$packageName = "tinymemory-module-$env:VERSION-$env:BUNDLE_ID"
$packageRoot = "dist/$packageName"
New-Item -ItemType Directory -Force $packageRoot | Out-Null
Copy-Item -LiteralPath $module, 'LICENSE', 'README.md' -Destination $packageRoot
# Same file set as the Unix package, including the spec — a consumer
# should not get different contents depending on their platform.
Copy-Item -LiteralPath $module, 'LICENSE', 'README.md', 'docs/specs/tinybus-module.md' -Destination $packageRoot
$hash = (Get-FileHash -LiteralPath $module -Algorithm SHA256).Hash.ToLowerInvariant()
$moduleName = Split-Path -Leaf $module
"`"$moduleName`" = `"$hash`"`n" |
# No trailing "`n": Set-Content adds its own terminator, so writing one
# here leaves a blank line the Unix `printf` form does not produce.
"`"$moduleName`" = `"$hash`"" |
Set-Content -Path "$packageRoot/modules.toml" -Encoding utf8NoBOM
Compress-Archive -Path "$packageRoot/*" -DestinationPath "dist/$packageName.zip"
"archive=dist/$packageName.zip" >> $env:GITHUB_OUTPUT
Expand Down Expand Up @@ -382,11 +390,21 @@ jobs:
run: |
set -euo pipefail
mapfile -t release_files < <(find release-assets -type f | sort)
gh release create "$RELEASE_TAG" "${release_files[@]}" \
--repo "$REPOSITORY" \
--verify-tag \
--title "$RELEASE_TAG" \
--generate-notes
# Re-cutting artifacts for an existing tag is what `existing_tag` is
# for, and a release for that tag usually already exists — so upload
# into it rather than failing on `already exists`. `--clobber` makes
# the re-cut idempotent instead of erroring on the second asset name.
if gh release view "$RELEASE_TAG" --repo "$REPOSITORY" >/dev/null 2>&1; then
echo "release ${RELEASE_TAG} exists; uploading assets into it"
gh release upload "$RELEASE_TAG" "${release_files[@]}" \
--repo "$REPOSITORY" --clobber
else
gh release create "$RELEASE_TAG" "${release_files[@]}" \
--repo "$REPOSITORY" \
--verify-tag \
--title "$RELEASE_TAG" \
--generate-notes
fi

- name: Verify the published module through TinyBus
shell: bash
Expand Down
27 changes: 16 additions & 11 deletions crates/tinymemory-module/src/embedding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,17 +272,22 @@ impl EmbeddingProvider for BusEmbeddingProvider {
texts.len()
);
}
if self.dimensions > 0 {
if let Some(bad) = vectors
.iter()
.find(|vector| vector.len() != self.dimensions)
{
anyhow::bail!(
"host returned a {}-dimension vector for a {}-dimension space",
bad.len(),
self.dimensions
);
}
// Checked unconditionally, including for a zero-dimension provider. An
// earlier revision skipped the check entirely when `dimensions == 0`,
// which let a host answer a "semantic search off" request with real
// 768-wide vectors and pass — precisely the split-embedding-space
// failure this check exists to prevent, except that the engine would
// additionally believe no vectors existed at all. Zero dimensions means
// empty vectors, and this is what says so.
if let Some(bad) = vectors
.iter()
.find(|vector| vector.len() != self.dimensions)
{
anyhow::bail!(
"host returned a {}-dimension vector for a {}-dimension space",
bad.len(),
self.dimensions
);
}

Ok(vectors)
Expand Down
30 changes: 26 additions & 4 deletions crates/tinymemory-module/src/embedding_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,9 @@ async fn a_wrong_vector_count_is_refused() {
}

#[tokio::test]
async fn a_zero_dimension_provider_is_exempt_from_the_width_check() {
// Zero dimensions is the engine's "semantic search off" state and is
// expected to yield empty vectors; enforcing a width there would break
// keyword-only retrieval.
async fn a_zero_dimension_provider_yields_empty_vectors() {
// Zero dimensions is the engine's "semantic search off" state, and the
// vectors it yields are expected to be empty rather than merely unchecked.
let connection = bus_with_host(FakeHostEmbedder {
width: 0,
force_count: None,
Expand All @@ -159,6 +158,29 @@ async fn a_zero_dimension_provider_is_exempt_from_the_width_check() {
assert!(vectors[0].is_empty());
}

#[tokio::test]
async fn a_zero_dimension_request_answered_with_real_vectors_is_refused() {
// The case an earlier revision let through: `dimensions == 0` skipped the
// width check outright, so a host could answer a "semantic search off"
// request with a real 768-wide space and pass validation. The engine would
// then believe no vectors existed while the store filled with embeddings
// from a space nothing tracks — the split-space failure, with the split
// hidden. Zero means empty, and this is the test that says so.
let connection = bus_with_host(FakeHostEmbedder {
width: 768,
force_count: None,
})
.await;
let host = BusEmbeddingHost::new(connection, &config_with_dims(0));
let provider = host.default_embedding_provider();

let error = provider
.embed(&["alpha"])
.await
.expect_err("a 768-wide answer to a zero-dimension request must be refused");
assert!(error.to_string().contains("768"), "{error}");
}

#[tokio::test]
async fn an_empty_batch_never_reaches_the_bus() {
// No host is served here at all, so this only passes if the call short
Expand Down
41 changes: 40 additions & 1 deletion crates/tinymemory-module/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ pub use embedding::{
};
pub use service::{BUS_NAME, OBJECT_PATH};

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

use tinybus::{Connection, Error as BusError, Result as BusResult};
Expand All @@ -99,6 +100,7 @@ const SETUP_FAILED_ERROR: &str = "ai.tinyhumans.tinymemory.Error.SetupFailed";
/// the host, which holds the real credential, so there is nothing to pass and
/// nothing here that could leak one.
async fn setup(connection: Connection, mut config: ModuleConfig) -> BusResult<()> {
claim_process_setup()?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

priority high critique likely

Acquire the setup claim after validation, not before

The claim is acquired at the very top of setup, before config.validate() runs. If validation fails, CLAIMED has already been swapped to true, so every subsequent call to setup in this process — even one with valid config — returns the "already set up" error. A config-validation failure is a precondition check, not an actual setup, so it should not consume the single process-global slot. The claim should be taken after validation succeeds (and ideally released when a later step fails, since a failed create_memory_store or service::serve has the same poisoning effect).

[RULE] null ·

config.validate().map_err(setup_error)?;

// `MemoryConfig` travels verbatim, and it contains a bearer token field for a
Expand Down Expand Up @@ -133,12 +135,49 @@ async fn setup(connection: Connection, mut config: ModuleConfig) -> BusResult<()
config.storage_provider.as_ref(),
&config.workspace_dir,
)
.map_err(|error| setup_error(format!("create memory store: {error}")))?;
.map_err(|error| {
// The factory error names the workspace directory it failed under, and
// a `MethodFailed.message` crosses the bus to a caller that has no
// business learning this process's filesystem layout. The detail stays
// in the module's own log; the wire gets the stage only.
log::error!("[tinymemory:module] create memory store failed: {error}");
setup_error("create memory store")
})?;

let provider = tinymemory_tinycortex::provider(Arc::from(memory));
service::serve(&connection, Arc::new(provider)).await
}

/// Claim this process's single setup slot.
///
/// `setup` installs a **process-global** embedding host
/// (`tinymemory_core::embedding_host::set_embedding_host`), so it is not
/// re-entrant the way a per-host resource would be. `ModuleHost` rejects a
/// duplicate module name only within one host, and nothing stops a process from
/// building a second host — a test harness is the obvious way it happens. The
/// second `setup` would replace the global embedder while stores built by the
/// first keep the `BusEmbeddingProvider` they captured, so embeds would be split
/// across two connections with no error anywhere.
///
/// Refusing the second setup is the honest outcome: one process serves this
/// module once. tinybus never unloads a library, so there is no release path to
/// pair with this and no state to reset.
///
/// # Errors
///
/// [`SETUP_FAILED_ERROR`], when this process has already run setup.
fn claim_process_setup() -> BusResult<()> {
static CLAIMED: AtomicBool = AtomicBool::new(false);

if CLAIMED.swap(true, Ordering::SeqCst) {
return Err(setup_error(
"this module is already set up in this process; it installs a \
process-global embedding host and cannot be served twice",
));
}
Ok(())
}

/// A setup failure, carrying no path and no credential.
fn setup_error(message: impl Into<String>) -> BusError {
BusError::MethodFailed {
Expand Down
106 changes: 98 additions & 8 deletions crates/tinymemory-module/src/service/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,24 @@
//! So there is no blob store, no chunking and no held output — the apparatus the
//! `tinydocs` module needs does not appear in this one.
//!
//! The one method that could grow without bound is `ExportPage`, and it is
//! already paged by contract with the caller choosing the page size. A caller
//! that asks for a million records in one page gets a frame-size error, which is
//! the correct answer.
//! Inline does not mean unbounded, though, and the three list-returning methods
//! are not all bounded the same way:
//!
//! - `ExportPage` is paged by contract, with the caller choosing the page size.
//! Asking for a million records in one page gets an error, correctly.
//! - `Recall` takes a `limit`, so the caller bounds the count — but not the
//! bytes, since fifty entries each holding a large document still overflow.
//! - `List` takes **neither**. It has no limit and no cursor, so entries can
//! accumulate across individually valid `Store` calls until the response
//! cannot cross a frame, and the caller has no way to ask for less.
//!
//! So `List` and `Recall` are checked against [`MAX_RESPONSE_BYTES`] and refuse
//! with a named `BudgetExceeded` rather than truncating. Truncating would be the
//! worse failure: with no cursor, a short list is indistinguishable from a
//! complete one, so a caller would conclude the missing entries do not exist.
//! `Namespaces` is left unchecked — it returns one small summary per namespace,
//! and a host with enough namespaces to fill 16 MiB of summaries has a different
//! problem.
//!
//! # Errors are named, and the names are the contract
//!
Expand Down Expand Up @@ -182,20 +196,28 @@ impl MemoryService {
}

/// List entries, narrowing by namespace, category and session.
///
/// Bounded by [`MAX_RESPONSE_BYTES`]: unlike `Recall` and `ExportPage`, this
/// method takes no limit and no cursor, so the caller has no way to ask for
/// less. See [`ensure_response_fits`] for why the answer is a named refusal
/// rather than a truncation.
async fn list(
&self,
namespace: Option<String>,
category: Option<MemoryCategory>,
session_id: Option<String>,
) -> BusResult<Vec<MemoryEntry>> {
self.provider
let entries = self
.provider
.list(
namespace.as_deref(),
category.as_ref(),
session_id.as_deref(),
)
.await
.map_err(|error| into_bus_error(&error))
.map_err(|error| into_bus_error(&error))?;
ensure_response_fits(&entries, "List")?;
Ok(entries)
}

/// Enumerate namespaces with their aggregate counts.
Expand All @@ -219,10 +241,15 @@ impl MemoryService {
opts: OwnedRecallOpts,
scope: Option<SourceScope>,
) -> BusResult<Vec<MemoryEntry>> {
self.provider
let entries = self
.provider
.recall(&query, limit, &opts, scope.as_ref())
.await
.map_err(|error| into_bus_error(&error))
.map_err(|error| into_bus_error(&error))?;
// `limit` bounds the count but not the bytes: a caller asking for 50
// entries that each hold a large document still overflows a frame.
ensure_response_fits(&entries, "Recall")?;
Ok(entries)
}

/// Read one page of the export, continuing from `cursor`.
Expand All @@ -245,6 +272,69 @@ impl MemoryService {
}
}

/// The response-size ceiling for a method that returns a list of entries.
///
/// A `TinyBus` frame is JSON capped at 16 MiB. 8 MiB of raw entry content leaves
/// room for the JSON structure around it and for escaping, which can double a
/// pathological string, so a response that passes this check fits with margin.
pub(crate) const MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;

/// Per-entry allowance for the fields that are not `content`.
///
/// Keys, namespaces, timestamps, category and taint. Deliberately generous: this
/// check exists to stop a response overflowing a frame, and over-estimating
/// refuses slightly early while under-estimating fails at the transport with an
/// error the caller cannot act on.
const PER_ENTRY_OVERHEAD_BYTES: usize = 512;

/// Refuse a response that would not fit in a frame.
///
/// # Why a refusal and not a truncation
///
/// Truncating would be worse than failing. `List` has no cursor, so a caller
/// receiving a short list has no way to tell it apart from a complete one and no
/// way to ask for the rest — it would conclude those entries do not exist. A
/// named error tells the caller to narrow by namespace, category or session,
/// which is a query it can actually issue.
///
/// # Why `BudgetExceeded` and not a new name
///
/// The name has to be one both ends already agree on, and
/// [`tinymemory_api::wire`] is the table that makes that true. `BudgetExceeded`
/// is what it means — the result exceeded a size budget — and it round-trips to
/// the host as `MemoryError::BudgetExceeded` with no client change. A new name
/// would decode to `Other` on any host older than the module, turning an
/// actionable "narrow your query" into an opaque backend failure.
///
/// # Errors
///
/// [`wire::BUDGET_EXCEEDED`], when the estimate exceeds [`MAX_RESPONSE_BYTES`].
/// The message names the method and the sizes, never entry content.
fn ensure_response_fits(entries: &[MemoryEntry], method: &str) -> BusResult<()> {
let estimate: usize = entries
.iter()
.map(|entry| entry.content.len().saturating_add(PER_ENTRY_OVERHEAD_BYTES))
.sum();

if estimate > MAX_RESPONSE_BYTES {
log::warn!(
"[tinymemory:module] {method} refused: {} entries estimated at {estimate} bytes \
exceeds the {MAX_RESPONSE_BYTES} byte response ceiling",
entries.len()
);
return Err(BusError::MethodFailed {
name: wire::BUDGET_EXCEEDED.to_string(),
message: format!(
"{method} would return {} entries (~{estimate} bytes), over the \
{MAX_RESPONSE_BYTES} byte response ceiling; narrow the query by \
namespace, category or session",
entries.len()
),
});
}
Ok(())
}

/// Map a [`MemoryError`] onto a named bus error.
///
/// Both the name and the message come from [`tinymemory_api::wire`], which the
Expand Down
Loading