Real-time serving (add-on)¶
Resident, in-process record matching (madmatcher_pro.serving): publish a batch
build’s artifacts as a bundle once, load it into memory, and answer each incoming
record from the loaded state. This is a separately-licensed add-on;
RealtimeMatcher.load requires the realtime license entitlement. Start with
the real-time matching tutorial;
this page documents every public entry point.
Publishing a bundle¶
- madmatcher_pro.serving.publish_serving_artifacts(out_dir, *, indexed_table, features, model, sparkly_index_path=None, fill_na, block_limit=50, id_col='_id', prebuilt_feature_table=None, query_spec=None, semantic_index_path=None, embed_provider=None, embed_fields=None, serializer=None, nprobe=32, fusion_weights=None, fusion_kappa=60, cosine_feature=False, embedding_store=None, embedding_store_id_col='_id', embedding_store_col='embedding', delex_program=None, delex_corpus=None, delex_id_col='_id', delex_optimize=False, delex_estimate_cost=False, spark=None, enable_crash_recovery=False, checkpoint_dir=None, validate_input=True, dashboard=None, dashboard_port=None, open_browser=None)¶
Write a serving bundle at
out_dirand return its path.Publish a new directory per version.
out_diris rewritten in place, which is safe for a fresh directory but must not target a bundle aRealtimeMatcheris actively serving (a live reader holds the corpus memory-map open). To switch a live process to a refreshed bundle with no downtime, publish the new version to its own directory and swap it in withHotSwappableMatcher.The bundle is assembled as ordinary files on the driver, which has two consequences once other machines are involved:
A Spark DataFrame is written by the executors, to the destination path as each of THEM resolves it.
embedding_storeanddelex_corpusare the two arguments that can be DataFrames. If the bundle directory is on storage every node shares – one machine, a network mount, a lakehouse volume – the executors write exactly where the driver reads and a DataFrame is fine. If the bundle is on the driver’s own disk while the executors are elsewhere, each of them writes to its own local disk under that path and the bundle gets nothing. Publishing verifies the driver can read each Spark-written artifact back, so that case fails naming the cause instead of surfacing steps later as a schema error. The fix is to stage the table once to a filesystem both sides can see and pass that URI:embedding_storeanddelex_corpusaccept a path or URI on any filesystem Spark can address (hdfs://,s3a://,gs://,abfss://, a local or shared-mount path), and publishing copies it into the bundle itself with no Spark write. Note that the relevant question is where the bundle is, not whether the cluster has a distributed filesystem: afile://write ignoresfs.defaultFSentirely.Give publishing its own Spark application. Publishing itself registers nothing with the
SparkContext, but a batch featurize run in the same context has already registered its own copy of the preprocessed corpus (addFile), and Spark ships every registered file to all executors of every later job. For a large corpus that is tens of gigabytes per executor, enough to fill their disks and get the nodes marked unhealthy. Run publishing as its own application rather than as a tail stage of a matching job.
Example
Publish a bundle from a finished build, then serve from it:
from madmatcher_pro.serving import publish_serving_artifacts, RealtimeMatcher publish_serving_artifacts( "bundles/v1", indexed_table=corpus_df, # your records (a Spark DataFrame) features=features, # the feature list from training model=trained_model, # the trained model sparkly_index_path="lucene_dir", # the search index you built fill_na=0.0, # same value you gave featurize() in training ) matcher = RealtimeMatcher.load("bundles/v1")
- Parameters:
indexed_table (Spark DataFrame) – the corpus to serve against (the candidate side); each incoming record is matched against it.
features (list) – the feature list used to train
model.model (SKLearnModel or SparkMLModel) – the trained matcher. An sklearn/xgboost model predicts in-process with no Spark on the request path; a SparkML model has no in-process predict, so serving it runs one Spark job per
match(supported for batch interoperability, but slower).sparkly_index_path (str or Path, optional) – an existing built sparkly index directory, copied into the bundle. Omit it for a bundle whose lexical blocker is delex, that blocks with semantic alone, or that is matching-only (served via
match(record, candidates=[...])). At least one blocker is recommended unless the bundle is used only in matching-only mode.fill_na (float) – the NaN fill value for feature vectors. Required: it must equal the
fill_napassed to the training-timefeaturize(...), because a mismatch silently changes NaN-valued features at serve time and cannot be validated against the model.prebuilt_feature_table (MemmapDataFrame, optional) – the preprocessed corpus table a batch run already built (from
build_preprocessed). Passing it skips re-scanning and re-preprocessing the whole corpus, the most expensive part of publishing. Must have been built fromindexed_tablewith thesefeatures.block_limit (int) –
how many candidates blocking retrieves per incoming record (default 50), and so how many pairs the model scores. Pinned in the manifest and read at load.
Deduplicating (serving corpus records against a corpus that contains them): serving has no self-match exclusion, so a record’s own row comes back at rank 1 from every blocker and eats one of the slots. The batch equivalent asked its blocker for K+1 candidates and dropped the self pair, so to serve the same K real candidates publish
block_limit=K+1and dropid1 == id2from the results yourself. Getting this wrong costs exactly one candidate per record: it shows up as a small recall gap against the batch run, never as an error.block_limitis read fromMANIFEST.jsonat load and used nowhere else, so an already-published bundle can be corrected by editing that one field rather than re-publishing.id_col (str) – the column an incoming record carries its own id in (default
_id). Serving stamps it onto each result row asid2when a request passes no explicitkey, so a served row always says which record produced it. A record without this column is still matched; results are then keyed only by an explicitkey(seeRealtimeMatcher.match).semantic_index_path (str or Path, optional) – an existing built (flat) semantic index directory. When given, serving fuses sparkly and semantic blocking. A lossy-codec index is refused (serving decodes flat postings only).
embed_provider (EmbeddingProvider or str, optional) – the embedder the corpus was built with: the same provider instance you passed to the batch embedding step (e.g. a
SentenceTransformerProvider), or a model-id string. Its config is stored in the bundle, and the loader rebuilds one resident embedder shared by the semantic probe and the embedding store. None means everymatchcall must supply a precomputedprobe_embedding.embed_fields (list, optional) – the record fields the serializer embedded, in order. Required when
semantic_index_pathis given.serializer (Serializer, optional) – the serializer the corpus embeddings were built with (a DittoSerializer or PlainConcatSerializer). Its kind and config are stored so the resident probe formats each probe’s text identically. None uses DittoSerializer defaults. A custom serializer is refused (it cannot be reproduced per record).
nprobe (int) – semantic cells probed per record (recall/latency knob).
fusion_weights (dict, optional) – RRF source weights, e.g.
{"sparkly": 0.5, "semantic": 0.5}.fusion_kappa (int) – RRF kappa (rank damping).
cosine_feature (bool) – True when the model was trained with the semantic cosine appended as the last feature. Serving then appends the per-candidate cosine identically, which needs a cosine source:
semantic_index_path(serving reuses the semantic blocker’s scores) and/orembedding_store(serving recomputes cosine from stored corpus vectors for the candidates the reuse path misses). With both, serving reuses where it can and recomputes only the gaps.embedding_store (str/Path or Spark DataFrame, optional) – the corpus embedding table (candidate side): a parquet path or URI (local, or on any filesystem Spark can address), or a Spark DataFrame – see the note above on which of the two a given deployment can use. Needs an id column and an embedding column. When given, serving computes the cosine feature by fetching each candidate’s stored vector and dotting it with the probe’s embedding, so even a candidate no semantic blocker ranked gets its real trained cosine. Stored in the bundle. Must map each corpus id to exactly one vector (a duplicate id is refused at load). The probe is embedded with
embed_provider/embed_fields/serializer(as for the semantic index); ifembed_provideris None, everymatchmust supplyprobe_embedding.embedding_store_id_col (str) – the id and embedding column names in
embedding_store(default_id/embedding).embedding_store_col (str) – the id and embedding column names in
embedding_store(default_id/embedding).delex_program (BlockingProgram, optional) – the delex program to fuse. Delex has no cross-process resident form, so the bundle stores the raw corpus and the (unbuilt) program, and the loader rebuilds the resident probe once at load. Fusing delex therefore makes the loader require a SparkSession.
delex_corpus (Spark DataFrame or str/Path, optional) – the corpus delex indexes (the candidate side). Defaults to
indexed_table. Stored in the bundle as parquet: a DataFrame is written there by Spark, while a path or URI to an existing parquet dataset is copied in without Spark – see the note above on which of the two a given deployment can use.delex_id_col (str) – the id column of the delex corpus (default
_id).delex_optimize (bool) – run the delex plan optimizer / cost estimation at the load-time build. Default off (the default plan).
delex_estimate_cost (bool) – run the delex plan optimizer / cost estimation at the load-time build. Default off (the default plan).
enable_crash_recovery (bool) – write the bundle as resumable, checkpointed artifact stages so a crash mid-publish resumes from the last committed stage instead of rebuilding the whole bundle. Off (default) writes the bundle in one non-checkpointed pass. Requires
checkpoint_dir.checkpoint_dir (str or Path, optional) – required when
enable_crash_recovery=True: where to keep the publish checkpoint. Any FileSystemStore-addressable URI (local,hdfs://,s3a://, …); reaching it needs a live SparkSession. Keep it outside the bundle and use a fresh directory per publish: resuming an existing checkpoint continues that exact publish, so a re-publish (a refreshed corpus or a changed config) needs its own directory. The checkpoint is never auto-deleted.validate_input (bool) – with
enable_crash_recovery(default True), pin the corpus row count and re-check it on resume: a resume after the corpus grew or shrank is refused (CheckpointMismatchError) rather than serving stale artifacts. Costs onecount()over the corpus. Pass False to skip it (a same-count corpus swap is not detected either way).dashboard (optional) – optional progress tracking on the shared dashboard (defer to the active session or env when None).
dashboard_port (optional) – optional progress tracking on the shared dashboard (defer to the active session or env when None).
open_browser (optional) – optional progress tracking on the shared dashboard (defer to the active session or env when None).
- madmatcher_pro.serving.check_embedding_store(embedding_store, id_col='_id', embedding_col='embedding')¶
Validate a corpus embedding store before publishing it into a serving bundle.
Checks that the id and embedding columns are present and that the id column is unique (a 1:1 id-to-vector map). publish_serving_artifacts runs this automatically when an embedding_store is given, so a bad table fails at publish rather than at serve time; you can also call it directly to check a table up front. A duplicate id is rejected: it would make the resident store’s lookup ambiguous and diverge from the cosine feature the model was trained on.
Example:
check_embedding_store(my_embeddings_df, id_col="_id", embedding_col="embedding") # raises ValueError if a column is missing or an id is repeated
- Parameters:
embedding_store – pyspark DataFrame | str | pathlib.Path The corpus embedding table (one row per record), or a path to a local parquet dataset of it.
id_col – str The id column name (default _id).
embedding_col – str The embedding (vector) column name (default embedding).
- Returns:
- int
The number of rows in the store (one per corpus id).
- Raises:
ValueError – a column is missing, or the id column has duplicates.
The resident matcher¶
- class madmatcher_pro.serving.RealtimeMatcher(served, sparkly_probe, model, *, semantic_probe=None, delex_probe=None, cosine_feature=False, embed_store=None, fusion_weights=None, fusion_kappa=60, block_limit=50, threshold=0.5, id_col=None)¶
Bases:
objectMatches one incoming record (or a batch of them) against a fixed set of your records, fast enough to answer live requests.
Create one with
RealtimeMatcher.load("bundle_dir"), then callmatch(record).- classmethod load(artifacts_dir, *, spark=None, threshold=0.5, track=False, dashboard=None, dashboard_port=None, open_browser=None, id_col=None)¶
Load a saved matcher package from a folder and return a ready-to-use matcher.
This is the only supported way to create a matcher: it checks that your license includes the real-time add-on, then loads everything the matcher needs into memory. Creating a matcher any other way makes match refuse to run.
Example:
matcher = RealtimeMatcher.load("bundle_dir") matcher.match({"name": "apple pie", "brand": "acme"})
- Parameters:
artifacts_dir – str | Path A bundle written by publish_serving_artifacts (local or a Spark URI such as s3://… for the artifacts Spark reads).
spark – pyspark.sql.SparkSession | None Used to read the model bytes, rebuild a delex probe, and (for a SparkML model) run predict. None defaults to the active or newly created session. A SparkSession is required only for a delex or SparkML bundle.
threshold – float Default confidence threshold for match and match_batch.
track – bool Show this matcher’s throughput on the dashboard (a live Serving requests counter advanced per match and match_batch). Off by default. Finished by close.
dashboard – optional Dashboard resolution overrides for track (defer to the active session or env when None).
dashboard_port – optional Dashboard resolution overrides for track (defer to the active session or env when None).
open_browser – optional Dashboard resolution overrides for track (defer to the active session or env when None).
- Returns:
- RealtimeMatcher
A resident matcher ready to answer match(record).
- Raises:
LicenseError – the base license is valid but lacks the realtime entitlement.
ValueError / FileNotFoundError – the bundle is incomplete, or its model width doesn’t match the pinned feature set.
- match(record, *, candidates=None, threshold=None, probe_embedding=None, key=None)¶
Find which of your stored records match one incoming record, answered right away.
Returns a table with one row per matching record: the matched record’s id (id1), the incoming record’s key (id2), the match prediction, and a confidence score (highest confidence first).
Example:
matcher.match({"name": "apple pie", "brand": "acme"}, key="req-7") # -> DataFrame [id1, id2, prediction, confidence], one row per match
- Parameters:
record – dict | pandas.Series | 1-row pandas.DataFrame | pyspark.Row The incoming record as a column-to-value mapping.
key – any | None Value identifying this record in the results (id2). Use it when the record carries no id of its own (for example a request off a queue). None falls back to the record’s own id (the id_col given at load), then to None.
candidates – list[int] | None Bring your own candidates: when given, blocking is skipped and the record is matched only against these corpus ids (an id absent from the corpus is dropped; an empty or all-unknown list gives the empty result). A cosine-trained bundle needs an embedding store to produce the cosine feature this way. None (default) blocks as usual.
threshold – float | None Keep a candidate only when its match confidence is >= this. None uses the value set at load.
probe_embedding – numpy.ndarray | None A precomputed embedding for the semantic source or cosine store, skipping the resident embedder for this call. None embeds the record with the bundle’s embedder, and is required when none was loaded.
- Returns:
- pandas.DataFrame with columns [id1, id2, prediction, confidence]
One row per predicted match at or above the threshold, sorted by confidence descending then id1 ascending. id2 is this record’s key on every row, so a result stays attributable once it leaves the call.
Note
There is no self-match exclusion. When the corpus contains the records you are serving (deduplication), each record matches itself: drop the id1 == id2 row yourself, and publish the bundle with block_limit one higher than the number of real candidates you want, since the self-match occupies a candidate slot in every blocker.
- match_batch(records, *, candidates=None, threshold=None, probe_embeddings=None, keys=None)¶
Match many incoming records at once, faster than calling match on each.
The results are exactly what you would get from match per record: one result table per input record, in the same order.
Example:
matcher.match_batch([ {"name": "apple pie", "brand": "acme"}, {"name": "banana bread", "brand": "acme"}, ]) # -> [DataFrame, DataFrame] # one [id1, id2, prediction, confidence] each
- Parameters:
records – sequence of record mappings, or a pandas.DataFrame of rows The incoming records (each normalized as in match).
candidates – sequence | None None blocks every record; otherwise a sequence aligned with records, each element a bring-your-own id list for that record, or None to block it. Same cosine-store requirement as match.
threshold – float | None Confidence threshold applied to every record (None uses load’s).
probe_embeddings – sequence | None Optional precomputed embedding per record, aligned with records.
keys – sequence | None Optional key per record, aligned with records (as match’s key). None falls back per record to its own id under the id_col given at load.
- Returns:
- list[pandas.DataFrame]
One [id1, id2, prediction, confidence] frame per input record, in order.
- close()¶
Release the files and memory this matcher is holding open.
Call this when you are done with the matcher, or before loading a new one, so it does not leave files open. Each part is closed independently, so a problem closing one part still releases the rest. Safe to call even if some parts were never opened.
Example:
matcher.close()
- Returns:
None
Serving pools¶
- class madmatcher_pro.serving.MatcherPool(matcher, *, n_workers=None, track=True, dashboard=None, dashboard_port=None, open_browser=None, job_id=None, max_queue=None, on_full='block')¶
Bases:
_WorkerPoolServe records through a loaded matcher on a resizable pool of worker threads, one record per call, in parallel.
- Parameters:
matcher – RealtimeMatcher The loaded matcher to serve through.
n_workers – int | None Number of parallel workers (default: the machine’s CPU count, capped at 16). Resize any time with scale(n).
track – bool Show live throughput on the dashboard (a Serving requests counter plus requests and workers KPIs). On by default, best-effort, and gated on an active session (no cost when no dashboard is active). Finished by close.
dashboard – optional Dashboard resolution overrides (defer to the active session or env when None).
dashboard_port – optional Dashboard resolution overrides (defer to the active session or env when None).
open_browser – optional Dashboard resolution overrides (defer to the active session or env when None).
job_id – str | None Dashboard job id; defaults to a per-instance id.
max_queue – int | None Max requests waiting to be served. None = 8 per worker; 0 = unbounded (only for a caller bounding admission itself).
on_full – “block” | “reject” What submit does when the queue is full: wait for space (default), or raise QueueFullError so the caller can shed load.
Example:
pool = MatcherPool(matcher, n_workers=8) for matches in pool.imap_unordered(records): # streamed, served in parallel handle(matches) pool.close()
- map(records, *, max_in_flight=None)¶
Serve an iterable of records in parallel and yield each result in submission order, streaming (it starts yielding before the input is exhausted). At most
max_in_flightrequests are queued at once, so this works on an unbounded stream.Example:
for matches in pool.map(records): write(matches)
- Parameters:
records – iterable of records (as in match).
max_in_flight – int | None Max requests queued at once (default ~2 per worker).
- Yields:
each record’s [id1, id2, prediction, confidence] frame, in input order.
- imap_unordered(records, *, max_in_flight=None)¶
Like map, but yield each result as soon as it is ready (not in input order), for maximum streaming throughput. Bounded in-flight, so it works on an unbounded stream.
Example:
for matches in pool.imap_unordered(records): write(matches)
- Parameters:
records – iterable of records (as in match).
max_in_flight – int | None Max requests queued at once (default ~2 per worker).
- Yields:
each record’s [id1, id2, prediction, confidence] frame, in completion order.
- close(*, wait=True, timeout=5.0)¶
Stop accepting new requests and shut down every worker. Requests already enqueued are still served; further submit raises. Safe to call more than once. (Using the pool as a with block calls this for you.)
Example:
pool.close()
- Parameters:
wait – bool Join the worker threads before returning.
timeout – float Max seconds to wait for each worker’s join (when wait).
- match(record, *, candidates=None, probe_embedding=None)¶
Submit a record and wait for its result (the blocking form of submit).
Example:
matches = pool.match({"name": "apple pie", "brand": "acme"}) # -> pandas.DataFrame [id1, id2, prediction, confidence]
- Parameters:
record – the incoming record (as in RealtimeMatcher.match).
candidates – list[int] | None Match against these ids only, or None to search for candidates.
probe_embedding – numpy.ndarray | None A precomputed embedding for the record, or None to embed it.
- Returns:
pandas.DataFrame [id1, id2, prediction, confidence] for the record.
- scale(n)¶
Grow or shrink the pool to
nworkers, at any time while serving.Scaling up starts new workers immediately. Scaling down is graceful: a retiring worker finishes its current unit (dropping no in-flight request) and then exits, so the effective count reaches
nshortly after the call.Example:
pool.scale(8) # burst: 8 parallel workers pool.scale(2) # quiet: drop back to 2
- Parameters:
n – int, >= 1 Desired pool size.
- Returns:
self
- Raises:
ValueError – n < 1.
RuntimeError – the pool has been closed.
- submit(record, *, candidates=None, probe_embedding=None, timeout=None)¶
Enqueue one record; return a Future for its result.
Thread-safe: call from as many request threads as you like (a streaming source of requests). One failing request only fails its own Future.
The queue is bounded, so a producer faster than the pool gets backpressure rather than unbounded memory growth. When it is full this blocks until space frees (the default
on_full="block") or raises QueueFullError (on_full="reject", for a caller that would rather shed load).Example:
handle = pool.submit({"name": "apple pie", "brand": "acme"}) matches = handle.result() # the [id1, id2, prediction, confidence] frame
- Parameters:
record – dict | pandas.Series | 1-row pandas.DataFrame | pyspark.Row The incoming record (as in RealtimeMatcher.match).
candidates – list[int] | None Matching-only candidate ids for this record, or None to block it.
probe_embedding – numpy.ndarray | None Precomputed embedding for this record, or None to embed it.
timeout – float | None Max seconds to wait for queue space before raising QueueFullError. None waits indefinitely (under
on_full="block").
- Returns:
- concurrent.futures.Future
Resolves to this record’s [id1, id2, prediction, confidence] frame (or the request’s own exception).
- Raises:
RuntimeError – the pool has been closed.
QueueFullError – the queue is full and the policy is to reject (or the wait exceeded timeout).
- property workers¶
The current number of live worker threads in the pool.
Example:
pool.workers # -> 4
- Returns:
int
- class madmatcher_pro.serving.MicroBatchMatcher(matcher, *, n_workers=1, max_batch=32, max_delay_ms=10.0, track=True, dashboard=None, dashboard_port=None, open_browser=None, job_id=None, max_queue=None, on_full='block')¶
Bases:
_WorkerPoolCoalesce concurrent single-record requests into RealtimeMatcher.match_batch calls, served by a resizable pool of worker threads, for throughput on a request stream.
A caller submit`s a record from any thread and gets a `Future.
n_workersdaemon drain threads share the queue, each grouping requests into windows (up to max_batch, or until max_delay_ms) and serving them with one match_batch, so a window shares one embedder pass and one predict. Separate windows run in parallel across the workers. Call scale(n) to add or remove workers at any time. It is an in-process helper: no service, no network hop, the caller owns transport.- Parameters:
matcher – RealtimeMatcher The loaded matcher to serve windows through.
n_workers – int, >= 1 Number of parallel drain threads sharing the queue (resize with scale(n)).
max_batch – int, >= 1 Max records coalesced into one match_batch window (per worker).
max_delay_ms – float Max time (ms) to wait for a window to fill before serving it.
track – bool Show live throughput on the dashboard (a Serving requests counter of total records served, plus batches, records, last batch size, and workers KPIs). On by default, best-effort, and gated on an active session (no cost when no dashboard is active). Finished by close.
dashboard – optional Dashboard resolution overrides (defer to the active session or env when None).
dashboard_port – optional Dashboard resolution overrides (defer to the active session or env when None).
open_browser – optional Dashboard resolution overrides (defer to the active session or env when None).
job_id – str | None Dashboard job id; defaults to a per-instance id so several batchers are distinguishable.
max_queue – int | None Max requests waiting to be coalesced. None = 8 per worker; 0 = unbounded.
on_full – “block” | “reject” What submit does when the queue is full: wait for space (default), or raise QueueFullError.
- close(*, wait=True, timeout=5.0)¶
Stop accepting new requests and shut down every worker. Requests already enqueued are still served; further submit raises. Safe to call more than once. (Using the pool as a with block calls this for you.)
Example:
pool.close()
- Parameters:
wait – bool Join the worker threads before returning.
timeout – float Max seconds to wait for each worker’s join (when wait).
- match(record, *, candidates=None, probe_embedding=None)¶
Submit a record and wait for its result (the blocking form of submit).
Example:
matches = pool.match({"name": "apple pie", "brand": "acme"}) # -> pandas.DataFrame [id1, id2, prediction, confidence]
- Parameters:
record – the incoming record (as in RealtimeMatcher.match).
candidates – list[int] | None Match against these ids only, or None to search for candidates.
probe_embedding – numpy.ndarray | None A precomputed embedding for the record, or None to embed it.
- Returns:
pandas.DataFrame [id1, id2, prediction, confidence] for the record.
- scale(n)¶
Grow or shrink the pool to
nworkers, at any time while serving.Scaling up starts new workers immediately. Scaling down is graceful: a retiring worker finishes its current unit (dropping no in-flight request) and then exits, so the effective count reaches
nshortly after the call.Example:
pool.scale(8) # burst: 8 parallel workers pool.scale(2) # quiet: drop back to 2
- Parameters:
n – int, >= 1 Desired pool size.
- Returns:
self
- Raises:
ValueError – n < 1.
RuntimeError – the pool has been closed.
- submit(record, *, candidates=None, probe_embedding=None, timeout=None)¶
Enqueue one record; return a Future for its result.
Thread-safe: call from as many request threads as you like (a streaming source of requests). One failing request only fails its own Future.
The queue is bounded, so a producer faster than the pool gets backpressure rather than unbounded memory growth. When it is full this blocks until space frees (the default
on_full="block") or raises QueueFullError (on_full="reject", for a caller that would rather shed load).Example:
handle = pool.submit({"name": "apple pie", "brand": "acme"}) matches = handle.result() # the [id1, id2, prediction, confidence] frame
- Parameters:
record – dict | pandas.Series | 1-row pandas.DataFrame | pyspark.Row The incoming record (as in RealtimeMatcher.match).
candidates – list[int] | None Matching-only candidate ids for this record, or None to block it.
probe_embedding – numpy.ndarray | None Precomputed embedding for this record, or None to embed it.
timeout – float | None Max seconds to wait for queue space before raising QueueFullError. None waits indefinitely (under
on_full="block").
- Returns:
- concurrent.futures.Future
Resolves to this record’s [id1, id2, prediction, confidence] frame (or the request’s own exception).
- Raises:
RuntimeError – the pool has been closed.
QueueFullError – the queue is full and the policy is to reject (or the wait exceeded timeout).
- property workers¶
The current number of live worker threads in the pool.
Example:
pool.workers # -> 4
- Returns:
int
- class madmatcher_pro.serving.ProcessMatcherPool(bundle_path, *, n_procs=None, load_kwargs=None, loader=None, semantic_cache_mb=None, max_in_flight=None, on_full='block')¶
Bases:
objectServe records through a bundle on a pool of worker processes, for near-linear throughput across cores.
Each worker process loads the bundle once, so the pool takes a bundle path rather than a loaded matcher, and each process pays its own load cost (JVM, model, embedder). Size the pool against measured memory, not core count.
- Parameters:
bundle_path – str | Path The published serving bundle each worker loads (as RealtimeMatcher.load).
n_procs – int | None Number of worker processes (default: the machine’s CPU count).
load_kwargs – dict | None Extra kwargs for RealtimeMatcher.load (e.g.
threshold,id_col). spark is not picklable and not needed: a resident bundle loads without it.loader – callable | None Advanced/testing: a picklable
loader(bundle_path, **load_kwargs)that returns the per-process matcher. Defaults to RealtimeMatcher.load.semantic_cache_mb – float | None Total memory the whole pool may spend caching semantic index cells, split evenly across the workers. None reads MM_SEMANTIC_CACHE_MB (default 1024); 0 disables caching. Only a bundle without the staged cell store uses this cache: a bundle published with staged_cells/ memory-maps one shared, read-only copy of the cells across all workers, and this budget is unused.
max_in_flight – int | None Max requests outstanding at once, so an unbounded producer cannot grow the parent’s heap. None = 4 per process; 0 = unbounded.
on_full – “block” | “reject” What submit does when that cap is reached: wait for a slot (default), or raise QueueFullError.
Example:
with ProcessMatcherPool("bundles/v1", n_procs=8, load_kwargs={"threshold": 0.7}) as pool: for matches in pool.imap_unordered(records): # served across processes handle(matches)
- property procs¶
The number of worker processes in the pool.
- Returns:
int
- submit(record, *, candidates=None, probe_embedding=None, key=None, timeout=None)¶
Send one record to a worker process; return a Future for its result.
In-flight work is bounded: once max_in_flight requests are outstanding this waits for one to finish (the default
on_full="block") or raises QueueFullError (on_full="reject"). The bound keeps an unbounded submit loop from growing the parent process’s heap with pending pickled records.Example:
handle = pool.submit({"name": "apple pie"}, probe_embedding=v, key="req-1") matches = handle.result()
- Parameters:
record – dict | pandas.Series | 1-row pandas.DataFrame | pyspark.Row The incoming record (pickled to the worker; must be picklable).
candidates – list[int] | None Matching-only candidate ids, or None to block.
probe_embedding – numpy.ndarray | None Precomputed embedding for the record, or None to embed it.
key – any | None Value identifying this record in the results (id2).
timeout – float | None Max seconds to wait for a slot before raising QueueFullError.
- Returns:
concurrent.futures.Future resolving to the [id1, id2, prediction, confidence] frame (or the request’s exception).
- Raises:
RuntimeError – the pool has been closed.
QueueFullError – no slot free and the policy is to reject (or timeout hit).
- property in_flight¶
Requests submitted but not yet finished.
- property submitted¶
Requests submitted over this pool’s lifetime.
- match(record, *, candidates=None, probe_embedding=None, key=None)¶
Send one record and wait for its result (the blocking form of submit).
- map(records, *, max_in_flight=None)¶
Serve an iterable of records across the processes and yield each result in submission order, streaming, with a bounded number in flight (so an unbounded stream does not materialize). For per-record candidates or embeddings, use submit directly.
- Yields:
each record’s [id1, id2, prediction, confidence] frame, in input order.
- imap_unordered(records, *, max_in_flight=None)¶
Like map, but yield each result as soon as it is ready (not in input order), for maximum streaming throughput. Bounded in-flight.
- close(*, wait=True)¶
Shut the pool down: release each worker’s matcher, then stop the processes.
Releasing each matcher frees resources (a Lucene reader, staged temp state) that killing the process would leave behind. Safe to call more than once.
- Parameters:
wait – bool Wait for pending work to finish before returning.
- exception madmatcher_pro.serving.QueueFullError¶
Bases:
RuntimeErrorThe pool’s queue is full and its admission policy is to reject.
Signals load shedding: the caller decides whether to retry, drop, or push back on its own upstream. Only raised under
on_full="reject"; the default policy blocks the producer instead.
Bundle refresh¶
- class madmatcher_pro.serving.HotSwappableMatcher(artifacts_dir, *, spark=None, poll_interval=0.005, drain_timeout=30.0, track=True, dashboard=None, dashboard_port=None, open_browser=None, **load_kwargs)¶
Bases:
objectA RealtimeMatcher you can atomically swap to a refreshed bundle.
Thread-safe: match runs concurrently with other match calls and with a swap; swap and close are serialized with each other.
Load the first bundle and start answering records.
Switch to a refreshed bundle later with swap while requests keep flowing.
Example:
live = HotSwappableMatcher("bundles/v1", threshold=0.7) live.match({"name": "apple pie"}) # -> pandas.DataFrame [id1, id2, prediction, confidence]
- Parameters:
artifacts_dir – str | Path The bundle to load first (as RealtimeMatcher.load).
spark – pyspark.sql.SparkSession | None Passed to load; needed for a delex or SparkML bundle.
poll_interval – float How often, in seconds, to re-check whether a retiring bundle’s outstanding requests have finished.
drain_timeout – float | None Seconds to wait for a replaced bundle’s outstanding requests to finish before giving up on closing it (None waits forever). A request that runs longer than this leaves the old bundle open (a bounded, one-matcher leak) rather than blocking future swaps or being torn down mid-request.
track – bool Show the swap lifecycle on the dashboard: a Hot-swap job counting versions swapped in, with each swap’s load/drain/live/failed steps on the event feed and the live bundle as a current bundle metric. On by default, finished by close. Controls only the swap job, not the wrapped matcher’s own request tracking.
dashboard – optional Dashboard resolution overrides for track.
dashboard_port – optional Dashboard resolution overrides for track.
open_browser – optional Dashboard resolution overrides for track.
**load_kwargs – Forwarded to RealtimeMatcher.load (e.g. threshold, id_col).
- property version¶
The location (path) of the bundle currently being served.
Example:
live.version # -> "bundles/v1"
- Returns:
the artifacts directory of the bundle currently in use.
- Return type:
str
- property current¶
The live matcher object, for read-only inspection or tests.
Prefer match for real use: the matcher returned here may be replaced and closed at any time.
- Returns:
the matcher currently answering requests.
- Return type:
- match(record, **kwargs)¶
Match one incoming record against the corpus and return its predicted matches.
Runs alongside other match calls and alongside a swap: a request that has already started finishes on the bundle it started on, even if a swap makes a newer bundle current partway through.
Example:
live = HotSwappableMatcher("bundles/v1", threshold=0.7) live.match({"name": "apple pie"}) # -> pandas.DataFrame [id1, id2, prediction, confidence]
- Parameters:
record – dict | pandas.Series | pandas.DataFrame | pyspark.sql.Row The incoming record to match (as RealtimeMatcher.match).
**kwargs – Forwarded to RealtimeMatcher.match (candidates, threshold, probe_embedding).
- Returns:
- pandas.DataFrame [id1, id2, prediction, confidence]
One row per candidate match for the record.
- Raises:
RuntimeError – this matcher has been closed.
- property undrained¶
Count of bundles left open because their requests had not finished.
Normally 0. A number that keeps climbing means requests are hanging, not that swapping is leaking.
- swap(artifacts_dir)¶
Switch to a refreshed bundle with no downtime, then retire the old one once its outstanding requests finish.
The new bundle loads and warms up (including any build work) while the old one keeps serving, then requests switch over in one step. Loading re-checks the license and realtime entitlement, so a lapsed license stops future serving.
Example:
live = HotSwappableMatcher("bundles/v1") live.swap("bundles/v2") # v2 now serves; v1 retired once its requests finish
- Parameters:
artifacts_dir – str | Path The refreshed bundle to load and make current.
- Returns:
self, now serving the new bundle.
- Return type:
- Raises:
RuntimeError – this matcher has been closed.
Exception – a failed load (invalid bundle, lost entitlement) raises and leaves the current bundle serving unchanged.
- close()¶
Shut down and free the bundle currently being served.
Waits for any outstanding requests to finish first (up to drain_timeout) so it never pulls a bundle out from under a running request. Safe to call more than once.
- class madmatcher_pro.serving.BundleWatcher(matcher, pointer_dir, *, poll_seconds=30.0, spark=None, on_swap=None)¶
Bases:
objectKeep one serving replica on the current bundle, by polling the pointer.
Runs a daemon thread that checks the pointer every poll_seconds and, when the version changes, calls swap on the matcher you gave it, which loads the new bundle, drains in-flight requests, and closes the old one. Serving never stops.
A failed swap (a bad or half-published bundle) is logged and the replica keeps serving the version it has, so a broken bundle never takes a replica down.
Example:
hot = HotSwappableMatcher("bundles/v1", threshold=0.7) watcher = BundleWatcher(hot, "bundles").start() ... # serve; new versions are picked up automatically watcher.stop()
- Parameters:
matcher – HotSwappableMatcher The live matcher to swap.
pointer_dir – str | pathlib.Path Where publish_bundle_version writes the pointer.
poll_seconds – float How often to check. Seconds to minutes is normal; the check is one small read.
spark – SparkSession | None Only needed for a non-local pointer_dir.
on_swap – callable | None Called as on_swap(version, bundle) after a successful swap.
- property current_version¶
The version label this replica last swapped to (None before the first).
- check_once()¶
Poll the pointer once and swap if it names a new version.
Exposed so a caller can drive the check from its own loop instead of running the polling thread.
- Returns:
True if a swap happened.
- Return type:
bool
- start()¶
Begin polling in a daemon thread.
- Returns:
self
- stop(*, timeout=5.0)¶
Stop polling. Does not close the matcher; that is the caller’s responsibility.
- madmatcher_pro.serving.publish_bundle_version(pointer_dir, bundle_path, *, version=None, spark=None, **info)¶
Point serving replicas at a bundle. They pick it up on their own next poll.
Call this only after the bundle is fully published: the pointer signals that the bundle is safe to load, so writing it early would send replicas at a half-written bundle.
Example:
publish_serving_artifacts("s3://.../bundles/v2", ...) publish_bundle_version("s3://.../bundles", "s3://.../bundles/v2", version="v2")
- Parameters:
pointer_dir – str | pathlib.Path Directory holding the pointer file. Every replica must be able to read it.
bundle_path – str | pathlib.Path The bundle replicas should serve.
version – str | None A label for logs and the pointer; defaults to the bundle’s directory name.
spark – SparkSession | None Only needed for a non-local pointer_dir.
**info – Extra fields recorded in the pointer (build id, row counts, and so on).
- Returns:
the pointer that was written.
- Return type:
dict
- madmatcher_pro.serving.read_bundle_version(pointer_dir, *, spark=None)¶
The bundle replicas should currently serve, or None if no pointer is set.
- Parameters:
pointer_dir – str | pathlib.Path
spark – SparkSession | None
- Returns:
dict with bundle and version, or None.
Streaming runners¶
- madmatcher_pro.serving.run_matching(source, sink, matcher, *, batch_size=None, passthrough=False, progress_every=0)¶
Stream every record from source through matcher into sink.
Holds only the current batch, so an unbounded source works. Not durable: for crash-resumable or multi-worker runs use run_matching_durable.
Example:
with ParquetSource("incoming/", id_col="_id") as src, \ ParquetSink("out/matches") as sink: stats = run_matching(src, sink, matcher) print(stats.records, stats.matches)
- Parameters:
source – RecordSource | iterable of (key, record) Where records come from.
sink – ResultSink Where results go.
matcher – RealtimeMatcher | MatcherPool | ProcessMatcherPool Anything with match(record, …). A pool matches in parallel.
batch_size – int | None Use match_batch in groups of this size (one embed pass + one predict per group). None matches one record at a time.
passthrough – bool Also give the sink the incoming record, so it can store the original fields beside the match.
progress_every – int Log a line every N records (0 = silent).
- Returns:
MatchRunStats
- madmatcher_pro.serving.run_matching_durable(source, sink, matcher, *, checkpoint_dir, granularity='record', shard_size=1000, batch_size=None, passthrough=False, worker_id=None, spark=None, progress_every=0, max_units=None, exactly_once=False, lease_seconds=None)¶
Stream source through matcher into sink durably, claiming work so several workers can share one input without coordinating.
Re-running after a crash skips what already completed. Running the same command on several machines that can all see checkpoint_dir splits the work between them: each claims what it can, and together they cover the input.
Granularity for live streams: granularity=”record” (the default) claims one record at a time, so a record is processed the moment it arrives. granularity=”shard” batches claims for cheaper markers, but a shard is processed only once it has filled, so on a live stream a trailing partial shard would wait for records that may never come. Use “shard” only for bulk backfill of records that are all already present.
Delivery is at-least-once by default: a worker that dies after writing results but before its completion marker leaves the record claimable, and a later worker redoes it. exactly_once=True additionally skips any record already marked done, suppressing that redelivery. The remaining window (a crash between the sink write and the marker) is closed only by an idempotent sink that de-duplicates on (id1, id2).
Ordering: the source is read in order, so a worker reads past units it does not claim. That is cheap for a file source but wasteful for an expensive one, so give each machine its own source slice when reading is costly.
Output: at granularity=”record” the sink is flushed after every record (results must be durable before the completion marker), so a ParquetSink writes one part file per matched record. For compact bulk output prefer granularity=”shard” (one flush per shard) or a streaming/coalescing sink.
Example:
stats = run_matching_durable( ParquetSource("incoming/", id_col="_id"), ParquetSink("out/matches"), matcher, checkpoint_dir="ckpt/run-7")
- Parameters:
source – RecordSource | iterable of (key, record)
sink – ResultSink
matcher – RealtimeMatcher | MatcherPool | ProcessMatcherPool
checkpoint_dir – str | pathlib.Path Where claims and completion markers live. Must be reachable by every worker sharing the run, and not reused across different inputs (the manifest pins the configuration and refuses a mismatch).
granularity – “record” | “shard” Claim per record (safe for live streams) or per fixed-size group (cheaper markers, bulk only).
shard_size – int Records per shard, when granularity=”shard”.
batch_size – int | None Use match_batch in groups of this size (shard mode only).
passthrough – bool Also give the sink the incoming record.
worker_id – str | None Identifies this worker in claim files; defaults to host+pid.
spark – SparkSession | None Only needed for a non-local checkpoint_dir.
progress_every – int Log a line every N records (0 = silent).
max_units – int | None Stop after completing this many units (records, or shards in shard mode).
exactly_once – bool Skip records already marked done instead of re-processing them.
lease_seconds – float | None Seconds before an incomplete claim is assumed abandoned and reclaimable by another worker (None uses the store default). Raise it for a granularity=”shard” run whose shards legitimately take longer than the default, so a slow shard is not reclaimed from its worker.
- Returns:
MatchRunStats
- Raises:
ValueError – checkpoint_dir was used for a different configuration.
- class madmatcher_pro.serving.MatchRunStats¶
Bases:
objectCounts and timing from a completed run.
A “unit” is whatever was claimed: one record at the default granularity, or one shard in shard mode. shards_done and shards_skipped are aliases of units_done and units_skipped.
- records¶
Records matched by this worker.
- matches¶
Match rows written by this worker.
- units_done¶
Units this worker claimed and completed.
- units_skipped¶
Units another worker had already done or claimed.
- elapsed¶
Wall-clock seconds.
- property shards_done¶
Alias of units_done.
- property shards_skipped¶
Alias of units_skipped.
- madmatcher_pro.serving.batched(source, size)¶
Group a source’s (key, record) pairs into lists of at most size.
Lazy: it holds one batch, not the whole stream, so an unbounded source can feed match_batch or a shard runner.
Example:
for chunk in batched(ParquetSource("in/"), 1000): ... # chunk is a list of (key, record)
- Parameters:
source – iterable of (key, record)
size – int, >= 1
- Yields:
list[(key, record)]
Record sources¶
- class madmatcher_pro.serving.RecordSource¶
Bases:
objectBase source: iterate to get (key, record) pairs.
Subclasses implement __iter__. A key of None means the record has no id of its own and none was supplied; the orchestrator then assigns a sequence number, so a result is always attributable to its input.
Example:
class OneRecord(RecordSource): def __iter__(self): yield "only", {"name": "apple pie"}
- close()¶
Release anything the source holds open. Safe to call more than once.
- class madmatcher_pro.serving.IterableSource(records, *, id_col=None)¶
Bases:
RecordSourceWrap any Python iterable of records (or of (key, record) pairs).
The in-memory case, and the adapter for a caller who already has their own reader. A generator is consumed lazily, so an unbounded one is fine.
Example:
IterableSource([{"_id": 1, "name": "apple pie"}], id_col="_id") IterableSource(stream_from_kafka()) # keys assigned downstream
- Parameters:
records – iterable Records, or (key, record) 2-tuples.
id_col – str | None Column holding each record’s own id, used as its key.
- class madmatcher_pro.serving.ParquetSource(path, *, id_col=None, columns=None, batch_rows=10000)¶
Bases:
RecordSourceStream records out of a parquet file or directory, in batches.
Reads through pyarrow one batch at a time, so a table far larger than memory is fine.
Example:
with ParquetSource("incoming/*.parquet", id_col="_id") as src: run_matching(src, sink, matcher)
- Parameters:
path – str | pathlib.Path Parquet file or directory.
id_col – str | None Column holding each record’s own id, used as its key.
columns – list[str] | None Read only these columns (plus id_col). None reads all.
batch_rows – int Rows per read batch.
- class madmatcher_pro.serving.CsvSource(path, *, id_col=None, chunk_rows=10000, **read_csv_kwargs)¶
Bases:
RecordSourceStream records out of a CSV, in chunks.
Example:
CsvSource("incoming.csv", id_col="_id")
- Parameters:
path – str | pathlib.Path The CSV file.
id_col – str | None Column holding each record’s own id, used as its key.
chunk_rows – int Rows per read chunk.
**read_csv_kwargs – Passed to pandas.read_csv (dtype, sep, …).
- class madmatcher_pro.serving.QueueSource(queue_=None, *, maxsize=1000, id_col=None)¶
Bases:
RecordSourceDrain a queue.Queue that a producer thread fills.
A producer (a request handler or consumer) put`s records in and the orchestrator matches them as they arrive. Iteration ends when the producer calls `close(), so a consumer loop terminates cleanly rather than blocking forever.
Example:
src = QueueSource(maxsize=1000, id_col="_id") threading.Thread(target=lambda: [src.put(r) for r in stream] or src.close()).start() run_matching(src, sink, matcher)
- Parameters:
queue – queue.Queue | None An existing queue to drain, or None to create one (see maxsize).
maxsize – int Bound for a created queue, so a fast producer gets backpressure instead of growing memory. 0 = unbounded.
id_col – str | None Column holding each record’s own id, used as its key.
- put(record, *, key=None, timeout=None)¶
Offer one record to the source (blocks when the queue is full).
- Parameters:
record – dict
key – any | None Its key; None falls back to id_col.
timeout – float | None Max seconds to wait for space.
- close()¶
Signal that no more records are coming, ending iteration.
Result sinks¶
- class madmatcher_pro.serving.ResultSink¶
Bases:
objectBase sink: every hook is a no-op, so a subclass implements only write.
A sink must be safe to call from several worker threads at once (the pools serve in parallel), so write implementations should lock around shared state.
Example:
class CountingSink(ResultSink): def __init__(self): self.n = 0 def write(self, result, record=None): self.n += len(result)
- write(result, record=None)¶
Take one record’s match results.
- Parameters:
result – pandas.DataFrame [id1, id2, prediction, confidence], possibly empty (a record with no match still arrives, so a sink can tell “no match” from “not processed”).
record – dict | None The incoming record, when the caller asked for passthrough.
- Returns:
None
- flush()¶
Push any buffered results to their destination. Called periodically and before close.
- close()¶
Flush and release the destination. Safe to call more than once.
- class madmatcher_pro.serving.MemorySink¶
Bases:
ResultSinkKeep every result frame in memory.
Grows with the stream, so it suits bounded runs, tests, and interactive use. For an unbounded stream use CallbackSink or ParquetSink.
Example:
sink = MemorySink() run_matching(source, sink, matcher) sink.frames # -> list[DataFrame] sink.to_frame() # -> one concatenated DataFrame
- to_frame()¶
All results as one DataFrame (empty with the right columns if none).
- Returns:
pandas.DataFrame [id1, id2, prediction, confidence]
- class madmatcher_pro.serving.DataFrameSink¶
Bases:
MemorySinkAccumulate results into one pandas.DataFrame, for handing straight on to the next step of a pipeline.
Example:
sink = DataFrameSink() run_matching(source, sink, matcher) df = sink.result # -> one DataFrame of every match
- property result¶
Every match so far as a single DataFrame.
- class madmatcher_pro.serving.CallbackSink(fn)¶
Bases:
ResultSinkHand each result to your own function as it is produced.
For custom handling: post to an API, push to a queue, update a store. Holds nothing, so it works on an unbounded stream.
Example:
CallbackSink(lambda result, record: publish(result))
- Parameters:
fn – callable Called as fn(result, record). A callable taking one argument is also accepted and receives just the result.
- class madmatcher_pro.serving.ParquetSink(out_dir, *, batch_rows=10000, part_prefix=None, passthrough_cols=None, spark=None)¶
Bases:
ResultSinkAppend results to parquet, batched, through backend-agnostic IO.
Writes one part file per batch under out_dir (local, HDFS, S3, GCS, whatever FileSystemStore resolves). Each part is written to a .tmp sibling and renamed, so a reader never sees a half-written part. Holds at most batch_rows in memory, so an unbounded stream is fine.
Example:
with ParquetSink("out/matches", batch_rows=50_000) as sink: run_matching(source, sink, matcher)
- Parameters:
out_dir – str | pathlib.Path Directory to write part files into (created if absent).
batch_rows – int Rows to buffer before writing a part.
part_prefix – str Filename prefix, so several writers into one dir do not collide. Defaults to a per-instance value.
passthrough_cols – list[str] | None Record fields to store beside each match. None writes results only; naming fields multiplies the bytes per match, so it is opt-in.
spark – SparkSession | None Only needed for a non-local out_dir.
- class madmatcher_pro.serving.FanOutResultSink(sinks)¶
Bases:
ResultSinkSend every result to several sinks at once.
Useful for “keep it in a DataFrame and also write it durably” in one configuration. Each sink is isolated: if one raises, the others still receive the result, and the error is re-raised afterward rather than silently swallowed.
Example:
sink = FanOutResultSink([DataFrameSink(), ParquetSink("out/matches")])
- Parameters:
sinks – iterable of ResultSink
Batch-to-serving handoff¶
- class madmatcher_pro.serving.BatchToServing(*, spool_dir, bundle_dir, id_col=None, checkpoint_dir=None, segment_rows=1000)¶
Bases:
objectCoordinate the batch build -> live serving handoff, losing no record in between.
Example:
h = BatchToServing(spool_dir="spool/", bundle_dir="bundles/v1") # while the batch build runs, records keep arriving h.accept({"_id": 1, "name": "apple pie"}) # the build finishes; publish from its artifacts and go live h.publish(lambda out: publish_serving_artifacts( out, indexed_table=corpus, features=features, model=model, sparkly_index_path=index_path, # already built by the batch job prebuilt_feature_table=table_a, # already preprocessed by featurize fill_na=0.0)) h.start_serving(matcher) h.drain(sink) # the backlog, matched h.match({"_id": 99, "name": "banana bread"}) # live from here on
- Parameters:
spool_dir – str | pathlib.Path Where records accepted before serving is ready are buffered durably.
bundle_dir – str | pathlib.Path Where publish writes the bundle.
id_col – str | None Column holding a record’s own id, used as its key.
checkpoint_dir – str | pathlib.Path | None Makes the backlog drain resumable (per-record claims), so a crash mid-drain resumes rather than restarting the backlog. None drains without durability.
segment_rows – int Records per spool segment.
- property state¶
One of accepting, publishing, draining, serving, closed.
- property spool¶
The underlying RecordSpool (also a RecordSource).
- property matcher¶
The live matcher once serving has started, else None.
- accept(record, *, key=None)¶
Buffer one record durably before serving is ready.
Safe to call from many threads. Once serving has started, prefer match; accept still works (it spools), so a producer need not track which phase the handoff is in.
- Parameters:
record – dict
key – any | None The record’s key. None falls back to id_col.
- Returns:
The record’s key.
- publish(publish_fn)¶
Build the serving bundle from the finished batch job’s artifacts.
publish_fn receives the output directory and performs the publish, typically by calling publish_serving_artifacts with the indices and preprocessed corpus your batch job already produced.
Example:
h.publish(lambda out: publish_serving_artifacts( out, indexed_table=corpus, features=features, model=model, sparkly_index_path=index_path, prebuilt_feature_table=table_a, fill_na=0.0))
- Parameters:
publish_fn – callable Called as
publish_fn(bundle_dir).- Returns:
the bundle directory.
- Return type:
str
- start_serving(matcher)¶
Go live with a loaded matcher (or pool).
- Parameters:
matcher – RealtimeMatcher | MatcherPool | ProcessMatcherPool | HotSwappableMatcher
- Returns:
self
- drain(sink, *, batch_size=None, passthrough=False, clear=True, **kwargs)¶
Match everything spooled during the build, into sink.
Uses the durable runner when a checkpoint_dir was given, so an interrupted drain resumes per record rather than restarting the backlog.
- Parameters:
sink – ResultSink
batch_size – int | None Match in groups of this size.
passthrough – bool Also give the sink each incoming record.
clear – bool Delete drained spool segments afterwards. False keeps them (to re-drain into a second sink, or to inspect).
**kwargs – Passed to the underlying runner.
- Returns:
MatchRunStats
- match(record, *, key=None, **kwargs)¶
Match one record live, once serving has started.
Before serving has started this raises; use accept to buffer the record until the bundle is ready.
- Parameters:
record – dict
key – any | None The record’s key, used to stamp id2 on the result.
- Returns:
pandas.DataFrame [id1, id2, prediction, confidence]
- Raises:
RuntimeError – serving has not started yet.
- close()¶
Stop accepting and seal the spool.
Does not close the matcher; the caller owns its lifecycle, which may outlive this handoff.
- class madmatcher_pro.serving.RecordSpool(directory, *, segment_rows=1000, id_col=None)¶
Bases:
RecordSourceA durable append-and-drain buffer of (key, record) pairs.
Safe for many threads to append to at once. Also a RecordSource, so it plugs straight into run_matching / run_matching_durable.
Example:
spool = RecordSpool("spool/incoming") spool.append({"name": "apple pie"}, key="req-1") # while batch is running ... spool.close_segment() # stop accepting, seal what is buffered run_matching(spool, sink, matcher) # drain it once the bundle exists
- Parameters:
directory – str | pathlib.Path Where segment files live (created if absent).
segment_rows – int Records per segment before it is sealed and a new one started. Smaller segments become readable sooner; larger ones cost fewer files.
id_col – str | None Column holding a record’s own id, used as its key when append is given none.
- append(record, *, key=None)¶
Buffer one record durably.
The record is flushed to the OS on every append, so a process crash loses at most what the OS had not yet written.
- Parameters:
record – dict
key – any | None The record’s key. None falls back to id_col, then to a generated key.
- Returns:
The record’s key.
- close_segment()¶
Seal the segment being written, making it readable. Idempotent.
- close()¶
Seal and stop writing. Safe to call more than once.
- property appended¶
Records appended by this instance (not the total on disk).
- pending()¶
How many records are readable right now (in sealed segments).
- Returns:
int
- source_for(segments)¶
A one-shot RecordSource over exactly segments (a snapshot from segments()).
Draining over a fixed snapshot lets a drain match precisely the segments it will clear, so a concurrent append that seals a new segment mid-drain is neither double-matched nor lost. A segment deleted meanwhile is skipped.
- Parameters:
segments – list[pathlib.Path]
- Returns:
RecordSource
- segments(*, sealed_only=True)¶
Segment files, oldest first.
- Parameters:
sealed_only – bool Only complete segments (the default). False also lists the open one, for recovering a spool whose writer died without sealing.
- Returns:
list[pathlib.Path]
- recover_open_segments()¶
Seal segments left open by a writer that died, so they can be drained.
A crash leaves the in-progress segment as .open, which the reader skips. This seals any such segment (except the one this instance is writing) so its records are recovered; a torn final line is skipped on read.
- Returns:
segments recovered.
- Return type:
int
- clear_drained(segments=None)¶
Delete sealed segments once their records have been matched.
Call this only once the results are durable: it is the point of no return for the backlog. run_matching_durable records per-record completion markers, so a drain interrupted before this is safe to re-run.
- Parameters:
segments – list[pathlib.Path] | None The specific segments to delete, typically the snapshot a drain took at its start so a concurrent append that sealed a new segment is not deleted before it is matched. None deletes every currently sealed segment.
- Returns:
segments deleted.
- Return type:
int
Durable claims¶
Filesystem-only per-unit claims behind run_matching_durable; use these directly
only for a custom durable runner.
- class madmatcher_pro.serving.RecordClaimStore(root, *, fs, worker_id, lease_seconds=1800.0)¶
Bases:
_BaseClaimStoreClaim work one record at a time.
The right granularity for a live stream, where records arrive singly and waiting for a group to fill would stall them. Costs two small files per record (a claim and a completion marker); for a bulk backfill of many already-present records, use ShardClaimStore.
Example:
store = RecordClaimStore("ckpt/run-7", fs=fs, worker_id="host-1") if store.claim(key): ... # match and write results store.complete(key, matches=3)
- class madmatcher_pro.serving.ShardClaimStore(root, *, fs, worker_id, lease_seconds=1800.0)¶
Bases:
_BaseClaimStoreClaim work a fixed-size group of records at a time.
Cheap markers for bulk backfill. Not for a live stream: a shard is processed only once it has filled, so a trailing partial shard waits for records that may never come.
Example:
store = ShardClaimStore("ckpt/backfill", fs=fs, worker_id="host-1") if store.claim(12): # shard index ... store.complete(12, records=1000)
Advanced: resident probes and feature state¶
The pieces RealtimeMatcher composes, usable directly for a custom serving layer.
- class madmatcher_pro.serving.ResidentSparklyProbe(index, query_spec, limit, fill_keys=frozenset({}))¶
Bases:
ResidentProbeKeyword (word-based) search over your records, kept loaded in memory so it can quickly find candidate records that look similar to one incoming record, with no Spark needed.
Example:
probe = ResidentSparklyProbe.load("bundle/blocking/sparkly", limit=50) probe.probe({"name": "apple pie"}) # -> array([12, 42, 7])
- class madmatcher_pro.serving.ResidentSemanticProbe(*, centroids, dim, quantizer, target_dim, id_col, postings_dir, provider, fields, serializer_cfg=None, col_types=None, nprobe=32, limit=50, staged_cells=None)¶
Bases:
ResidentProbeMeaning-based (embedding) search over your records, one record at a time, in process.
Given an incoming record, this finds the stored records whose meaning is most similar to it (not just those sharing the same words) and returns them with a similarity score. Serving builds one from a persisted semantic index at publish time; you normally use it through
RealtimeMatcher, but it can be used directly.Example:
probe = ResidentSemanticProbe.load( "bundle/blocking/semantic", provider=my_embedder, fields=["name", "description"]) ids, scores = probe.probe({"name": "apple pie"})
With a large index that uses an approximate (HNSW) coarse quantizer, a record near a region boundary may occasionally return a slightly different candidate set than the batch engine; the similarity scores are always exact.
- class madmatcher_pro.serving.ResidentDelexProbe(nodes, sink, search_cols, in_schema, *, limit=None)¶
Bases:
ResidentProbeRuns a Delex blocking program (a set of matching rules) in memory to find the candidate records for one incoming record, with no Spark job on the lookup path.
Serving builds one from your records at publish time; you normally use it through RealtimeMatcher, but it can be used directly.
Example:
probe = ResidentDelexProbe.build(corpus_spark_df, blocking_program) probe.probe({"name": "apple pie"}) # -> array([12, 87, 103])
- class madmatcher_pro.serving.ResidentEmbeddingStore(store, dim, *, provider=None, fields=None, serializer_cfg=None, col_types=None, target_dim=None, staging_dir=None)¶
Bases:
objectA loaded table of your records’ embedding vectors, keyed by id, that computes the semantic-similarity (cosine) feature for a record against a set of candidates.
Serving builds one from an embedding table at publish time; you normally use it only through RealtimeMatcher, but it can be used directly.
Example:
store = ResidentEmbeddingStore.load( "bundle/embedding_store", provider=my_embedder, fields=["name"]) store.cosine_for_ids([12, 99], record={"name": "apple pie"})
- classmethod load(embedding_dir, *, id_col='_id', embedding_col='embedding', provider=None, fields=None, serializer_cfg=None, col_types=None, target_dim=None, dest_dir=None)¶
Load an embedding table (a folder of (id, vector) rows) into memory.
Example:
store = ResidentEmbeddingStore.load( "bundle/embedding_store", id_col="_id", embedding_col="embedding", provider=my_embedder, fields=["name", "brand"])
Uses the ready-to-mmap table publish_serving_artifacts writes into the bundle: it is mapped read-only, so several matchers over the same bundle (for example a ProcessMatcherPool’s worker processes) share one set of physical pages. A bundle published without that table falls back to staging a private copy and warns; re-publish to get the shared table.
- Parameters:
embedding_dir – str | pathlib.Path The bundle’s blocking/embedding_store folder. A direct path to a parquet embedding table also works (it is then staged privately).
id_col – str Name of the id column in that table.
embedding_col – str Name of the vector column in that table.
provider – an embedding provider, or None Used to turn an incoming record into a vector. If None, every call to cosine_for_ids must pass a precomputed probe_embedding.
fields – list[str] | None The record fields to build the incoming record’s embedding text from.
serializer_cfg – dict | None How to format that text (from the manifest); None uses the default.
col_types – dict | None Column-type hints so an incoming value is formatted like the stored records; None is fine for plain text fields.
target_dim – int | None Truncate an incoming embedding to this width before scoring (for Matryoshka embeddings); None keeps the full width.
dest_dir – str | None Where to stage the table when a private copy is needed; None uses a temp dir that close cleans up. Unused when the bundle already holds the shared table.
- Returns:
- ResidentEmbeddingStore
Ready to answer cosine_for_ids.
- serialize(record)¶
Turn one record into the text its embedding is built from (the same way the stored records were turned into text).
Example:
store.serialize({"name": "Apple Pie", "brand": "Acme"}) # -> "[COL] name [VAL] apple pie [COL] brand [VAL] acme"
- Parameters:
record – dict The incoming record as a column->value mapping.
- Returns:
- str
The embedding text for that record.
- cosine_for_ids(ids, *, record=None, probe_embedding=None, fill=0.0)¶
Semantic-similarity score of an incoming record against each of the given record ids, as a number from -1 to 1 (higher means more similar).
The incoming record is turned into a vector (embedded from record, or taken from probe_embedding), and each id’s stored vector is compared to it. An id that isn’t in the store gets fill and a one-time warning is logged (add its vector to the embedding table and re-publish to fix).
Example:
store.cosine_for_ids([12, 42, 99], record={"name": "apple pie"}) # -> array([0.83, 0.61, 0. ]) # id 99 wasn't in the store -> fill (0.0)
- Parameters:
ids – sequence of int The record ids to score against the incoming record.
record – dict | None The incoming record to embed. Required unless probe_embedding is given.
probe_embedding – numpy.ndarray | None A precomputed vector for the incoming record, skipping the embedder.
fill – float The value used for an id the store doesn’t have a vector for.
- Returns:
- numpy.ndarray of float, one per id in ids (same order)
The similarity for each id, or fill where the store has no vector.
- close()¶
Free the loaded embedding table and delete its temporary files. Safe to call more than once.
Example:
store.close()
- Returns:
None
- class madmatcher_pro.serving.ServedState(corpus, features, fill_na=0.0, column_types=None)¶
Bases:
objectYour saved record-comparison state, loaded into memory and ready to compare an incoming record against your records while serving.
You normally get one from ServedState.load and hand it to featurize_one.
Example:
served = ServedState.load("bundle/feature_state") served.known_ids([1, 2, 999999]) # -> array([1, 2])
- classmethod load(dir_path, fill_na=0.0)¶
Load the saved record-comparison state (from publish_feature_state) back into memory so it can score incoming records.
Example:
served = ServedState.load("bundle/feature_state") # -> a ready-to-use ServedState
- Parameters:
dir_path – str | pathlib.Path Folder the state was saved to by publish_feature_state.
fill_na – float Value to use for a comparison that can’t be computed (e.g. a field the incoming record is missing).
- Returns:
- ServedState
The loaded state, ready for known_ids and featurize_one.
- known_ids(ids)¶
Keep only the record ids that actually exist in your data, dropping any unknown id so it is skipped instead of causing an error later.
Example:
served.known_ids([1, 2, 999999]) # -> array([1, 2])
- Parameters:
ids – sequence of int Candidate record ids to check against your data.
- Returns:
- numpy.ndarray of int64
The ids from ids that are present (same order), unknowns removed.
- madmatcher_pro.serving.featurize_one(probe_record, candidate_ids, served)¶
Turn one incoming record plus a set of candidate records into the comparison feature vectors a trained matcher scores (one row of numbers per candidate).
Example:
fvs = featurize_one({"name": "apple pie"}, [12, 42], served) # -> (2, n_features) float32 array
- Parameters:
probe_record – dict (field name -> value) The incoming record to compare against the candidates.
candidate_ids – sequence of int Ids of your records to compare the incoming record against.
served – ServedState The loaded state from ServedState.load.
- Returns:
- numpy.ndarray of shape (len(candidate_ids), n_features), float32
One comparison feature vector per candidate id, in the same order.
- madmatcher_pro.serving.publish_feature_state(A, B, features, out_dir, prebuilt_table_a=None)¶
Build and save the record-comparison state for your data, so the real-time serving layer can load it later and score incoming records without reprocessing the whole dataset.
Example:
publish_feature_state(corpus_df, None, features, "bundle/feature_state") # writes the reusable state into bundle/feature_state/ ; returns None
- Parameters:
A – Spark DataFrame Your records (the indexed data that incoming records are compared against).
B – Spark DataFrame, or None A second table to compare A against; pass None for a single table (the common case).
features – list of feature objects The record comparisons to compute (e.g. from create_features, or the ones a trained matcher already carries).
out_dir – str | pathlib.Path Folder to write the saved state into (created if it does not exist).
prebuilt_table_a – MemmapDataFrame | None An already-preprocessed corpus table, to avoid rebuilding it. A batch featurize run produces exactly this (via build_preprocessed), so a pipeline that goes on to publish can pass it straight through. It must have been built from A with these same features, or serving would score against different state than it advertises. Note that a batch-built table was registered with the SparkContext (addFile) by the featurize run that made it, so reusing it here keeps that registration – one more reason to publish in its own Spark application rather than as a tail stage.
- Returns:
None
- madmatcher_pro.serving.reciprocal_rank_fusion_local(sources, limit, *, kappa=60, cosine_fill=0.0)¶
Merge several blockers’ candidate lists for one record into a single ranked list, so a record found by more than one blocker ranks higher.
Example:
fused_ids, scores = reciprocal_rank_fusion_local( [{"ids": [12, 42], "scores": [3.1, 2.0], "weight": 0.5, "semantic": False}, {"ids": [42, 7], "scores": [0.9, 0.8], "weight": 0.5, "semantic": True}], limit=50) # fused_ids: array([42, 12, 7]); scores: the meaning-similarity per id
- Parameters:
sources – list of dict One entry per blocker, each with keys: ids (its candidate ids), scores (each id’s ranking strength, or None if the blocker gives no order), weight (how much to weight this blocker, default 0.5), semantic (True if scores are meaning-similarity values to carry through), and optional shared_rank (True to treat every id as equally ranked, for an unordered blocker).
limit – int The most candidates to keep after merging.
kappa – int A rank-smoothing constant (larger flattens the contribution of rank).
cosine_fill – float The similarity value recorded for a candidate no meaning-based blocker ranked.
- Returns:
- tuple (numpy.ndarray of int ids, numpy.ndarray of float | None)
The merged candidate ids, best first (at most limit), and each id’s meaning-similarity score. The second value is None when no blocker was a meaning-based one.
Probe serializers¶
How an incoming record’s fields become the one text a resident embedder embeds, matching the batch-time corpus serialization.
- class madmatcher_pro.serving.ProbeSerializer¶
Bases:
ABCSerialize one probe record to embedding text. Subclasses differ only in how each field is formatted and how the parts join; the base handles the shared walk: iterate
fieldsin order, convert each value to text (using the column’s Spark type fromcol_typesfor parity with the corpus), drop null or uncastable values, and lower-case whenself.lower.
- class madmatcher_pro.serving.DittoProbeSerializer(*, col_token='[COL]', val_token='[VAL]', lower=True)¶
Bases:
ProbeSerializerFormats a record as
[COL] <field> [VAL] <value>for each field, joined by spaces, dropping empty fields. This is the serving twin of the batchsemantic.serialize.DittoSerializer, so a probe’s text matches how the corpus was embedded.Example:
DittoProbeSerializer().serialize( {"name": "Apple Pie", "brand": "Acme"}, ["name", "brand"]) # -> "[COL] name [VAL] apple pie [COL] brand [VAL] acme"
Create a Ditto serializer, optionally customizing the marker tokens and whether values are lower-cased.
Example:
DittoProbeSerializer(col_token="[COL]", val_token="[VAL]", lower=True)
- Parameters:
col_token – str The marker placed before each field name (keyword-only).
val_token – str The marker placed before each field value (keyword-only).
lower – bool Whether to lower-case each value before formatting (keyword-only).
- Returns:
None
- class madmatcher_pro.serving.PlainConcatProbeSerializer(*, sep=' ', lower=True)¶
Bases:
ProbeSerializerJoins the field values directly with a separator, with no markers, keeping empty strings and dropping only null fields. This is the serving twin of the batch
semantic.serialize.PlainConcatSerializer, so a probe’s text matches how the corpus was embedded.Example:
PlainConcatProbeSerializer(sep=" ").serialize({"name": "Apple Pie"}, ["name"]) # -> "apple pie"
Create a plain-concatenation serializer, optionally setting the separator and whether values are lower-cased.
Example:
PlainConcatProbeSerializer(sep=" ", lower=True)
- Parameters:
sep – str The string placed between field values (keyword-only).
lower – bool Whether to lower-case each value before joining (keyword-only).
- Returns:
None
- madmatcher_pro.serving.register_serializer(cls)¶
Record a serializer class under its
kindname so it can be saved and reloaded later. Use as a decorator on a ProbeSerializer subclass.Example:
@register_serializer class MyScheme(ProbeSerializer): kind = "my_scheme" # serializer_from_manifest({"kind": "my_scheme"}) can now rebuild it
- Parameters:
cls – type A ProbeSerializer subclass that sets a non-empty
kindclass attribute (an empty or missingkindis rejected).- Returns:
- type
The same class, unchanged, so it works as a decorator.