Skip to content

perf: streaming analyze — flat memory at any input size#10

Merged
gesh75 merged 1 commit into
mainfrom
perf/streaming-analyze
Jun 10, 2026
Merged

perf: streaming analyze — flat memory at any input size#10
gesh75 merged 1 commit into
mainfrom
perf/streaming-analyze

Conversation

@gesh75

@gesh75 gesh75 commented Jun 10, 2026

Copy link
Copy Markdown
Owner

Summary

Closes the last verified performance finding: '1GB syslog analyze dies: full in-memory materialization (~6.5GB RSS) + CPU vs 120s gunicorn timeout'.

Approach

One bounded pass (_aggregate_stream) replaces materialize-everything-then-sort:

  • per-severity top-300 heaps keyed (timestamp, -seq) — reproduces classify_events()'s (severity, ts desc, arrival) ordering exactly, asserted by a contract test
  • action-item groups (bounded by the KB rule table) keep the 5 newest sample messages via the same heap scheme — matching the old sorted-list sampling
  • per-device counters

classifier.iter_classify() is the new lazy building block; classify_events(), build_action_items(), _extract_top_devices() keep their public contracts. Web file/directory sources and the CLI pass generators end-to-end. The web route gains AI_LOG_ANALYZER_MAX_FILE_MB (default 200, HTTP 413 over) because streaming fixes memory, not the worker's 120s CPU budget — the CLI streams multi-GB files with no limit.

Measured (100MB synthetic syslog, 1.04M events, identical outputs)

peak RSS wall
before 651MB 41.1s
after 40MB 40.8s

Memory is now flat regardless of input size — 1GB stays ~40MB instead of ~6.5GB.

Test plan

  • ruff check clean
  • 278/278 tests pass (8 new: ordering-contract equivalence, generator==list, aggregator bounds, 413 guard, empty-file 404, CLI streaming happy-path + exit-2 — cli.py was 0% covered)
  • CI matrix green

🤖 Generated with Claude Code

Summary by Sourcery

Streamline log analysis to run in bounded memory for any input size while preserving existing behavior and interfaces.

New Features:

  • Introduce iter_classify() as a lazy classifier that yields ClassifiedEvent instances without materializing or sorting the full event stream.
  • Allow analyze() to accept any iterable of LogEvents, enabling fully streaming end-to-end pipelines from adapters, web routes, and the CLI.
  • Add a configurable web file size limit via AI_LOG_ANALYZER_MAX_FILE_MB with HTTP 413 responses for oversized files.

Enhancements:

  • Refactor analysis into a single-pass streaming aggregator that maintains per-severity top events, action-item groups, and per-device counters without unbounded growth.
  • Update action-item and top-device aggregation helpers to support streaming-safe, heap-based sampling while preserving existing output ordering and contracts.
  • Adjust CLI analyze command to chain lazy input streams and process very large files in constant memory.
  • Document streaming performance improvements and configuration in the changelog and README, including updated test counts.

Tests:

  • Add contract tests to ensure streamed analyze() output matches the materialized classify_events() pipeline, including ordering of top classified events.
  • Add tests for generator vs list inputs, action-item grouping behavior, aggregator bounds, and device counting under synthetic high-volume loads.
  • Add web API tests for file-size guard behavior and empty-file handling, plus new CLI tests covering file input, no-LLM runs, and empty-stdin exit codes.

…B on 100MB file)

The pipeline materialized every event twice (LogEvent list + classified
list) plus two full sorts, despite the result keeping only counters,
bounded action-item groups, per-device counts, and 300 events. 100MB
syslog measured 651MB peak RSS; 1GB extrapolated to ~6.5GB + worker
timeout death (verified review finding).

- classifier.iter_classify(): lazy per-event classification (new
  building block); classify_events() unchanged contract on top of it
- analyzer._aggregate_stream(): single bounded pass — per-severity
  top-K heaps keyed (timestamp, -seq) that reproduce classify_events'
  (severity, ts desc, arrival) ordering EXACTLY; action-item groups
  keep the 5 newest sample messages via the same bounded-heap scheme
- analyze() accepts any Iterable[LogEvent]; build_action_items /
  _extract_top_devices keep their signatures (now streaming inside)
- web /api/analyze file+directory sources pass generators end-to-end;
  peek-based empty check works for lists and generators alike; new
  size guard AI_LOG_ANALYZER_MAX_FILE_MB (default 200) returns 413
  with CLI guidance — streaming fixes memory, not the 120s worker CPU
  budget
