onlylogs 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 19813b5a5884acae634a3516e4cd23dc67fb5cb359062491a15d331f35de2448
4
- data.tar.gz: 158e9b56b481744b67a6d92b0835f1443f769b0f264149fb71fe1b69fc86e250
3
+ metadata.gz: 0e6b4ee2fde339a248ff9e15557c8bb8f0ea525b3807aa09b157e537057c0c7a
4
+ data.tar.gz: 208543eb460dc3f7c302fac4d4bd3277c710e402346f759c7e66c1b2dfc7049b
5
5
  SHA512:
6
- metadata.gz: 9b2c8cd637017af2c310b06a406d75c0a70659244f6b93dfcb12b0f470337d87c8254e257fa4e4810faea6b12e0f2ad27a60b40d2c3423cc98deb21cf56df39d
7
- data.tar.gz: d2b9af11af98db9c2d92142553a1a7cc3eb915ddc398ed91b34a04d201e4cbda3dbc926bd007fef335071cbfc77e91ce22b3ff272c3eb7ffa8b607b032841cc6
6
+ metadata.gz: 4b47a580f758cacca9c34b1de0ebe445ff4db5df1e45f6e515cd80b1dd866d3dbbb39d7aea7862b94ee46e5d18cd39797fbadcb202e59745bcb94fb0a62da2b7
7
+ data.tar.gz: 8956f328a31a63cbf07a180a51ffa612c516f51ae37523bba49f6a41557a14ae35a83d91bc1bcc05b465bedd6cbc61cbffb8a402822a27b17d534668acd65211
@@ -55,8 +55,12 @@ module Onlylogs
55
55
  true
56
56
  end
57
57
 
58
- def grep(filter, regexp_mode: false, start_position: 0, end_position: nil, &block)
59
- Grep.grep(filter, path, regexp_mode: regexp_mode, start_position: start_position, end_position: end_position) do |content|
58
+ # +timeout+ is in seconds and defaults to nil, meaning unbounded. See
59
+ # Grep.grep.
60
+ def grep(filter, regexp_mode: false, start_position: 0, end_position: nil,
61
+ max_matches: Onlylogs.max_line_matches, timeout: nil, &block)
62
+ Grep.grep(filter, path, regexp_mode: regexp_mode, start_position: start_position,
63
+ end_position: end_position, max_matches: max_matches, timeout: timeout) do |content|
60
64
  yield content
61
65
  end
62
66
  end
@@ -1,21 +1,21 @@
1
+ require "timeout"
2
+
1
3
  module Onlylogs
2
4
  class Grep
3
- def self.grep(pattern, file_path, start_position: 0, end_position: nil, regexp_mode: false, &block)
4
- # Use the appropriate script based on configuration
5
- script_name = Onlylogs.ripgrep_enabled? ? "super_ripgrep" : "super_grep"
6
- super_grep_path = ::File.expand_path("../../../bin/#{script_name}", __dir__)
7
-
8
- command_args = [super_grep_path]
9
- command_args += ["--max-matches", Onlylogs.max_line_matches.to_s] if Onlylogs.max_line_matches.present?
10
- command_args << "--regexp" if regexp_mode
11
-
12
- # Add byte range parameters if specified
13
- if start_position > 0 || end_position
14
- command_args << "--start-position" << start_position.to_s
15
- command_args << "--end-position" << end_position.to_s if end_position
16
- end
5
+ # Raised when a search runs past its own +timeout+. It inherits from
6
+ # Timeout::Error so callers that already wrap searches in Timeout.timeout
7
+ # can keep a single rescue.
8
+ class TimeoutError < ::Timeout::Error; end
17
9
 
18
- command_args += [pattern, file_path]
10
+ # +timeout+ is in seconds and defaults to nil, which is no deadline at all:
11
+ # the search runs until it finishes or the caller abandons it. The default
12
+ # is deliberately not a number, because only the caller knows how long it
13
+ # can afford to hold the thread it runs on. Anything serving a request
14
+ # should pass one.
15
+ def self.grep(pattern, file_path, start_position: 0, end_position: nil, regexp_mode: false,
16
+ max_matches: Onlylogs.max_line_matches, timeout: nil, &block)
17
+ command_args = search_command(pattern, file_path, start_position: start_position,
18
+ end_position: end_position, regexp_mode: regexp_mode, max_matches: max_matches)
19
19
 
20
20
  results = []
21
21
 
@@ -23,14 +23,14 @@ module Onlylogs
23
23
  parse_line = if Onlylogs.ripgrep_enabled?
24
24
  ->(line) {
25
25
  parts = line.split(":", 2)
26
- [parts[0].to_i, parts[1] || ""]
26
+ [parts[0].to_i + start_position, parts[1] || ""]
27
27
  }
28
28
  else
29
29
  ->(line) { [nil, line] }
30
30
  end
31
31
 
