Skip to content

feat(search): consumption mode on submit, applied mode on each page - #391

Open
tomaz-lc wants to merge 3 commits into
masterfrom
feat-search-mode
Open

tomaz-lc wants to merge 3 commits into
masterfrom
feat-search-mode

Conversation

@tomaz-lc

@tomaz-lc tomaz-lc commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

POST /v1/search accepts an optional mode field declaring how the caller intends to consume a search, and each paginated page reports what it actually ran as. This binds both sides of that in the SDK and the CLI.

mode is "interactive" (optimize for time to first results) or "batch" (optimize for throughput over the whole result set, which means fewer and larger pages). It says how the results will be read, not how many of them are wanted: the caller never sends a row count, the server sizes the pages. Results and their ordering are identical in both modes. Only where the page boundaries fall changes.

Three things below deserve a reviewer's attention before the rest: the SDK default is a behavior change, the CLI deliberately does not inherit it, and invalid values are refused client-side at a stated cost.

Read this first: the SDK default is a behavior change

Search.execute() defaults to mode="batch". This is a behavior change, not a backward-compatible addition.

Every existing SDK caller that does not pass mode now sends a request body with a key it did not send before, and gets different page shapes: fewer and larger pages.

  • No caller reads different data. Results and ordering are identical and only page boundaries move.
  • A caller can still notice. Anything that sizes a buffer per page, meters progress per page, or has a timeout tuned to the old page shape will see the difference.
  • mode=None is the exact restoration. Passing it explicitly sends no mode key at all, producing a byte-identical request to the one built before the field existed, and leaves the choice to the server. None remains meaningful; it is simply no longer the default.

The default is batch because the callers of this method are scripts and automation. They read every page and pay a fixed cost per round trip, so the throughput shape is the right one for them, and the shape tuned for a human watching a screen is the wrong one. A client that puts a person in front of the results passes "interactive" for itself rather than relying on this default, which is exactly what the CLI in this repo does.

Read this second: the CLI does not inherit that default

A person running a search at a terminal wants the first rows quickly. So the CLI resolves its own mode on every execution path and the SDK default can never reach a command line.

The rule: default to interactive, and switch to batch only where the run is evidently a bulk retrieval. A run qualifies when any of these hold:

  1. it is driven by a checkpoint (--checkpoint, with or without --resume), whose whole purpose is to fetch a result set to a file, resumably;
  2. stdout is not a terminal, so output is redirected to a file or piped into another program;
  3. the resolved output format is jsonl, csv or toon.

Everything else resolves to interactive, including every case those signals leave ambiguous. The asymmetry is deliberate: an over-large page in front of someone waiting at a terminal is a worse failure than a slightly chattier bulk fetch that nobody is watching.

Clause 3 follows this repo's own statements about each format rather than a guess. jsonl is what search run --explain already recommends for 100K+ events, csv is an export (sensor export --output csv), and format_toon's docstring says TOON exists for feeding CLI output into LLM prompts. None of the three is read a page at a time. json and yaml are excluded on purpose: both are readable, and json is merely what an unset --output resolves to whenever stdout is not a terminal, which clause 2 already covers.

Per-command outcome

Path Default Why
search run at a terminal interactive someone is waiting for rows
search run redirected or piped batch nobody reads pages as they arrive
search run --output jsonl / csv / toon batch the format exists for a consumer, not a reader
search run --checkpoint batch pages go to the file as they arrive; nothing renders until the search ends
search run --resume the recorded mode, else batch repeats what the original run actually did
search saved-run the same rule, minus the checkpoint clause it has no --checkpoint
search validate, estimate, queries, limits, saved-list/get/create/delete, checkpoints, checkpoint-show no mode at all none of them executes a search

--mode overrides the default in every one of these cases.

--output json at a terminal is the one genuinely ambiguous case, and it stays interactive per the rule above.

Read this third: invalid values are refused client-side

Anything that is not exactly "interactive" or "batch" is refused before the request is sent: ValidationError in the SDK, click.Choice in the CLI. Matching is exact, so "Batch" is a typo like any other, as is a non-string value.

The reason is that the server ignores a value it does not recognise and runs the search as interactive. Sending a typo therefore costs a full query, returns the mode the caller did not ask for, and reports nothing wrong. That is the hardest kind of failure to notice, and it is worth an up-front refusal. It also matches how this class already treats an unknown state on list_open_queries, and how --state is a click.Choice.

The honest tradeoff: a mode added server-side in future needs an SDK bump before callers of this package can use it. That is a real cost. It is accepted here because the set is small and stable, and because a silent no-op is the worse outcome of the two.

Implementation details