- cli analyze chains lazy iterators — multi-GB files now CLI-safe

Measured (100MB synthetic syslog, 1.04M events, identical outputs):
  before: 651MB peak RSS, 41.1s
  after:   40MB peak RSS, 40.8s

Tests: 270 -> 278 (ordering-contract equivalence vs classify_events,
generator==list results, aggregator bound invariants, 413 guard,
empty-file 404, CLI happy path + exit-2 — cli.py was 0% covered)
@sourcery-ai

sourcery-ai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Reviewer's Guide

Refactors the analyze pipeline to be fully streaming and bounded-memory by introducing a single-pass aggregator over a lazy classifier, updating action-item and device aggregation helpers to be streaming-safe, wiring web and CLI entrypoints to pass generators instead of materialized lists with a size guard for the web route, and adding tests to pin behavioral equivalence and bounds plus documentation updates.

Sequence diagram for streaming analyze pipeline over lazy classifier

sequenceDiagram
    actor User
    participant WebApp as api_analyze
    participant CLI as cmd_analyze
    participant Analyzer as analyze
    participant Classifier as iter_classify
    participant Aggregator as _aggregate_stream
    participant ActionItems as _finalize_action_items
    participant TopDevices as _finalize_top_devices

    User->>WebApp: HTTP POST /api/analyze
    WebApp->>WebApp: parse_file / parse_directory
    WebApp->>Analyzer: analyze(events_generator, use_llm)

    User->>CLI: ai-log-analyzer analyze ...
    CLI->>CLI: parse_file / frr_docker_logs / parse_lines
    CLI->>Analyzer: analyze(events_generator, use_llm)

    Analyzer->>Classifier: iter_classify(events_generator)
    Classifier-->>Analyzer: ClassifiedEvent (streamed)

    Analyzer->>Aggregator: _aggregate_stream(iter_classify(events))
    Aggregator->>Aggregator: _group_actionable
    Aggregator->>Aggregator: _count_device
    Aggregator-->>Analyzer: top_events, sev_counts, cat_counts, groups, by_host

    Analyzer->>ActionItems: _finalize_action_items(groups, llm_top_n)
    ActionItems-->>Analyzer: action_items

    Analyzer->>TopDevices: _finalize_top_devices(by_host)
    TopDevices-->>Analyzer: top_devices

    Analyzer-->>WebApp: AnalysisResult
    Analyzer-->>CLI: AnalysisResult
    WebApp-->>User: JSON response
    CLI-->>User: Printed JSON
Loading

Flow diagram for web and CLI streaming inputs with size guard

flowchart TD
    U[User] -->|HTTP request| W[api_analyze]
    U -->|CLI command| C[cmd_analyze]

    subgraph WebPath
        W --> S[Resolve source]
        S -->|directory| D[parse_directory]
        S -->|file| F[Check size_mb vs MAX_FILE_MB]
        F -->|> MAX_FILE_MB| E413[Return 413 error]
        F -->|<= MAX_FILE_MB| PF[parse_file]
        D --> G1[events generator]
        PF --> G1
        G1 --> EC1[Empty check via next]
        EC1 -->|no events| E404[Return 404]
        EC1 -->|has events| A1[analyze]
    end

    subgraph CLIPath
        C --> B1[Build streams list]
        B1 --> CH[itertools.chain.from_iterable]
        CH --> EC2[Empty check via next]
        EC2 -->|no events| EXIT2[Exit code 2]
        EC2 -->|has events| A2[analyze]
    end

    A1 --> R1[AnalysisResult]
    A2 --> R2[AnalysisResult]
    R1 --> U
    R2 --> U
Loading

File-Level Changes

Change Details Files
Make the analysis pipeline streaming and bounded-memory via a single-pass aggregator over a lazy classifier while preserving external contracts.
  • Introduce iter_classify() to lazily yield ClassifiedEvent objects without materializing or sorting, and reimplement classify_events() on top of it to maintain the existing sorted/materialized contract for callers that need it.
  • Add _aggregate_stream() to perform a single bounded pass over a ClassifiedEvent stream, maintaining per-severity top-k heaps keyed by (timestamp, -seq), severity and category counts, action-item groups, and per-device counters, and returning the final top events and aggregates.
  • Update analyze() to accept any iterable of LogEvent, call iter_classify() and _aggregate_stream(), finalize action items and top devices from the aggregated state, and return only the top-300 classified events while keeping existing public behavior.