32
- IO.popen(command_args, err: "/dev/null") do |io|
33
- io.each_line do |line|
32
+ begin
33
+ each_output_line(command_args, timeout: timeout) do |line|
34
34
  byte_offset, content = parse_line.call(line.chomp)
35
35
 
36
36
  # Use String.new to create a copy and prevent memory retention from IO buffers
@@ -51,12 +51,100 @@ module Onlylogs
51
51
  block_given? ? nil : results
52
52
  end
53
53
 
54
+ def self.search_command(pattern, file_path, start_position: 0, end_position: nil, regexp_mode: false,
55
+ max_matches: Onlylogs.max_line_matches)
56
+ script_name = Onlylogs.ripgrep_enabled? ? "super_ripgrep" : "super_grep"
57
+ super_grep_path = ::File.expand_path("../../../bin/#{script_name}", __dir__)
58
+
59
+ command_args = [super_grep_path]
60
+ command_args += ["--max-matches", max_matches.to_s] if max_matches.present?
61
+ command_args << "--regexp" if regexp_mode
62
+
63
+ # Add byte range parameters if specified
64
+ if start_position > 0 || end_position
65
+ command_args << "--start-position" << start_position.to_s
66
+ command_args << "--end-position" << end_position.to_s if end_position
67
+ end
68
+
69
+ command_args + [pattern, file_path]
70
+ end
71
+
72
+ # Runs the search subprocess and yields its output line by line.
73
+ #
74
+ # The child is a shell pipeline (tail | head | rg) that can spend minutes
75
+ # scanning a multi-GB file, so two things have to hold. timeout(1) bounds
76
+ # the run and escalates TERM to KILL on the whole pipeline by itself, which
77
+ # covers the deadline. The rest is the caller walking away early - a break
78
+ # out of the yield, an exception, a dropped connection - and there the order
79
+ # below is the point: signal the process group *before* closing the pipe.
80
+ # Closing first waits for a child that, having matched nothing, never wrote
81
+ # and so never received SIGPIPE. That is how a 25 second timeout once turned
82
+ # into a 24 minute request.
83
+ def self.each_output_line(command_args, timeout: nil, &block)
84
+ reader, writer = IO.pipe
85
+
86
+ begin
87
+ pid = Process.spawn(*bounded(command_args, timeout), out: writer, err: ::File::NULL, pgroup: true)
88
+ rescue
89
+ reader.close
90
+ raise
91
+ ensure
92
+ writer.close
93
+ end
94
+
95
+ begin
96
+ reader.each_line(&block)
97
+ ensure
98
+ status = stop(pid)
99
+ reader.close
100
+ end
101
+
102
+ raise TimeoutError, "search exceeded #{timeout}s" if status&.exitstatus == TIMED_OUT_EXIT_STATUS
103
+ end
104
+
105
+ # timeout(1) exits with this when it had to stop the command.
106
+ TIMED_OUT_EXIT_STATUS = 124
107
+ KILL_GRACE_PERIOD = 0.5
108
+
109
+ def self.bounded(command_args, timeout)
110
+ return command_args unless timeout && timeout_command_available?
111
+
112
+ ["timeout", "-k", KILL_GRACE_PERIOD.to_s, timeout.to_s, *command_args]
113
+ end
114
+
115
+ # Not part of a BSD userland, so a machine without GNU coreutils falls back
116
+ # to whatever deadline the caller imposes. The kill path below still works.
117
+ def self.timeout_command_available?
118
+ return @timeout_command_available if defined?(@timeout_command_available)
119
+
120
+ @timeout_command_available = system("command -v timeout > /dev/null 2>&1")
121
+ end
122
+
123
+ # Runs while unwinding from an async exception often enough that a second
124
+ # one - another Timeout::Error, a Thread#kill from a shutting down server -
125
+ # could otherwise land between the signal and the reap and leave the
126
+ # pipeline running with nobody left to stop it.
127
+ def self.stop(pid)
128
+ Thread.handle_interrupt(::Exception => :never) do
129
+ begin
130
+ Process.kill("TERM", -pid)
131
+ rescue Errno::ESRCH, Errno::EPERM
132
+ nil
133
+ end
134
+
135
+ Process.waitpid2(pid).last
136
+ end
137
+ rescue Errno::ECHILD
138
+ nil
139
+ end
140
+
54
141
  # Searching a large log file pulls the whole file into the OS page cache.
55
142
  # In a container the kernel charges that cache to the cgroup, so a few
56
143
  # searches over multi-GB logs can exhaust the memory limit and trigger an
57
144
  # OOM kill even though no Ruby memory leaked. Hint the kernel to drop the
58
- # pages we just read. Best-effort: advise is only a hint and is unsupported
59
- # on some platforms, so never let it break a search.
145
+ # pages we just read, once the child is gone and nothing is refilling them.
146
+ # Best-effort: advise is only a hint and is unsupported on some platforms,
147
+ # so never let it break a search.
60
148
  def self.drop_page_cache(file_path)
