Module: Wp2txt::IndexCommands
- Defined in:
- lib/wp2txt/index_commands.rb
Overview
CLI command handlers for --build-index and --find-articles. Mixed into WpApp (bin/wp2txt); relies on CliUI helpers for output.
Instance Method Summary collapse
-
#build_fulltext_index(opts, multistream_path, stream_offsets, meta_db_path, num_processes) ⇒ Object
Build the FTS5 full-text index (Tier 2) after the metadata index.
-
#load_stream_offsets(index_path, opts) ⇒ Array(Array<Integer>, Integer)
Load stream offsets without holding the full title index in memory.
-
#run_build_index(opts) ⇒ Object
Build (or refresh) the local metadata index for a dump.
-
#run_find_articles(opts) ⇒ Object
Query the metadata index and print matching article titles.
-
#run_fts_optimize(opts) ⇒ Object
Standalone optimize of an existing full-text index (--fts-optimize).
-
#run_phase_isolated(&block) ⇒ Boolean
Run a build phase in a forked child process.
-
#run_search(opts) ⇒ Object
Full-text search from the CLI (--search).
Instance Method Details
#build_fulltext_index(opts, multistream_path, stream_offsets, meta_db_path, num_processes) ⇒ Object
Build the FTS5 full-text index (Tier 2) after the metadata index
130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 |
# File 'lib/wp2txt/index_commands.rb', line 130 def build_fulltext_index(opts, multistream_path, stream_offsets, , num_processes) fts_db_path = FtsIndex.path_for(multistream_path, cache_dir: opts[:cache_dir]) tokenizer = opts[:fts_tokenizer] || FtsIndex.default_tokenizer(multistream_path) fts = FtsIndex.new(fts_db_path, ) if fts.valid_for?(multistream_path) && !opts[:update_cache] print_success("Full-text index is up to date: #{fts_db_path}") fts.close return CliUI::EXIT_SUCCESS end fts.close puts pastel.cyan("Building full-text index (tokenizer: #{tokenizer})...") unless quiet? time_start = Time.now ok = run_phase_isolated do builder = FtsIndexBuilder.new( multistream_path, stream_offsets, db_path: fts_db_path, meta_db_path: , tokenizer: tokenizer, num_processes: num_processes, optimize: !opts[:skip_fts_optimize] ) last_report = Time.now built = builder.build do |done, total| now = Time.now if !quiet? && (now - last_report >= DEFAULT_PROGRESS_INTERVAL || done == total) last_report = now percent = (done.to_f / total * 100).round(1) elapsed = now - time_start eta = done.positive? ? (total - done) * (elapsed / done) : 0 puts pastel.dim(format(" [%d/%d] %.1f%% | ETA: %s", done, total, percent, format_duration(eta))) end end puts unless quiet? print_success("Full-text index built in #{format_duration(Time.now - time_start)}") stats = built.stats print_info("Tokenizer", stats[:tokenizer].to_s) print_info("Sections", stats[:section_count].to_s) print_info("Size", format_size(stats[:db_size])) unless stats[:optimized] ("Index is unoptimized (built with --skip-fts-optimize). Run 'wp2txt --fts-optimize' later for best query speed.") end built.close true end ok ? CliUI::EXIT_SUCCESS : CliUI::EXIT_ERROR end |
#load_stream_offsets(index_path, opts) ⇒ Array(Array<Integer>, Integer)
Load stream offsets without holding the full title index in memory. The builders fork worker processes; a parent heap holding millions of index entries (~14GB for enwiki) gets duplicated through copy-on-write breakage in every child and OOMs the machine. Offsets are a small integer array, so prefer reading just them from the SQLite cache.
112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 |
# File 'lib/wp2txt/index_commands.rb', line 112 def load_stream_offsets(index_path, opts) cache = IndexCache.new(index_path, cache_dir: opts[:cache_dir]) if cache.valid? [cache.stream_offsets, cache.stats[:entry_count]] else # First run: parse the index (this also writes the SQLite cache), # then discard the entry hashes before any fork ms_index = MultistreamIndex.new(index_path, cache_dir: opts[:cache_dir], show_progress: !quiet?) offsets = ms_index.stream_offsets count = ms_index.size ms_index = nil GC.start GC.compact if GC.respond_to?(:compact) [offsets, count] end end |
#run_build_index(opts) ⇒ Object
Build (or refresh) the local metadata index for a dump
15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
# File 'lib/wp2txt/index_commands.rb', line 15 def run_build_index(opts) multistream_path, index_path = resolve_dump_paths(opts, download: true) return CliUI::EXIT_ERROR unless multistream_path db_path = MetadataIndex.path_for(multistream_path, cache_dir: opts[:cache_dir]) = MetadataIndex.new(db_path) if .valid_for?(multistream_path) && !opts[:update_cache] print_success("Metadata index is up to date: #{db_path}") print_index_stats(.stats) .close return CliUI::EXIT_SUCCESS unless opts[:fulltext] stream_offsets, = load_stream_offsets(index_path, opts) num_processes = opts[:num_procs] || MemoryMonitor.optimal_processes return build_fulltext_index(opts, multistream_path, stream_offsets, db_path, num_processes) end .close ("Build Metadata Index", { "Dump" => File.basename(multistream_path), "Output" => db_path }) time_start = Time.now puts pastel.cyan("Loading multistream index...") unless quiet? stream_offsets, entry_count = load_stream_offsets(index_path, opts) if stream_offsets.empty? print_error("Multistream index is empty or unreadable: #{index_path}") return CliUI::EXIT_ERROR end num_processes = opts[:num_procs] || MemoryMonitor.optimal_processes puts pastel.cyan("Scanning #{entry_count} pages in #{stream_offsets.size} streams (#{num_processes} processes)...") unless quiet? ok = run_phase_isolated do builder = MetadataIndexBuilder.new( multistream_path, stream_offsets, db_path: db_path, num_processes: num_processes ) last_report = Time.now built = builder.build do |done, total| now = Time.now if !quiet? && (now - last_report >= DEFAULT_PROGRESS_INTERVAL || done == total) last_report = now percent = (done.to_f / total * 100).round(1) elapsed = now - time_start eta = done.positive? ? (total - done) * (elapsed / done) : 0 puts pastel.dim(format(" [%d/%d] %.1f%% | ETA: %s", done, total, percent, format_duration(eta))) end end puts unless quiet? print_success("Metadata index built in #{format_duration(Time.now - time_start)}") print_index_stats(built.stats) built.close true end return CliUI::EXIT_ERROR unless ok return build_fulltext_index(opts, multistream_path, stream_offsets, db_path, num_processes) if opts[:fulltext] CliUI::EXIT_SUCCESS end |
#run_find_articles(opts) ⇒ Object
Query the metadata index and print matching article titles
248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 |
# File 'lib/wp2txt/index_commands.rb', line 248 def run_find_articles(opts) multistream_path, = resolve_dump_paths(opts, download: false) return CliUI::EXIT_ERROR unless multistream_path db_path = MetadataIndex.path_for(multistream_path, cache_dir: opts[:cache_dir]) = MetadataIndex.new(db_path) unless .built? print_error("Metadata index not found for this dump.") ("Build it first with: wp2txt --build-index #{opts[:lang] ? "-L #{opts[:lang]}" : "-i #{opts[:input]}"}") return CliUI::EXIT_ERROR end unless .valid_for?(multistream_path) print_warning("Metadata index was built from a different version of this dump. Consider re-running --build-index.") end filters = { category: opts[:in_category], depth: opts[:in_category] ? opts[:depth] : 0, has_section: opts[:has_section], use_aliases: !opts[:no_section_aliases], alias_file: opts[:alias_file], title_match: opts[:title_match] } total = .count_articles(**filters) titles = .find_articles(**filters, limit: opts[:limit]) dump_name = .stats[:dump_name] .close if opts[:format].to_s.downcase == "json" puts JSON.generate({ dump: dump_name, total: total, returned: titles.size, titles: titles }) else titles.each { |t| puts t } $stderr.puts pastel.dim("# #{titles.size} of #{total} matching articles (dump: #{dump_name})") end CliUI::EXIT_SUCCESS end |
#run_fts_optimize(opts) ⇒ Object
Standalone optimize of an existing full-text index (--fts-optimize)
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 |
# File 'lib/wp2txt/index_commands.rb', line 180 def run_fts_optimize(opts) multistream_path, = resolve_dump_paths(opts, download: false) return CliUI::EXIT_ERROR unless multistream_path fts = FtsIndex.new( FtsIndex.path_for(multistream_path, cache_dir: opts[:cache_dir]), MetadataIndex.path_for(multistream_path, cache_dir: opts[:cache_dir]) ) unless fts.built? print_error("Full-text index not found for this dump.") ("Build it first with: wp2txt --build-index --fulltext #{opts[:lang] ? "-L #{opts[:lang]}" : "-i #{opts[:input]}"}") return CliUI::EXIT_ERROR end if fts.optimized? print_success("Full-text index is already optimized.") fts.close return CliUI::EXIT_SUCCESS end ("Optimizing full-text index (single-threaded; can take 30-60+ minutes on large indexes)...") time_start = Time.now fts.optimize! print_success("Optimize complete in #{format_duration(Time.now - time_start)}") print_info("Size", format_size(File.size(fts.db_path))) fts.close CliUI::EXIT_SUCCESS end |
#run_phase_isolated(&block) ⇒ Boolean
Run a build phase in a forked child process. Each phase pumps its whole dataset through the parent's heap (Marshal.load of every worker batch), leaving a multi-GB high-water mark; a later phase forking workers from that bloated parent duplicates it per worker via copy-on-write breakage and OOMs the machine (observed on enwiki: 18GB per FTS worker). A child process confines each phase's high-water mark to that phase.
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 |
# File 'lib/wp2txt/index_commands.rb', line 88 def run_phase_isolated(&block) return block.call unless Process.respond_to?(:fork) pid = Process.fork do status = 1 begin status = block.call ? 0 : 1 rescue StandardError => e warn "#{e.class}: #{e.}" end $stdout.flush $stderr.flush exit!(status) # skip at_exit handlers; they belong to the parent end _, status = Process.waitpid2(pid) status.success? end |
#run_search(opts) ⇒ Object
Full-text search from the CLI (--search)
210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 |
# File 'lib/wp2txt/index_commands.rb', line 210 def run_search(opts) multistream_path, = resolve_dump_paths(opts, download: false) return CliUI::EXIT_ERROR unless multistream_path corpus = Corpus.new( multistream_path: multistream_path, index_path: resolve_dump_paths(opts, download: false)[1], cache_dir: opts[:cache_dir] ) unless corpus.fts.built? print_error("Full-text index not found for this dump.") ("Build it first with: wp2txt --build-index --fulltext #{opts[:lang] ? "-L #{opts[:lang]}" : "-i #{opts[:input]}"}") return CliUI::EXIT_ERROR end result = corpus.search_text( opts[:search], sections: opts[:has_section] ? [opts[:has_section]] : nil, category: opts[:in_category], depth: opts[:in_category] ? opts[:depth] : 0, limit: opts[:limit].positive? ? opts[:limit] : 20, count: "exact" ) if opts[:format].to_s.downcase == "json" puts JSON.generate(result) else result[:hits].each do |hit| puts "#{hit[:section_path]}: #{hit[:snippet]}" end $stderr.puts pastel.dim("# #{result[:returned]} of #{result[:total]} matches (dump: #{result[:dump]})") end corpus.close CliUI::EXIT_SUCCESS end |