src/ai_log_analyzer/classifier.py
src/ai_log_analyzer/analyzer.py
Refactor action-item and per-device aggregation helpers to be streaming-safe and shared between the single-pass aggregator and legacy list-based callers.
  • Extract _group_actionable() to fold a single ClassifiedEvent into bounded action-item groups keyed by (severity, description), using a min-heap of size 5 per group keyed by (timestamp, -seq) to keep the newest sample messages.
  • Extract _finalize_action_items() to sort groups by severity and count, convert heaps to newest-first message lists, invoke deep_analyze(), and construct ActionItem objects while maintaining the existing external contract of build_action_items().
  • Extract _count_device() and _finalize_top_devices() so that per-device counting and ranking logic can be reused both from the streaming aggregator and from list-based callers via _extract_top_devices().
src/ai_log_analyzer/analyzer.py
Wire the web API and CLI to use streaming inputs, enforce a web-only file size cap, and keep behavior for empty inputs and exit codes.
  • In the web /api/analyze route, change file and directory sources to pass generators from parse_file/parse_directory into analyze(), add MAX_FILE_MB configurable via AI_LOG_ANALYZER_MAX_FILE_MB with a 413 response for oversized files, and implement an empty-input check that works for both lists and generators by peeking at the first element and chaining it back.
  • In the CLI cmd_analyze, build a list of lazy streams for FRR, files, and stdin; chain them via itertools.chain.from_iterable; peek one event to detect the empty-input case (still returning exit code 2 with an error message) and then pass a re-chained iterator into analyze() for streaming behavior.
  • Clarify comments around web vs CLI responsibilities, emphasizing that the web route is size-limited by worker timeout while the CLI is the supported path for multi-GB inputs.
src/ai_log_analyzer/web/app.py
src/ai_log_analyzer/cli.py
Add tests and documentation to pin the new streaming behavior, equivalence with the old pipeline, and updated limits and counts.
  • Add test_streaming_analyze.py to validate that streamed analyze() matches classify_events() output and counters, generator vs list inputs produce identical results, build_action_items() still honors its contract, _aggregate_stream() enforces bounds, the web file size guard returns 413 with proper messaging, empty web files still yield 404, and CLI streaming behavior including exit code 2 for empty stdin.
  • Update CHANGELOG.md with a Performance section documenting the streaming analyze change, memory improvements, iter_classify() introduction, generator usage in web/CLI, and the AI_LOG_ANALYZER_MAX_FILE_MB guard.
  • Update README.md badges to reflect the increased test count from 270 to 278.
tests/test_streaming_analyze.py
CHANGELOG.md
README.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@gesh75
gesh75 merged commit f0a5796 into main Jun 10, 2026
4 checks passed
@gesh75
gesh75 deleted the perf/streaming-analyze branch June 10, 2026 11:33

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 2 issues, and left some high level feedback:

  • The various aggregation helpers (_group_actionable, _aggregate_stream, _count_device) rely on loosely-typed dict buckets with string keys; consider introducing small dataclasses or TypedDicts to make the internal structure explicit and reduce the risk of key/shape mismatches over time.
  • Constants like the per-group message cap (5) and the global top-k cap (300) are now used in multiple places (heaps, finalization, tests); it may be worth centralizing them as module-level named constants to avoid divergence if these limits are tuned later.
  • In _aggregate_stream, the return tuple is fairly wide and unlabelled at the call site; wrapping these aggregates into a lightweight result object (e.g., dataclass) would improve readability and make future changes to the aggregation outputs less fragile.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The various aggregation helpers (`_group_actionable`, `_aggregate_stream`, `_count_device`) rely on loosely-typed `dict` buckets with string keys; consider introducing small dataclasses or TypedDicts to make the internal structure explicit and reduce the risk of key/shape mismatches over time.
- Constants like the per-group message cap (5) and the global top-k cap (300) are now used in multiple places (heaps, finalization, tests); it may be worth centralizing them as module-level named constants to avoid divergence if these limits are tuned later.
- In `_aggregate_stream`, the return tuple is fairly wide and unlabelled at the call site; wrapping these aggregates into a lightweight result object (e.g., dataclass) would improve readability and make future changes to the aggregation outputs less fragile.

## Individual Comments

### Comment 1
<location path="src/ai_log_analyzer/web/app.py" line_range="85-90" />
<code_context>
+# Web-route cap for single-file analysis. Streaming keeps memory bounded at
+# any size, but classification CPU (~2.7 MB/s) must finish inside the HTTP
+# worker timeout (120s) — bigger files belong on the CLI, which has no timeout.
+MAX_FILE_MB: float = float(os.environ.get("AI_LOG_ANALYZER_MAX_FILE_MB", "200"))
+
+
</code_context>
<issue_to_address>
**suggestion:** File-size check may confuse users around boundary conditions and actual size units

