Performance notes¶
Performance depends on storage backend, indexing choices, payload size, and query patterns.
Storage backend¶
SpecStar uses a storage factory to create a per-model storage instance:
- Memory storage: suitable for development / small datasets; fastest, but not persistent.
- Disk storage (or other persistent backends): suitable for production; performance depends on IO.
If you store blobs, blob store behavior may also depend on the chosen storage factory.
Indexing¶
Use add_model(indexed_fields=...) to index frequently queried fields.
Trade-offs: - Indexing improves query speed and filtering capability. - Indexing increases write/update cost and storage size.
Listing & pagination¶
For large datasets: - use paginated list endpoints - avoid returning huge payloads in one request - prefer querying fields that are indexed
Updating many rows at once¶
Pushing one change across many rows — mirroring a collection's visibility onto
every document in it, say — is not a job for a loop of update(). Each
iteration costs a meta read, a payload read, an encode and two writes, and on a
remote backend every one of those is a round-trip. Use patch_many instead:
result = rm.patch_many(
(QB["collection_id"] == collection_id).build(),
MergePatch({"visibility": "public"}),
)
print(result.patched, result.unchanged, result.conflicts, result.failures)
You describe the fields that change and never touch the data. That is what
makes the batching possible: once the read belongs to SpecStar, it can fetch a
whole batch in one query (octet_length + one SELECT on Postgres, concurrent
head_object + GET on S3) and write the batch back through save_*_bulk.
Handing the manager (resource_id, full_data) pairs instead would keep the read
on your side, one round-trip at a time.
What it does not change:
- Events, and therefore permission checks, still fire per row. Write ACLs are per-row policies; authorizing a batch is not the same as authorizing its rows.
- A row whose revision moved since it was selected is reported as a conflict, not overwritten — a patch rewrites the whole body, so writing anyway would discard a concurrent edit entirely. Re-run to pick conflicts up.
- A no-op patch creates no revision, so re-running a fan-out is cheap and you do not need to pre-filter rows that already hold the target value.
Failures are collected rather than raised, so one unwritable row does not
strand the rest; they come back in result.failures as (resource_id, reason).
Memory¶
max_bytes (default 256 MiB) caps how much row data is held at once. The
budget is in bytes rather than a row count on purpose: row size varies by
orders of magnitude between models, so a batch size that suits a flat derived
table will exhaust memory on documents that carry their whole extracted text.
Rows are read in budget-sized batches; a row larger than the entire budget is
still processed, on its own.
Watch the count¶
patch_many patches exactly what the query selects — including its
limit, which carries a process-wide default that a deployment can configure
via SPECSTAR_DEFAULT_QUERY_LIMIT. result.total reports how many rows were
selected, so compare it against what you expected rather than assuming the
fan-out covered everything.
Backup / restore¶
dump() and load() are streaming-based (msgpack archive). For large backups:
- run as offline maintenance jobs
- expect throughput to be limited by storage IO and payload size