Skip to content

Commit a8f2dc4

Browse files
authored
Merge pull request #1 from choiyounggi/knowledge/streaming-large-result-sets-20260713-120141
knowledge: streaming large result sets (server-side cursor blocked → fetchmany + write-only)
2 parents 8b53c6c + 495ce81 commit a8f2dc4

5 files changed

Lines changed: 96 additions & 1 deletion

File tree

.dev-loop/INGEST_REPORT.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Knowledge flush — 1 insight
2+
3+
Source: RNR-3440 (사내 잠재매물 주간 추출 스크립트 메모리 피크 저감). Candidate:
4+
"QueryPie 프록시 경유로 대용량 결과를 스트리밍할 때 server-side named cursor 대신
5+
일반 커서 + `fetchmany` + openpyxl `write_only`."
6+
7+
## Verified best-practice
8+
9+
**Claim 1 — psycopg2 server-side (named) cursor requires a transaction; fails under autocommit.**
10+
- Source: psycopg2 usage docs — `https://github.com/psycopg/psycopg2/blob/master/doc/src/usage.rst` (via Context7). Quote: "Named cursors are typically created 'WITHOUT HOLD', meaning they exist only within the current transaction. Attempting to fetch from them after a commit or in autocommit mode raises an exception."
11+
- Matches my live repro (`can't use a named cursor outside of transactions`). → **verified**
12+
13+
**Claim 2 — a client-side (default) cursor pulls the whole result set to the client on execute; `fetchmany` only caps the Python-list explosion.**
14+
- Source: psycopg2 cursor/usage docs + FAQ (named-cursor advantage = "data is fetched in chunks … minimal client memory"). By contrast the default cursor buffers the full result in libpq. → **verified**
15+
16+
**Claim 3 — openpyxl `write_only` gives near-constant memory (<10 MB); one save only; lxml is for serialization speed, not the memory saving.**
17+
- Source: openpyxl Optimised Modes — `https://openpyxl.readthedocs.io/en/stable/optimized.html` (via WebSearch). "keeping memory usage under 10Mb"; "A write-only workbook can only be saved once"; "make sure you have lxml installed" for large dumps (speed).
18+
- This **corrects** the raw candidate's "lxml unnecessary" → precise form: unnecessary *for the memory win*, recommended *for large-dump speed*. Confirmed by my server test (write-only worked with lxml absent). → **verified**
19+
20+
**Claim 4 — QueryPie blocks `BEGIN`, so server-side cursor is impossible there.**
21+
- Environment-specific, no external source. Live repro in gui context: `autocommit=False` + named cursor → `[ENGINE] No permission to execute BEGIN statement`. → **field-tested**. Generalized in the page to "a read-only access-control proxy that blocks transaction control", with QueryPie as the concrete example (not a product-specific page).
22+
- Memory figure 838 MB → 38 MB (300k synthetic rows) is my RNR-3440 measurement (`ru_maxrss`, separate processes).
23+
24+
## Existing-layer check
25+
26+
- Pages read: `databases/index.md`, `databases/query-optimization/keyset-pagination.md`, `backend/python/index.md`.
27+
- Overlap: keyset-pagination is the nearest neighbor (both handle large result sets) but a **distinct** topic — pagination splits the read into many bounded queries; this page streams a *single* query's result in chunks. Not a duplicate → new page + **bidirectional `related` link** added to both.
28+
- backend/python has no DB-cursor page; the psycopg2/openpyxl specifics live as concrete examples inside the databases page rather than a separate python page (no duplication).
29+
- Conflicts: none found.
30+
31+
## Routing decision
32+
33+
- Target: **`databases/query-optimization/streaming-large-result-sets.md`** (new page).
34+
- Category `query-optimization` fits (memory-bounding how a query's result is pulled into the app is query-execution optimization); no new category needed.
35+
- Registered in `databases/index.md` (query-optimization section) and appended to `log.md`.

log.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,4 @@ Append-only. Format: `## [YYYY-MM-DD] <ingest|revise|lint|gap|contradiction|drif
3232
## [2026-07-11] ingest | security +1 (new category api-exposure): exposing-an-origin-http-api — code-vs-edge control split for putting an origin HTTP API behind a public tunnel/proxy. Captures the non-obvious 5xx-bypasses-request-middleware gotcha (set security headers in the exception handler too — verify with a forced 500), header-only fail-closed auth, docs/OpenAPI off at the origin by default, sanitized 5xx bodies, and the code(validate/headers/errors)-vs-edge(TLS/HSTS/rate-limit/WAF) boundary. Derived from hardening korea-data-suite for external exposure + independent pentest; sources OWASP Secure Headers, MDN, Starlette middleware, FastAPI docs.
3333
## [2026-07-12] ingest | databases +1 (new category sqlite): concurrent-access-for-a-read-api — WAL + busy_timeout for a read-heavy API with a background writer; set journal_mode once (per-connection pragma cost ~0.4ms measured vs 0.04ms busy_timeout), single writer, run the batch writer as a separate process (interpreter-lock isolation), scale reads with worker processes not threads. Derived from load-testing korea-data-suite (measured connect/query/pragma costs, GIL-bound concurrency, separate-sync-process deployment). Sources: SQLite WAL/pragma/FAQ docs.
3434
## [2026-07-12] revise | security/secrets-in-code +1 edge case: third-party HTTP client (httpx/requests) logs the full request URL — including a query-param API key — at INFO, so root/DEBUG logging leaks it; keep the client logger above INFO. Found when a standalone sync process set logging.basicConfig(INFO) and httpx wrote the data.go.kr serviceKey to the log file. last_verified bumped to 2026-07-12.
35+
## [2026-07-13] ingest | databases +1 (query-optimization): streaming-large-result-sets — memory-bounded export of a huge single-query result. Client-side cursor pulls the whole set to libpq on execute (fetchmany caps only the Python-list explosion); only a server-side/named cursor truly streams but needs a transaction, so it fails under autocommit or a proxy that blocks BEGIN → fall back to client-side fetchmany + disk spool + openpyxl write_only (measured 300k rows 838MB→38MB). Derived from RNR-3440 (potential-listing weekly extract memory peak); QueryPie BEGIN-block generalized to "read-only access proxy", field-tested. Sources: psycopg2 usage/cursor docs (named-cursor WITHOUT HOLD + autocommit exception), openpyxl optimized-modes (write-only near-constant memory, lxml=speed-not-memory).

wiki/databases/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Match your situation to a "load when" line; load only matching pages.
2121
|------|-----------|
2222
| [reading-execution-plans](query-optimization/reading-execution-plans.md) | A single query/statement is slow; verifying an index/query change with EXPLAIN before shipping (endpoint slow because it runs *many* fast queries → n-plus-one-queries) |
2323
| [keyset-pagination](query-optimization/keyset-pagination.md) | Implementing pagination, infinite scroll, or batch table walks |
24+
| [streaming-large-result-sets](query-optimization/streaming-large-result-sets.md) | Exporting/reading a very large single-query result into the app; process memory peaks on `fetchall` or building a big file; server-side cursor blocked by autocommit or a read-only proxy |
2425
| [large-in-lists](query-optimization/large-in-lists.md) | Building `IN (...)` queries whose list size can grow (batch lookups, fetch-by-ids) |
2526
| [n-plus-one-queries](query-optimization/n-plus-one-queries.md) | Loading a list plus per-row associations via an ORM; query count scales with result size |
2627
| [existence-and-count-checks](query-optimization/existence-and-count-checks.md) | Writing "is there any…", counts, badges, or gating logic on row presence |

wiki/databases/query-optimization/keyset-pagination.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ sources:
88
- https://use-the-index-luke.com/no-offset
99
- https://www.postgresql.org/docs/current/queries-limit.html
1010
last_verified: 2026-07-10
11-
related: [databases-indexing-composite-index-column-order]
11+
related: [databases-indexing-composite-index-column-order, databases-query-optimization-streaming-large-result-sets]
1212
---
1313

1414
# Paginating Large Result Sets
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
id: databases-query-optimization-streaming-large-result-sets
3+
domain: databases
4+
category: query-optimization
5+
applies_to: [postgresql, python, general]
6+
confidence: verified
7+
sources:
8+
- https://github.com/psycopg/psycopg2/blob/master/doc/src/usage.rst
9+
- https://github.com/psycopg/psycopg2/blob/master/doc/src/cursor.rst
10+
- https://openpyxl.readthedocs.io/en/stable/optimized.html
11+
last_verified: 2026-07-13
12+
related: [databases-query-optimization-keyset-pagination]
13+
---
14+
15+
# Streaming Large Query Result Sets into the App
16+
17+
## When this applies
18+
19+
A single query returns a very large result (hundreds of thousands of rows or
20+
more) and your process reads it all into the app — typically to export to a file
21+
(Excel/CSV) or feed another sink. The pain is process **memory peak**, not query
22+
speed. (Splitting the read into many bounded queries → keyset-pagination.)
23+
24+
## Do this
25+
26+
| Situation | Do |
27+
|-----------|----|
28+
| Stream a large single-query result to the app | Server-side cursor (`cursor(name=...)` in psycopg2 → PostgreSQL `DECLARE`) fetching in chunks via `itersize`/`fetchmany` — the DB sends batches, client memory stays minimal |
29+
| Server-side cursor is unavailable (env blocks transactions/`BEGIN`) | Client-side cursor + `fetchmany(N)` batches so you never build the full Python list (`fetchall`), and write output through a **streaming writer** instead of buffering it |
30+
| Output is Excel | openpyxl `Workbook(write_only=True)` + `ws.append(row)` — never holds the whole workbook; near-constant memory (<10 MB). Install `lxml` for large dumps (serialization speed, **not** the memory saving) |
31+
| The result must be traversed twice (e.g. a post-pass needs the whole set first) | Don't hold it all in Python — spool each row to a local file (pickle/CSV) once, then stream-read the spool for the second pass |
32+
33+
Why, in order of memory impact:
34+
35+
1. **A client-side (default) cursor pulls the entire result set to the client on
36+
`execute`.** `fetchmany` only hands you slices of what libpq already buffered,
37+
so it caps the *Python object* explosion but not the driver buffer. Avoiding
38+
`fetchall` (the full Python list) is necessary but not sufficient.
39+
2. **Only a server-side (named) cursor truly streams** — the DB ships chunks. But
40+
it lives *inside a transaction* (created `WITHOUT HOLD`), so it fails under
41+
autocommit or wherever transaction control is blocked.
42+
3. **The output writer is often the bigger share.** A normal openpyxl workbook
43+
holds every cell object until `save()`; write-only mode removes that.
44+
45+
## Edge cases
46+
47+
| Case | Then |
48+
|------|------|
49+
| Named cursor under `autocommit=True` | Fetching raises "named cursor isn't valid anymore" / can't use outside a transaction — set `autocommit=False` |
50+
| A read-only access proxy blocks `BEGIN` (e.g. an access-control proxy like QueryPie) | Server-side cursor is impossible (`No permission to execute BEGIN statement`) → fall back to client-side `fetchmany` + spool + streaming writer. Measured: 300k rows `fetchall`+normal workbook 838 MB → `fetchmany`+write-only **38 MB** |
51+
| write-only workbook re-save/append | Only one `save()` is allowed (`WorkbookAlreadySaved`). Set column widths / `freeze_panes` **before** the first `append`; compute `auto_filter` from a row count, not `ws.max_row` (unreliable in write-only) |
52+
| Two-pass over the result via re-running the query | Re-executing a heavy query doubles DB load — spool the single fetch to disk and re-read it instead |
53+
54+
## Sources
55+
56+
- https://github.com/psycopg/psycopg2/blob/master/doc/src/usage.rst — server-side (named) cursors are `WITHOUT HOLD`, invalid after commit / under autocommit; stream large datasets in chunks
57+
- https://github.com/psycopg/psycopg2/blob/master/doc/src/cursor.rst`fetchmany`/`itersize` semantics
58+
- https://openpyxl.readthedocs.io/en/stable/optimized.html — write-only mode, near-constant memory, one save only, lxml recommended for large dumps

0 commit comments

Comments
 (0)