The check divides `st_size` by `1_048_576` but labels the result as `{size_mb:.0f}MB`, which both rounds to an integer and uses MB while actually computing MiB. This can be confusing at the limit (e.g., a ~200.4 MiB file shown as `200MB` but rejected). Consider either displaying one decimal place and/or explicitly labeling the unit as MiB so the message matches the actual threshold more clearly.

Suggested implementation:

```python
        size_mb = stat.st_size / 1_048_576
        if size_mb > MAX_FILE_MB:
            abort(
                400,
                f"File too large: {size_mb:.1f} MiB (max {MAX_FILE_MB:.1f} MiB). "
                "For larger logs, please use the CLI tool instead of the web UI.",
            )

```

I assumed:
- The size computation is `size_mb = stat.st_size / 1_048_576`.
- The rejection message is currently `File too large: {size_mb:.0f}MB (max {MAX_FILE_MB:.0f}MB)...`.

If the actual variable names or message text differ, apply the same adjustments wherever the size is:
1. Computed as `st_size / 1_048_576` and formatted with `:.0f`.
2. Labeled with `MB`.

In those places:
- Keep the same variable name (`size_mb` is fine) but change the format specifier from `:.0f` to `:.1f`.
- Change the unit text from `MB` to `MiB` so it matches the binary (MiB) calculation and is clearer at the limit.
</issue_to_address>

### Comment 2
<location path="src/ai_log_analyzer/cli.py" line_range="30-31" />
<code_context>
         for path in args.file:
-            events.extend(parse_file(path))
+            streams.append(parse_file(path))
     if args.stdin:
-        events.extend(parse_lines(sys.stdin.readlines()))
+        streams.append(parse_lines(sys.stdin.readlines()))

-    if not events:
</code_context>
<issue_to_address>
**issue (performance):** Reading all stdin lines eagerly undermines the streaming guarantees for large inputs

This call to `sys.stdin.readlines()` still buffers all input in memory, which regresses the new bounded-memory streaming behavior for large stdin. To keep stdin consistent with files/FRR logs, pass `sys.stdin` directly to `parse_lines` or iterate over `sys.stdin` instead of using `readlines()` so lines are consumed lazily.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +85 to 90
MAX_FILE_MB: float = float(os.environ.get("AI_LOG_ANALYZER_MAX_FILE_MB", "200"))


def _path_allowed(p: Path) -> bool:
"""True when the resolved path sits under one of FILE_ROOTS."""
try:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: File-size check may confuse users around boundary conditions and actual size units

The check divides st_size by 1_048_576 but labels the result as {size_mb:.0f}MB, which both rounds to an integer and uses MB while actually computing MiB. This can be confusing at the limit (e.g., a ~200.4 MiB file shown as 200MB but rejected). Consider either displaying one decimal place and/or explicitly labeling the unit as MiB so the message matches the actual threshold more clearly.

Suggested implementation:

        size_mb = stat.st_size / 1_048_576
        if size_mb > MAX_FILE_MB:
            abort(
                400,
                f"File too large: {size_mb:.1f} MiB (max {MAX_FILE_MB:.1f} MiB). "
                "For larger logs, please use the CLI tool instead of the web UI.",
            )

I assumed:

  • The size computation is size_mb = stat.st_size / 1_048_576.
  • The rejection message is currently File too large: {size_mb:.0f}MB (max {MAX_FILE_MB:.0f}MB)....

If the actual variable names or message text differ, apply the same adjustments wherever the size is:

  1. Computed as st_size / 1_048_576 and formatted with :.0f.
  2. Labeled with MB.

In those places:

  • Keep the same variable name (size_mb is fine) but change the format specifier from :.0f to :.1f.
  • Change the unit text from MB to MiB so it matches the binary (MiB) calculation and is clearer at the limit.

Comment on lines 30 to +31
if args.stdin:
events.extend(parse_lines(sys.stdin.readlines()))
streams.append(parse_lines(sys.stdin.readlines()))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (performance): Reading all stdin lines eagerly undermines the streaming guarantees for large inputs

This call to sys.stdin.readlines() still buffers all input in memory, which regresses the new bounded-memory streaming behavior for large stdin. To keep stdin consistent with files/FRR logs, pass sys.stdin directly to parse_lines or iterate over sys.stdin instead of using readlines() so lines are consumed lazily.

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