mode is submitted once, on the POST. Continuation pages are GETs carrying only the pagination token and no body at all, so they inherit the mode and can never resend it. It is not part of the token.

The mode is a hint and never an assumption. It is enabled per organization and the server may also select the mode itself, so a search can run as something other than what was asked for. Every page therefore reports what it actually ran as, in its result stats:

Field Meaning
searchMode the mode the page actually ran as
pageSize the soft per-page result cap
paginatedByteCap the reply-byte ceiling

All three are omitted for a search that ran without pagination. pageSize is a target rather than a promise: a page can end below it on a time limit, a byte limit or the end of the data, and slightly above it, because a page stops only once a whole batch of results has arrived.

The SDK already yielded the server's SearchResult objects untouched, so these three fields reach callers and --output json / jsonl with no parsing code added. What was missing was the human-readable path: --mode batch with no feedback about whether it was honoured. The stderr stats line now leads with the applied mode, read from the page's own stats rather than from cumulativeStats, since the counters roll up across pages but the mode is a property of this one page.

mode applies to a paginated search only. A query that must process all the data before it can answer anything, such as GROUP BY, ORDER BY or an aggregation over every record, is unaffected.

Two things a reviewer should know rather than discover

  • --resume opens a fresh search. It does not continue the original one: it issues a new POST and hands the stored token to the first GET. A resume is therefore a submission and has to re-state the mode, or a batch search silently downgrades to interactive halfway through. A checkpointed run records the mode it resolved to, and a resume repeats that record. --mode on a resume overrides it for that leg, which is allowed unlike --query or --start, because the mode moves the page boundaries and nothing else: the rows already on disk and the rows still to come are the same either way. Checkpoint metadata written before this change has no mode key, reads back as absent, and falls back to the checkpoint-driven default.
  • search run --quiet submits no search at all. _stream_search_output returns early on ctx.obj.quiet without ever iterating the results generator. This is existing behavior on master and is not introduced or changed here. It came up while deciding whether --quiet needed a rule of its own; it does not, because there is no submission. A test records it so a future change to --quiet surfaces there rather than as a silently batch-shaped run.

Backward compatibility

Surface Compatible? Notes
SDK Search.execute() default No See the first section. mode=None restores the previous request exactly.
SDK execute() signature Yes mode is keyword-accessible and every other parameter is unchanged and in the same position.
CLI search run / saved-run Yes No existing flag changes meaning. A command line that never mentioned --mode still sends the mode best suited to how that invocation is being consumed, and output is byte-identical.
Wire format Yes, additive One optional string key on the POST body. A server that does not support the field ignores it, and an unrecognised value is treated as interactive, so sending it cannot fail a request.
Response parsing Yes The three stats fields are read additively. Their absence is handled as "this search did not paginate", never as zero.
Checkpoint metadata on disk Yes, both directions The mode key is additive and every reader uses .get(). Old metadata read by new code falls back to the default; new metadata read by old code ignores the key. No schema version bump needed.

Feature flags

None. This package has no feature-flag mechanism; the behavior is selected per call by mode and per invocation by --mode.

Performance characteristics

No measurable change in this package. mode adds one short optional key to one request body per search, and the resolver is a handful of comparisons run once per invocation. Nothing is added to any per-page or per-row path, and no allocation behavior changes.

What does change is the shape of the work the server returns: batch means fewer, larger pages for the same result set, so fewer round trips and correspondingly larger responses per trip. That is the point of the field. This PR carries no throughput figures, because the effect is produced server-side and measuring it here would report the server's behavior rather than this package's.

Testing

pytest tests/unit/ tests/microbenchmarks/, the invocation CI runs: 4691 passed, 5 skipped on this branch rebased onto the current master, against 4572 passed on master with the same command, so this adds 119 tests and changes no existing one.

Coverage is split across three files:

  • tests/unit/test_sdk_search.py - the request body, byte for byte: the default submits batch, an explicit None submits a body byte-identical to one built before the field existed, and the default is pinned on the signature via inspect as well as through a call, because the signature is where a reader forms the wrong assumption. Plus exact-match rejection over cased and non-string values with no request made, one POST across a three-page search with token-only GETs, the token-resume submission, and the three stats fields present, absent, and differing from what was requested.
  • tests/unit/test_cli_search_mode.py - the rule end to end through CliRunner with only the HTTP layer mocked: every format on both sides of the terminal check, redirected output, checkpoint runs, all four resume precedence cases, saved-run, and --mode overriding on all six formats. A guard test asserts that the bulk-format set plus the explicitly listed readable set exactly equals --output's own choices, so a format added to one list and not the other fails loudly instead of being classified by omission.
  • tests/unit/test_search_helpers.py - the stats line: the applied mode leads it, is read from the page stats and not the roll-up, is omitted when the search did not paginate, survives an unrecognised value, and is escaped for control characters since it arrives over the wire.