61
149
  ::File.open(file_path) { |file| file.advise(:dontneed) }
62
150
  rescue
data/bin/super_grep CHANGED
@@ -5,10 +5,6 @@ export LC_ALL=C
5
5
  max_matches=""
6
6
  start_position=""
7
7
  end_position=""
8
- # Block size for dd operations. Performance testing shows no significant
9
- # difference across block sizes (4K-8M) on typical log files. 1M balances
10
- # memory usage and I/O efficiency.
11
- block_size="${BLOCK_SIZE:-1M}"
12
8
 
13
9
  while [[ $# -gt 0 ]]; do
14
10
  case "$1" in
@@ -67,26 +63,22 @@ fi
67
63
 
68
64
  # Handle byte range if specified
69
65
  if [ -n "$start_position" ] || [ -n "$end_position" ]; then
66
+ case "${start_position:-0}${end_position:-0}" in
67
+ *[!0-9]*) exit 1 ;;
68
+ esac
69
+
70
70
  file_size=$(wc -c < "$file")
71
71
  range_start=${start_position:-0}
72
72
  range_end=${end_position:-$file_size}
73
73
  range_size=$((range_end - range_start))
74
-
75
- # Validate range
76
- if [ $range_start -lt 0 ] || [ $range_size -le 0 ] || [ $range_start -ge $file_size ]; then
74
+
75
+ if [ $range_size -le 0 ] || [ $range_start -ge $file_size ]; then
77
76
  exit 0
78
77
  fi
79
-
80
- # Adjust if exceeds file size
78
+
81
79
  [ $range_end -gt $file_size ] && range_end=$file_size && range_size=$((range_end - range_start))
82
-
83
- # Extract byte range using dd
84
- start_mb=$((range_start / 1048576))
85
- start_offset=$((range_start % 1048576))
86
- count_mb=$(((range_size + 1048576 - 1) / 1048576))
87
-
88
- dd if="$file" bs="$block_size" skip=$start_mb count=$count_mb 2>/dev/null | \
89
- dd bs=1 skip=$start_offset count=$range_size 2>/dev/null | \
80
+
81
+ tail -c +$((range_start + 1)) "$file" | head -c $range_size | \
90
82
  "${grep_cmd[@]}" "$query_regex"
91
83
  else
92
84
  # Search entire file
data/bin/super_ripgrep CHANGED
@@ -5,10 +5,6 @@ export LC_ALL=C
5
5
  max_matches=""
6
6
  start_position=""
7
7
  end_position=""
8
- # Block size for dd operations. Performance testing shows no significant
9
- # difference across block sizes (4K-8M) on typical log files. 1M balances
10
- # memory usage and I/O efficiency.
11
- block_size="${BLOCK_SIZE:-1M}"
12
8
 
13
9
  while [[ $# -gt 0 ]]; do
14
10
  case "$1" in
@@ -69,26 +65,22 @@ rg_cmd="rg --color=never --no-filename --byte-offset --no-mmap"
69
65
 
70
66
  # Handle byte range if specified
71
67
  if [ -n "$start_position" ] || [ -n "$end_position" ]; then
68
+ case "${start_position:-0}${end_position:-0}" in
69
+ *[!0-9]*) exit 1 ;;
70
+ esac
71
+
72
72
  file_size=$(wc -c < "$file")
73
73
  range_start=${start_position:-0}
74
74
  range_end=${end_position:-$file_size}
75
75
  range_size=$((range_end - range_start))
76
-
77
- # Validate range
78
- if [ $range_start -lt 0 ] || [ $range_size -le 0 ] || [ $range_start -ge $file_size ]; then
76
+
77
+ if [ $range_size -le 0 ] || [ $range_start -ge $file_size ]; then
79
78
  exit 0
80
79
  fi
81
-
82
- # Adjust if exceeds file size
80
+
83
81
  [ $range_end -gt $file_size ] && range_end=$file_size && range_size=$((range_end - range_start))
84
-
85
- # Extract byte range using dd
86
- start_mb=$((range_start / 1048576))
87
- start_offset=$((range_start % 1048576))
88
- count_mb=$(((range_size + 1048576 - 1) / 1048576))
89
-
90
- dd if="$file" bs="$block_size" skip=$start_mb count=$count_mb 2>/dev/null | \
91
- dd bs=1 skip=$start_offset count=$range_size 2>/dev/null | \
82
+
83
+ tail -c +$((range_start + 1)) "$file" | head -c $range_size | \
92
84
  $rg_cmd -e "$query_regex"
93
85
  else
94
86
  # Search entire file
@@ -1,3 +1,3 @@
1
1
  module Onlylogs
2
- VERSION = "0.7.0"
2
+ VERSION = "0.8.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: onlylogs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alessandro Rodi