onlylogs 0.9.0 → 0.10.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.
@@ -11,8 +11,13 @@ module Onlylogs
11
11
  # This turns transient-failure / restart data loss into at-least-once delivery: a batch that was in
12
12
  # fact received but whose response was lost will be replayed and show up as a duplicate
13
13
  # downstream. Duplicates are an accepted trade for not losing data.
14
+ #
15
+ # The byte cap is enforced from an in-memory ledger of the files in the directory, refreshed by
16
+ # listing the directory at most every LEDGER_TTL seconds (sibling processes such as other Puma
17
+ # workers write to the same directory), so a write costs one file and not a stat of every file.
14
18
  class Spool
15
19
  DEFAULT_MAX_BYTES = 128 * 1024 * 1024 # 128 MB
20
+ LEDGER_TTL = 5
16
21
 
17
22
  def initialize(dir:, max_bytes: DEFAULT_MAX_BYTES)
18
23
  @dir = dir
@@ -21,6 +26,9 @@ module Onlylogs
21
26
  @token = SecureRandom.hex(4)
22
27
  @seq = 0
23
28
  @mutex = Mutex.new
29
+ @ledger = nil
30
+ @ledger_bytes = 0
31
+ @ledger_at = nil
24
32
  ::FileUtils.mkdir_p(@dir)
25
33
  end
26
34
 
@@ -29,6 +37,7 @@ module Onlylogs
29
37
  return if body.nil? || body.empty?
30
38
 
31
39
  @mutex.synchronize do
40
+ refresh_ledger if ledger_stale?
32
41
  evict(body.bytesize)
33
42
  seq = (@seq += 1)
34
43
  final = ::File.join(@dir, "#{@token}-#{format("%09d", seq)}.batch")
@@ -37,30 +46,59 @@ module Onlylogs
37
46
  # half-written file (it only globs *.batch).
38
47
  ::File.binwrite(tmp, body)
39
48
  ::File.rename(tmp, final)
49
+ @ledger << [final, body.bytesize]
50
+ @ledger_bytes += body.bytesize
40
51
  end
41
52
  rescue => e
42
53
  Kernel.warn "Onlylogs::Spool write error: #{e.class}: #{e.message}"
43
54
  end
44
55
 
45
- # Replay pending batches oldest-first. Yields each body; if the block returns truthy the file
46
- # is deleted (delivered), otherwise replay stops and the remaining files are kept for later.
47
- def replay
48
- pending_files.each do |path|
56
+ # Replay pending batches oldest-first, at most `limit` of them. Yields each body; if the block
57
+ # returns truthy the file is deleted (delivered, or given up on), otherwise replay stops and the
58
+ # remaining files are kept for later.
59
+ #
60
+ # Works off the ledger rather than listing the directory: with a large backlog replayed one file
61
+ # at a time, a glob per call would cost more than the delivery itself.
62
+ def replay(limit: nil)
63
+ paths = @mutex.synchronize do
64
+ refresh_ledger if ledger_stale?
65
+ @ledger.first(limit || @ledger.size).map(&:first)
66
+ end
67
+
68
+ paths.each do |path|
49
69
  body = read(path)
50
- next if body.nil? # already claimed/deleted by another process
70
+ if body.nil? # already claimed/deleted by another process
71
+ forget(path)
72
+ next
73
+ end
51
74
 
52
75
  break unless yield(body)
53
76
 
54
77
  delete(path)
78
+ forget(path)
55
79
  end
56
80
  end
57
81
 
58
82
  def empty?
59
- pending_files.empty?
83
+ @mutex.synchronize do
84
+ refresh_ledger if ledger_stale?
85
+ @ledger.empty?
86
+ end
60
87
  end
61
88
 
62
89
  private
63
90
 
91
+ def ledger_stale?
92
+ @ledger.nil? || Process.clock_gettime(Process::CLOCK_MONOTONIC) - @ledger_at > LEDGER_TTL
93
+ end
94
+
95
+ # Caller holds @mutex.
96
+ def refresh_ledger
97
+ @ledger = pending_files.map { |path| [path, size(path)] }
98
+ @ledger_bytes = @ledger.sum { |_, bytes| bytes }
99
+ @ledger_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
100
+ end
101
+
64
102
  # Oldest-first. mtime is the primary key; the zero-padded sequence in the filename breaks
65
103
  # ties (and preserves per-process write order when mtimes collide at coarse FS resolution).
66
104
  def pending_files
@@ -73,6 +111,18 @@ module Onlylogs
73
111
  Time.at(0)
74
112
  end
75
113
 
114
+ # Replay is oldest-first and so is the ledger, so the path is nearly always the head.
115
+ def forget(path)
116
+ @mutex.synchronize do
117
+ next if @ledger.nil?
118
+
119
+ index = (@ledger.first&.first == path) ? 0 : @ledger.index { |candidate, _| candidate == path }
120
+ next unless index
121
+
122
+ @ledger_bytes -= @ledger.delete_at(index).last
123
+ end
124
+ end
125
+
76
126
  def read(path)
77
127
  ::File.binread(path)
78
128
  rescue Errno::ENOENT
@@ -85,14 +135,12 @@ module Onlylogs
85
135
  nil
86
136
  end
87
137
 
88
- # Delete oldest batches until `incoming` more bytes fit under the cap.
138
+ # Delete oldest batches until `incoming` more bytes fit under the cap. Caller holds @mutex.
89
139
  def evict(incoming)
90
- files = pending_files
91
- total = files.sum { |path| size(path) }
92
-
93
- while total + incoming > @max_bytes && (oldest = files.shift)
94
- total -= size(oldest)
95
- delete(oldest)
140
+ while @ledger_bytes + incoming > @max_bytes && (oldest = @ledger.shift)
141
+ path, bytes = oldest
142
+ delete(path)
143
+ @ledger_bytes -= bytes
96
144
  end
97
145
  end
98
146
 
@@ -1,3 +1,3 @@
1
1
  module Onlylogs
2
- VERSION = "0.9.0"
2
+ VERSION = "0.10.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.9.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alessandro Rodi
@@ -58,6 +58,7 @@ files:
58
58
  - app/javascript/onlylogs/controllers/index.js
59
59
  - app/javascript/onlylogs/controllers/keyboard_shortcuts_controller.js
60
60
  - app/javascript/onlylogs/controllers/log_streamer_controller.js
61
+ - app/javascript/onlylogs/controllers/range_slider_controller.js
61
62
  - app/javascript/onlylogs/controllers/text_selection_controller.js
62
63
  - app/jobs/onlylogs/application_job.rb
63
64
  - app/models/onlylogs/ansi_color_parser.rb
@@ -80,6 +81,7 @@ files:
80
81
  - db/migrate/20250902112548_create_books.rb
81
82
  - lib/onlylogs.rb
82
83
  - lib/onlylogs/configuration.rb
84
+ - lib/onlylogs/continuous_log_writer.rb
83
85
  - lib/onlylogs/engine.rb
84
86
  - lib/onlylogs/formatter.rb
85
87
  - lib/onlylogs/http_device.rb