Live end-to-end tests, run in CI

tests/integration/test_search_mode_e2e.py holds eight tests that submit real searches and assert on what the server reports. The PR build's integration step sets LC_TEST_SEARCH_MODE_E2E=1 next to its --oid / --key, so they run there, and all eight pass (the step: 171 passed, 2 skipped elsewhere in the suite).

Everywhere else they skip by default, gated twice over, because they start real, billed searches and need an organization with telemetry in the last day:

  1. tests/integration is outside testpaths in pyproject.toml, so a plain pytest run never collects the file.
  2. Even when the directory is named explicitly, every test skips unless LC_TEST_SEARCH_MODE_E2E is set. Running the file with credentials but without the variable yields 8 skipped.

To run them:

LC_TEST_SEARCH_MODE_E2E=1 pytest tests/integration/test_search_mode_e2e.py \
    --oid <organization id> --key <api key> -v

They also carry a searchmode_e2e marker, registered in pyproject.toml, so an otherwise live integration run can drop them with -m "not searchmode_e2e".

Test Asserts CI result
test_a_submitted_mode_comes_back_as_an_applied_mode (batch, interactive) a paginated search submitted with each mode reports an applied searchMode pass
test_a_paginated_page_reports_all_three_shape_fields a paginated page carries searchMode, pageSize and paginatedByteCap pass
test_the_applied_mode_holds_for_every_page_of_one_search the applied mode is identical across every page of one search, and rows arrive whole pass
test_both_modes_return_the_same_rows_in_the_same_order the two modes return the same rows in the same order pass
test_no_mode_leaves_it_to_the_organization mode=None sends no key and a page still reports a mode pass; the organization's default applied
test_the_cli_bulk_default_reaches_the_server_and_comes_back_applied search run --output jsonl with stdout not a terminal resolves its own mode and the page reports it pass; resolved batch, ran as batch
test_the_cli_mode_flag_reaches_the_server_and_comes_back_applied --mode interactive overrides that resolution on a real submission pass; ran as interactive

They never assert that searchMode equals what was requested. The mode is a hint that the server may decline, so demanding equality would fail for a legitimate reason and teach whoever sees it to ignore the file. They assert the field is present and is a mode this package knows; the applied modes in the table are what the test organization reported, printed by the tests. Where the environment is not rich enough to answer the question, for example an organization with too little telemetry to paginate, they skip with a reason saying so rather than failing on a condition that is not about this code.

Three checks were run against the tests themselves rather than the code. Reverting the SDK default to None fails 2 tests; making the CLI blindly inherit batch fails 8; and because CliRunner never provides a terminal, the resolver was additionally exercised under a real pty and a real pipe, which reproduced the per-command table above exactly.

Rollback

Ordered least disruptive first.

  1. Per call, no release needed. An SDK caller that wants the previous request passes mode=None. A CLI user who disagrees with a default passes --mode.
  2. Pin the previous release. Nothing here changes an install-time requirement, so pinning the prior version of this package is a clean revert for a consumer.
  3. Revert the commit. It is self-contained across limacharlie/sdk/search.py, limacharlie/commands/search.py and limacharlie/search_checkpoint.py, with no migration and no persisted state to unwind: the mode key left in existing checkpoint metadata is ignored by the reverted code.

What to watch after release

  • Reports that a paginated SDK script sees larger pages or longer waits between them than before. That is this change working as designed, and mode=None or mode="interactive" is the answer.
  • A searchMode in page stats that consistently disagrees with what was requested. That is the server declining the hint, which is legitimate, but a persistent mismatch is worth knowing about.
  • ValidationError on mode from callers, which would mean the accepted set has fallen behind the server's.

Links and references

  • doc/sdk/search-insight.md and doc/cli/data-query.md in this PR document the field for SDK and CLI users respectively.
  • search run --explain carries the full rule and the per-command reasoning for CLI users.

Related PRs

None. This change is contained to this repository.

🤖 Generated with Claude Code

@tomaz-lc
tomaz-lc marked this pull request as ready for review September 25, 2026 05:51
@limacharlie-refractionpoint

Copy link
Copy Markdown

LimaCharlie Cloud Security — code scan

No new code findings were introduced by this pull request.

This check reports and never fails: no gating.fail_on is set on the code_scanning policy.

Scanned refractionPOINT/python-limacharlie ef8e5bd…f5588e3 — only findings new in the head commit are listed; anything already on the base branch is the repository's own finding set, on the Cloud Security Code page.

This comment is updated in place on every push to this pull request.

@tomaz-lc

Copy link
Copy Markdown
Contributor Author

/lc-review

