Skip to content

Commit cbd46f5

Browse files
authored
Merge pull request #31 from hallelx2/feat/parse-robustness
fix(ingest): total-parse timeout + working section cap + pdftable v0.3.1 (robust full-feature ingest)
2 parents dfc1c45 + 9d8c7b4 commit cbd46f5

12 files changed

Lines changed: 784 additions & 31 deletions

File tree

cmd/engine/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ func run() error {
172172
DB: pool,
173173
Storage: store,
174174
LLM: llmClient,
175-
Parsers: ingest.RegistryFromTableOpts(tableOptsFromConfig(cfg.Ingest.Tables)),
175+
Parsers: ingest.RegistryFromIngestParams(tableOptsFromConfig(cfg.Ingest.Tables), cfg.Ingest.MaxSections, time.Duration(cfg.Ingest.ParseTimeoutSeconds)*time.Second),
176176
Logger: logger,
177177
Mode: cfg.Ingest.Mode,
178178
HyDEEnabled: cfg.Ingest.HyDE.Enabled,

cmd/server/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ func run() error {
198198
DB: pool,
199199
Storage: store,
200200
LLM: llmClient,
201-
Parsers: ingest.RegistryFromTableOpts(tableOptsFromConfig(cfg.Engine.Ingest.Tables)),
201+
Parsers: ingest.RegistryFromIngestParams(tableOptsFromConfig(cfg.Engine.Ingest.Tables), cfg.Engine.Ingest.MaxSections, time.Duration(cfg.Engine.Ingest.ParseTimeoutSeconds)*time.Second),
202202
Logger: logger,
203203
Mode: cfg.Engine.Ingest.Mode,
204204
HyDEEnabled: cfg.Engine.Ingest.HyDE.Enabled,

config.example.yaml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,39 @@ ingest:
320320
# vectorless-server use VLS_INGEST_MODE=minimal (no secret edit needed).
321321
mode: "full"
322322

323+
# Total-parse timeout (seconds). Bounds the ENTIRE parse of one
324+
# document end to end — row extraction, table extraction, section
325+
# building, and the leaf-section cap. It is the outermost robustness
326+
# valve: a pathological/malformed PDF (observed: a 10-K stuck 600s+ in
327+
# `parsing`, even in minimal mode, inside pure-Go row extraction) is
328+
# abandoned at the deadline and the document fails fast instead of
329+
# wedging the pipeline forever. NOTHING is disabled by this bound — the
330+
# full feature set (LLM TOC, tables, summarize, HyDE, multi-axis) still
331+
# runs; parse is merely time-boxed. Applies in BOTH full and minimal
332+
# mode (parse runs in both).
333+
#
334+
# 120 is comfortably longer than a healthy 300-page filing's parse
335+
# (seconds to low tens of seconds) yet short enough to reap a hang
336+
# quickly. 0 uses the engine default (120). Override per-process with
337+
# VLE_INGEST_PARSE_TIMEOUT_SECONDS; the deployed server also honours
338+
# VLS_INGEST_PARSE_TIMEOUT_SECONDS.
339+
parse_timeout_seconds: 120
340+
341+
# Cap on the number of leaf sections one document may produce. A
342+
# pathological PDF (e.g. a 90-page 10-K whose every bold statement
343+
# title trips the heading detector, or a heading→one-body-leaf chain
344+
# repeated hundreds of times) can shatter into far more leaves than the
345+
# document has real sections — each leaf then costs a summarize + HyDE
346+
# + multi-axis LLM call, which is what throttles/stalls full ingest.
347+
# When the parsed leaf count exceeds this cap the parser merges
348+
# sections (smallest first; single-leaf parents collapse into their
349+
# parent) until the document is back under the cap, preserving content
350+
# and never merging table sections. 400 sits comfortably above a real
351+
# filing's section count while still catching the runaway case. 0 uses
352+
# the engine default (400); a negative value disables the cap. Override
353+
# with VLE_INGEST_MAX_SECTIONS.
354+
max_sections: 400
355+
323356
# The summarize and HyDE stages run concurrently. This caps the total
324357
# number of LLM calls in flight across both stages combined, so the
325358
# provider's per-tenant concurrency limit isn't exceeded. 0 disables

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ require (
1313
github.com/go-chi/chi/v5 v5.2.5
1414
github.com/google/uuid v1.6.0
1515
github.com/hallelx2/llmgate v0.2.0
16-
github.com/hallelx2/pdftable v0.3.0
16+
github.com/hallelx2/pdftable v0.3.1
1717
github.com/hibiken/asynq v0.26.0
1818
github.com/jackc/pgx/v5 v5.9.2
1919
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+u
134134
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90=
135135
github.com/hallelx2/llmgate v0.2.0 h1:x/LNCeHUPZpafn2IXi+LqpnZa7TtEQdLVlpkkJTlzBI=
136136
github.com/hallelx2/llmgate v0.2.0/go.mod h1:MK2Ol/5CIweTQ2/9eSiTJ5g/KSSuobNZL9TD3s57JxY=
137-
github.com/hallelx2/pdftable v0.3.0 h1:SwZPu2z4cIR4R30gP+7bpunGh931StjO1vrsxoldiDw=
138-
github.com/hallelx2/pdftable v0.3.0/go.mod h1:pxNlc4D43wjzis7M6EfgQZvHOsQ4okggm+xqUu+OokI=
137+
github.com/hallelx2/pdftable v0.3.1 h1:Uqe+9G8s9jrGYwxk8dEMXBCB+SlzvWPmW0Ze5863W1I=
138+
github.com/hallelx2/pdftable v0.3.1/go.mod h1:pxNlc4D43wjzis7M6EfgQZvHOsQ4okggm+xqUu+OokI=
139139
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
140140
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
141141
github.com/hhrutter/pkcs7 v0.2.2 h1:xMoifoVWah1LNym3C0pomEiLmyJyVIBXt/8oTPyPz+8=

internal/config/config.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ func Default() Config {
205205
MaxAge: 86400,
206206
},
207207
Governance: GovernanceConfig{
208-
MaxBodySizeBytes: 33554432, // 32 MiB
208+
MaxBodySizeBytes: 33554432, // 32 MiB
209209
DefaultTimeout: 30 * time.Second,
210210
QueryTimeout: 120 * time.Second,
211211
},
@@ -326,6 +326,15 @@ func applyEnvOverrides(c *Config) {
326326
if v := firstEnv("VLS_INGEST_MODE", "VLE_INGEST_MODE"); v != "" {
327327
c.Engine.Ingest.Mode = v
328328
}
329+
// Total-parse timeout (seconds). Forwarded so the deployed server
330+
// honours a tuned parse deadline without a secret/config edit — the
331+
// outermost robustness valve against a parse that hangs (pre-LLM,
332+
// pure-Go row extraction). VLS_-prefixed wins over VLE_.
333+
if v := firstEnv("VLS_INGEST_PARSE_TIMEOUT_SECONDS", "VLE_INGEST_PARSE_TIMEOUT_SECONDS"); v != "" {
334+
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
335+
c.Engine.Ingest.ParseTimeoutSeconds = n
336+
}
337+
}
329338
// Anthropic-compatible gateway overrides (e.g. GLM/Zhipu via
330339
// https://api.z.ai/api/anthropic): base URL + model, so the
331340
// anthropic driver can run a non-Anthropic model without a secret

pkg/config/config.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,28 @@ type IngestConfig struct {
125125
// section count (~170-510 with tables) while still catching the
126126
// runaway case. A negative value is rejected by Validate.
127127
MaxSections int `yaml:"max_sections"`
128+
129+
// ParseTimeoutSeconds bounds the ENTIRE parse of a single document —
130+
// row extraction, table extraction, section building, and the leaf
131+
// cap, end to end. It is the outermost robustness valve: every
132+
// per-stage timeout inside the parser (per-page / doc-wide table
133+
// budgets) is bounded by something pre-LLM, but pure-Go row extraction
134+
// (ledongthuc's reader.Page(n).Content()) had no bound, so a
135+
// pathological PDF (observed: a 10-K stuck 600s+ in `parsing` even in
136+
// minimal mode) could hang the parse forever.
137+
//
138+
// When the whole parse exceeds this deadline the parser abandons the
139+
// work and returns a clear error; the ingest pipeline treats it like
140+
// any other parse failure (the document goes to `failed`), so a doc
141+
// that can't parse in time fails fast and is visible to ops/bench
142+
// rather than wedging the pipeline. NOTHING is disabled — the full
143+
// feature set (LLM TOC, tables, summarize, HyDE, multi-axis) still
144+
// runs; parse is merely bounded.
145+
//
146+
// 0 (or omitted) defaults to 120. A negative value is rejected by
147+
// Validate. Engine env override: VLE_INGEST_PARSE_TIMEOUT_SECONDS;
148+
// the server binary also forwards VLS_/VLE_INGEST_PARSE_TIMEOUT_SECONDS.
149+
ParseTimeoutSeconds int `yaml:"parse_timeout_seconds"`
128150
}
129151

130152
// TOCBlock configures the LLM-driven table-of-contents tree
@@ -728,6 +750,7 @@ func Default() Config {
728750
GlobalLLMConcurrency: 12,
729751
LLMCallTimeoutSeconds: 90,
730752
MaxSections: 400,
753+
ParseTimeoutSeconds: 120,
731754
HyDE: HyDEConfig{
732755
Enabled: true,
733756
NumQuestions: 5,
@@ -911,6 +934,11 @@ func applyEnvOverrides(c *Config) {
911934
c.Ingest.MaxSections = n
912935
}
913936
}
937+
if v := os.Getenv("VLE_INGEST_PARSE_TIMEOUT_SECONDS"); v != "" {
938+
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
939+
c.Ingest.ParseTimeoutSeconds = n
940+
}
941+
}
914942
// pdftable-driven table extraction.
915943
if v := os.Getenv("VLE_INGEST_TABLES_ENABLED"); v != "" {
916944
switch strings.ToLower(strings.TrimSpace(v)) {
@@ -1200,6 +1228,9 @@ func (c Config) Validate() error {
12001228
if c.Ingest.MaxSections < 0 {
12011229
return fmt.Errorf("ingest.max_sections must be >= 0, got %d", c.Ingest.MaxSections)
12021230
}
1231+
if c.Ingest.ParseTimeoutSeconds < 0 {
1232+
return fmt.Errorf("ingest.parse_timeout_seconds must be >= 0, got %d", c.Ingest.ParseTimeoutSeconds)
1233+
}
12031234

12041235
switch c.Ingest.Tables.VerticalStrategy {
12051236
case "", "lines", "lines_strict", "text", "explicit":

pkg/config/config_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,38 @@ func TestDefaultValues(t *testing.T) {
8282
if cfg.Ingest.TOC.TOCCheckPages != 20 {
8383
t.Errorf("ingest.toc.toc_check_pages = %d, want 20", cfg.Ingest.TOC.TOCCheckPages)
8484
}
85+
if cfg.Ingest.ParseTimeoutSeconds != 120 {
86+
t.Errorf("ingest.parse_timeout_seconds = %d, want 120", cfg.Ingest.ParseTimeoutSeconds)
87+
}
88+
if cfg.Ingest.MaxSections != 400 {
89+
t.Errorf("ingest.max_sections = %d, want 400", cfg.Ingest.MaxSections)
90+
}
91+
}
92+
93+
// TestIngestParseTimeoutEnvOverride covers VLE_INGEST_PARSE_TIMEOUT_SECONDS
94+
// — the operator knob that lets a tuned whole-parse deadline reach the
95+
// parser without a config-file edit.
96+
func TestIngestParseTimeoutEnvOverride(t *testing.T) {
97+
prev := os.Getenv("VLE_INGEST_PARSE_TIMEOUT_SECONDS")
98+
defer os.Setenv("VLE_INGEST_PARSE_TIMEOUT_SECONDS", prev)
99+
100+
os.Setenv("VLE_INGEST_PARSE_TIMEOUT_SECONDS", "300")
101+
cfg := Default()
102+
applyEnvOverrides(&cfg)
103+
if cfg.Ingest.ParseTimeoutSeconds != 300 {
104+
t.Errorf("ingest.parse_timeout_seconds = %d, want 300", cfg.Ingest.ParseTimeoutSeconds)
105+
}
106+
}
107+
108+
// TestIngestParseTimeoutValidate rejects a negative parse timeout — a
109+
// non-positive deadline would silently disable the bound, which must be
110+
// an explicit choice, not a typo that slips through Load.
111+
func TestIngestParseTimeoutValidate(t *testing.T) {
112+
cfg := Default()
113+
cfg.Ingest.ParseTimeoutSeconds = -1
114+
if err := cfg.Validate(); err == nil {
115+
t.Error("Validate should reject a negative ingest.parse_timeout_seconds")
116+
}
85117
}
86118

87119
// TestIngestModeDefault locks the default ingest mode to "full" so the

pkg/ingest/ingest.go

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1122,15 +1122,35 @@ func DefaultRegistry() *parser.Registry {
11221122
}
11231123

11241124
// RegistryFromTableOpts returns a parser.Registry where the PDF parser
1125-
// is configured from the supplied TableOpts. Pass nil to disable table
1126-
// extraction entirely; pass parser.DefaultTableOpts() (or a custom set)
1127-
// to enable. All non-PDF parsers are constructed at their defaults.
1125+
// is configured from the supplied TableOpts, with the leaf-section cap
1126+
// and total-parse timeout left at their parser defaults (400 sections,
1127+
// 120s). Pass nil to disable table extraction entirely; pass
1128+
// parser.DefaultTableOpts() (or a custom set) to enable. All non-PDF
1129+
// parsers are constructed at their defaults.
1130+
//
1131+
// Use RegistryFromIngestParams to thread an operator-tuned cap / parse
1132+
// timeout from config.
11281133
func RegistryFromTableOpts(opts *parser.TableOpts) *parser.Registry {
1134+
return RegistryFromIngestParams(opts, 0, 0)
1135+
}
1136+
1137+
// RegistryFromIngestParams returns a parser.Registry where the PDF parser
1138+
// is configured from the supplied TableOpts AND the operator-tuned
1139+
// leaf-section cap and total-parse timeout (ingest.max_sections,
1140+
// ingest.parse_timeout_seconds). maxSections == 0 / parseTimeout == 0
1141+
// each select the parser's built-in default; a negative value disables
1142+
// that bound. All non-PDF parsers are constructed at their defaults.
1143+
//
1144+
// This is the constructor the engine/server wiring uses so the parse
1145+
// deadline and section cap from config actually reach the parser — the
1146+
// outermost robustness valves for full-feature ingest. Table extraction,
1147+
// the section tree, and the cap all still run; they are merely bounded.
1148+
func RegistryFromIngestParams(opts *parser.TableOpts, maxSections int, parseTimeout time.Duration) *parser.Registry {
11291149
return parser.NewRegistry(
11301150
parser.NewMarkdown(),
11311151
parser.NewHTML(),
11321152
parser.NewDOCX(),
1133-
parser.NewPDFWithTables(opts),
1153+
parser.NewPDFWithConfig(opts, maxSections, parseTimeout),
11341154
parser.NewText(),
11351155
)
11361156
}

0 commit comments

Comments
 (0)