@@ -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" :
0 commit comments