tomaz-lc and others added 3 commits September 25, 2026 18:58
…active in the CLI

POST /v1/search accepts an optional "mode" declaring how the caller intends
to consume the search rather than how much data it wants: "interactive"
favours time to first results, "batch" favours throughput over the whole
result set, which means fewer and larger pages. The rows and their ordering
are identical either way; only where the page boundaries fall changes.

Search.execute() defaults it to "batch". This is a behavior change, not a
backward-compatible addition: an existing SDK caller that does not pass mode
now sends a body with a key it did not have and gets different page shapes.
Nobody reads different data, but a caller that sizes buffers per page, meters
progress per page or has timeouts tuned to the old shape will notice. The
default is batch because the callers here are scripts and automation that
read every page and pay a fixed cost per round trip, and the shape tuned for
a human watching a screen is the wrong one for them. Passing mode=None
explicitly restores the previous request exactly and leaves the choice to the
server; None stays meaningful, it is just no longer the default.

The CLI resolves its own mode on every path rather than inheriting that, so
the SDK default can never reach a command line. It defaults to interactive,
and to batch only where the run is evidently a bulk retrieval: driven by
--checkpoint or --resume, stdout redirected to a file or a pipe, or --output
jsonl, csv or toon, the three formats that exist to be consumed by something
other than a reader. Everything the signals leave ambiguous, --output json at
a terminal included, stays interactive: an over-large page in front of
someone waiting is worse than a chattier fetch nobody is watching. --mode
overrides the default in every case.

An unrecognised spelling is refused client-side, with ValidationError in the
SDK and click.Choice in the CLI, matching how an unknown "state" is handled
on list_open_queries. The server ignores a value it does not recognise and
runs the search as interactive, so a typo would otherwise spend the query and
produce a mode the caller did not ask for with nothing to signal it. Matching
is exact, so "Batch" is a typo like any other. The isinstance guard in front
of the set lookup keeps an unhashable value from raising TypeError in place
of the refusal.

The mode is submitted once. Continuation pages are GETs carrying only the
pagination token, so they inherit it and never resend it. A checkpointed run
records the mode it resolved to, and a resume, which opens a fresh search,
repeats that record; --mode on the resume overrides it for that leg, which is
safe because the mode moves the page boundaries and nothing else. Metadata
predating the field carries no mode and falls back to the checkpoint-driven
default.

Each page reports what it actually ran as in its stats: searchMode, pageSize
and paginatedByteCap, all three absent for a search that ran without
pagination. Those reach --output json and jsonl untouched, and the stderr
stats line leads with the applied mode, since the requested one is a hint
that the server, which enables the mode per organization and may select one
itself, need not take.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… by default

Five tests that submit real searches and assert on what the server reports
back, rather than on what was requested. The mode is served by the search
backend, so until that support is deployed these fail for a server-side
reason with nothing wrong in this package.

They are gated twice over so that cannot turn a build red. tests/integration
is already outside testpaths, so the default pytest run that CI executes never
collects the file. On top of that every test skips unless
LC_TEST_SEARCH_MODE_E2E is set, which is the opt-in that says the backend
being pointed at is expected to serve the field. The skip reason names the
variable, the --oid and --key requirement, and the deployment dependency, so a
future reader hitting a skip does not have to guess. A searchmode_e2e marker,
registered in pyproject.toml, lets an otherwise live integration run drop them
with -m "not searchmode_e2e".

Coverage is deliberately small: a paginated search submitted with each mode
reports an applied searchMode; a paginated page carries all three of
searchMode, pageSize and paginatedByteCap; the applied mode is identical
across every page of one search, which is the part of the contract this
package leans on by never resending the mode; rows arrive whole, so a page
boundary never falls through a record; and the two modes return the same rows
in the same order.

None of them asserts that searchMode equals the requested mode. The mode is a
hint the server may decline, and a test demanding equality would fail for a
legitimate reason and teach whoever sees it to ignore the file. They assert
the field is present and is a mode this package knows. Conditions that are
about the environment rather than the code, an organization with too little
telemetry to paginate or a window that moved between two runs, skip with a
reason instead of failing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he CLI

The server now serves the search mode field, so the end-to-end tests no
longer wait on a deployment. The PR build's integration step sets
LC_TEST_SEARCH_MODE_E2E=1 next to its --oid and --key, so they run
there. They still skip by default elsewhere, because they start real,
billed searches and need an organization with telemetry in the last day.

Three more paths are now covered against a live server:

- mode=None sends no mode key and a page still reports what it ran as;
- the CLI's own resolution (JSONL output, stdout not a terminal) reaches
  a real submission and the page reports its applied mode;
- --mode interactive overrides that resolution on a real submission.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant