slk 0.8.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.
@@ -4,6 +4,26 @@ module Slk
4
4
  module Commands
5
5
  # Displays help information for commands
6
6
  class Help < Base
7
+ # Names are padded to the longest one rather than by hand, so adding a
8
+ # command cannot quietly break the column for every line below it.
9
+ COMMAND_SUMMARIES = [
10
+ ['status', 'Get or set your status'],
11
+ ['presence', 'Get or set your presence (away/active)'],
12
+ ['dnd', 'Manage Do Not Disturb'],
13
+ ['messages', 'Read channel or DM messages'],
14
+ ['search', 'Search messages across channels'],
15
+ ['unread', 'View and clear unread messages'],
16
+ ['activity', 'Show activity feed (reactions, mentions, threads)'],
17
+ ['later', 'Show saved "Later" items'],
18
+ ['who', 'Show a user profile'],
19
+ ['deactivations', 'Show who left the workspace, and when'],
20
+ ['preset', 'Manage and apply status presets'],
21
+ ['workspaces', 'Manage Slack workspaces'],
22
+ ['cache', 'Manage user/channel cache'],
23
+ ['emoji', 'Download workspace custom emoji'],
24
+ ['config', 'Configuration and setup']
25
+ ].freeze
26
+
7
27
  def execute
8
28
  topic = positional_args.first
9
29
 
@@ -35,26 +55,13 @@ module Slk
35
55
  HEADER
36
56
  end
37
57
 
38
- # rubocop:disable Metrics/AbcSize
39
58
  def build_commands_section
40
- <<~COMMANDS
41
- #{output.bold('COMMANDS:')}
42
- #{output.cyan('status')} Get or set your status
43
- #{output.cyan('presence')} Get or set your presence (away/active)
44
- #{output.cyan('dnd')} Manage Do Not Disturb
45
- #{output.cyan('messages')} Read channel or DM messages
46
- #{output.cyan('search')} Search messages across channels
47
- #{output.cyan('unread')} View and clear unread messages
48
- #{output.cyan('activity')} Show activity feed (reactions, mentions, threads)
49
- #{output.cyan('later')} Show saved "Later" items
50
- #{output.cyan('preset')} Manage and apply status presets
51
- #{output.cyan('workspaces')} Manage Slack workspaces
52
- #{output.cyan('cache')} Manage user/channel cache
53
- #{output.cyan('emoji')} Download workspace custom emoji
54
- #{output.cyan('config')} Configuration and setup
55
- COMMANDS
59
+ width = COMMAND_SUMMARIES.map { |name, _| name.length }.max + 2
60
+ rows = COMMAND_SUMMARIES.map do |name, summary|
61
+ " #{output.cyan(name)}#{' ' * (width - name.length)}#{summary}"
62
+ end
63
+ "#{output.bold('COMMANDS:')}\n#{rows.join("\n")}\n"
56
64
  end
57
- # rubocop:enable Metrics/AbcSize
58
65
 
59
66
  def build_options_section
60
67
  <<~OPTIONS
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Formatters
5
+ # RFC 4180 CSV, hand-rolled: Ruby 3.4 moved csv out of the default gems
6
+ # and into the bundled ones, so requiring it would make this tool depend
7
+ # on a gem, and it ships with none.
8
+ #
9
+ # Quotes only the fields that need it, so the common row stays readable in
10
+ # a terminal as well as in a spreadsheet.
11
+ module CsvWriter
12
+ module_function
13
+
14
+ NEEDS_QUOTES = /[",\r\n]|\A\s|\s\z/
15
+
16
+ def row(values)
17
+ values.map { |value| escape(value) }.join(',')
18
+ end
19
+
20
+ def escape(value)
21
+ text = stringify(value)
22
+ return text unless NEEDS_QUOTES.match?(text)
23
+
24
+ %("#{text.gsub('"', '""')}")
25
+ end
26
+
27
+ # nil is an empty cell. Writing the literal "nil" would give a
28
+ # spreadsheet a four-character string to count, sort and average.
29
+ def stringify(value)
30
+ value.nil? ? '' : value.to_s
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Formatters
5
+ # CSV export of a deactivation list, for the spreadsheet that inevitably
6
+ # gets asked for. Every matched row is written, not just the screenful a
7
+ # terminal would show — a truncated export is a wrong answer that looks
8
+ # like a right one.
9
+ class DeactivationCsv
10
+ HEADERS = %w[deactivated_on user_id handle real_name title email bot].freeze
11
+ TENURE_HEADERS = %w[started_on tenure_months tenure].freeze
12
+
13
+ def initialize(output:, tenures: nil)
14
+ @output = output
15
+ @tenures = tenures
16
+ end
17
+
18
+ def render(records)
19
+ @output.puts(CsvWriter.row(headers))
20
+ records.each { |record| @output.puts(CsvWriter.row(cells(record))) }
21
+ end
22
+
23
+ private
24
+
25
+ def headers
26
+ @tenures ? HEADERS + TENURE_HEADERS : HEADERS
27
+ end
28
+
29
+ def cells(record)
30
+ row = [record.date, record.user_id, record.handle, record.real_name,
31
+ record.title, record.email, record.bot]
32
+ @tenures ? row + tenure_cells(record) : row
33
+ end
34
+
35
+ # Empty cells rather than zeros, so a spreadsheet averaging tenure skips
36
+ # them instead of counting people who left the day they arrived. Two
37
+ # different unknowns land here: no start date on file at all, which
38
+ # blanks the whole group, and a start date later than the departure,
39
+ # which keeps started_on so the bad data is visible and blanks the
40
+ # length that cannot be derived from it.
41
+ def tenure_cells(record)
42
+ tenure = @tenures[record.user_id]
43
+ return [nil, nil, nil] unless tenure
44
+
45
+ [tenure.started, tenure.months, tenure.to_s]
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Formatters
5
+ # Renders deactivation lists and per-month histograms.
6
+ class DeactivationFormatter
7
+ DATE_WIDTH = 10
8
+ MAX_NAME_WIDTH = 26
9
+ MIN_TITLE_WIDTH = 12
10
+ MAX_BAR_WIDTH = 48
11
+ BAR_CHARS = '█'
12
+
13
+ def initialize(output:, width: nil)
14
+ @output = output
15
+ @width = width || 100
16
+ end
17
+
18
+ # One line per departure: date, name, tenure, title. Full ISO dates on
19
+ # every row (rather than month headings) so the output stays greppable.
20
+ #
21
+ # `tenures` is keyed by user ID and may cover only some of the rows: a
22
+ # start date nobody filled in leaves the column blank rather than
23
+ # guessing, and the column disappears entirely when none are known.
24
+ def list(records, tenures: {})
25
+ name_width = name_column_width(records)
26
+ tenure_width = tenure_column_width(records, tenures)
27
+ records.each { |record| @output.puts(row(record, name_width, tenures, tenure_width)) }
28
+ end
29
+
30
+ def chart(records, from: nil, to: nil)
31
+ counts = monthly_counts(records, from: from, to: to)
32
+ return if counts.empty?
33
+
34
+ peak = counts.values.max
35
+ label_width = counts.values.map { |n| n.to_s.length }.max
36
+ counts.each { |month, count| @output.puts(chart_row(month, count, peak, label_width)) }
37
+ end
38
+
39
+ def summary(text)
40
+ @output.puts(@output.bold(text))
41
+ end
42
+
43
+ def note(text)
44
+ @output.puts(@output.gray(text))
45
+ end
46
+
47
+ # Deactivations per calendar month, oldest first, with empty months
48
+ # filled in — a gap in a histogram should read as zero, not as absence.
49
+ # `from`/`to` widen the span to the window the caller asked about, so a
50
+ # quiet first or last month is shown as quiet rather than dropped.
51
+ def monthly_counts(records, from: nil, to: nil)
52
+ months = records.filter_map(&:month).sort
53
+ first = [from, months.first].compact.min
54
+ last = [to, months.last].compact.max
55
+ return {} if first.nil? || last.nil? || first > last
56
+
57
+ all_months(first, last).to_h do |month|
58
+ [month, months.count(month)]
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def chart_row(month, count, peak, label_width)
65
+ bar = BAR_CHARS * bar_length(count, peak)
66
+ "#{@output.gray(month)} #{count.to_s.rjust(label_width)} #{bar}".rstrip
67
+ end
68
+
69
+ def row(record, name_width, tenures = {}, tenure_width = 0)
70
+ line = "#{@output.gray((record.date || 'unknown').ljust(DATE_WIDTH))} " \
71
+ "#{truncate(record.best_name.to_s, name_width).ljust(name_width)}"
72
+ line = "#{line} #{@output.gray(tenure_cell(record, tenures, tenure_width))}" if tenure_width.positive?
73
+ title = truncate(record.title.to_s, title_width(name_width, tenure_width))
74
+ title.empty? ? line : "#{line} #{@output.gray(title)}"
75
+ end
76
+
77
+ def tenure_cell(record, tenures, width)
78
+ tenures[record.user_id].to_s.rjust(width)
79
+ end
80
+
81
+ def tenure_column_width(records, tenures)
82
+ return 0 if tenures.nil? || tenures.empty?
83
+
84
+ records.filter_map { |r| tenures[r.user_id]&.to_s&.length }.max || 0
85
+ end
86
+
87
+ def name_column_width(records)
88
+ longest = records.map { |r| r.best_name.to_s.length }.max || 0
89
+ longest.clamp(8, MAX_NAME_WIDTH)
90
+ end
91
+
92
+ def title_width(name_width, tenure_width = 0)
93
+ tenure_space = tenure_width.positive? ? tenure_width + 2 : 0
94
+ [@width - DATE_WIDTH - name_width - tenure_space - 4, MIN_TITLE_WIDTH].max
95
+ end
96
+
97
+ # A month nobody left gets no bar at all. Rounding a zero up to one
98
+ # block would draw departures that did not happen.
99
+ def bar_length(count, peak)
100
+ return 0 if peak.zero? || count.zero?
101
+
102
+ available = (@width - 16).clamp(10, MAX_BAR_WIDTH)
103
+ [((count.to_f / peak) * available).round, 1].max
104
+ end
105
+
106
+ def truncate(text, width)
107
+ return text if text.length <= width
108
+
109
+ "#{text[0, width - 1]}…"
110
+ end
111
+
112
+ def all_months(first, last)
113
+ (month_index(first)..month_index(last)).map do |index|
114
+ format('%<year>04d-%<month>02d', year: index / 12, month: (index % 12) + 1)
115
+ end
116
+ end
117
+
118
+ def month_index(month)
119
+ year, mon = month.split('-').map(&:to_i)
120
+ (year * 12) + (mon - 1)
121
+ end
122
+ end
123
+ end
124
+ end
@@ -26,6 +26,7 @@ module Slk
26
26
  @color = color.nil? ? io.tty? : color
27
27
  @verbose = verbose
28
28
  @quiet = quiet
29
+ @last_progress_width = nil
29
30
  end
30
31
 
31
32
  def puts(message = '')
@@ -52,6 +53,44 @@ module Slk
52
53
  puts(colorize(message))
53
54
  end
54
55
 
56
+ # Transient progress on stderr: it never pollutes piped stdout, and it
57
+ # overwrites itself rather than scrolling. Silent under --quiet, and
58
+ # when stderr is not a terminal, since a log file full of half-drawn
59
+ # counters helps nobody.
60
+ def progress(message)
61
+ return unless progress?
62
+
63
+ @last_progress_width = message.length
64
+ write_progress("\r#{message}")
65
+ end
66
+
67
+ # Erases whatever progress() last drew. Keyed off the saved width rather
68
+ # than re-checking tty state: if a line was drawn, it gets cleaned up.
69
+ def clear_progress
70
+ return unless @last_progress_width
71
+
72
+ write_progress("\r#{' ' * @last_progress_width}\r")
73
+ @last_progress_width = nil
74
+ end
75
+
76
+ # This is decoration. It is often called from an ensure block cleaning
77
+ # up after a real failure, and a closed or broken stderr must not
78
+ # replace that failure with one about drawing a counter.
79
+ def write_progress(text)
80
+ @err.print(text)
81
+ @err.flush
82
+ rescue SystemCallError, IOError
83
+ nil
84
+ end
85
+
86
+ def progress? = tty_err? && !@quiet
87
+
88
+ def tty_err?
89
+ @err.tty?
90
+ rescue SystemCallError, IOError
91
+ false
92
+ end
93
+
55
94
  def debug(message)
56
95
  return unless @verbose
57
96
 
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Models
5
+ # A deactivated Slack account and when it was deactivated.
6
+ #
7
+ # `deactivated_at` comes from the user object's `updated` field, which is
8
+ # the epoch of the last change to that account. For a deactivated account
9
+ # the deactivation is almost always that last change — but an admin who
10
+ # edits a departed user's profile afterwards moves the timestamp forward,
11
+ # so treat it as "last touched", not a payroll record.
12
+ Deactivation = Data.define(
13
+ :user_id, :handle, :real_name, :title, :email, :deactivated_at, :bot
14
+ ) do
15
+ def self.from_api(member)
16
+ profile = member['profile'] || {}
17
+
18
+ new(
19
+ user_id: member['id'],
20
+ handle: member['name'],
21
+ real_name: profile['real_name'] || member['real_name'],
22
+ title: profile['title'],
23
+ email: profile['email'],
24
+ deactivated_at: positive_int(member['updated']),
25
+ bot: bot?(member)
26
+ )
27
+ end
28
+
29
+ # Rebuild from a JSON round-trip through the meta cache.
30
+ def self.from_cache(hash)
31
+ new(
32
+ user_id: hash['user_id'], handle: hash['handle'], real_name: hash['real_name'],
33
+ title: hash['title'], email: hash['email'],
34
+ deactivated_at: positive_int(hash['deactivated_at']), bot: hash['bot'] ? true : false
35
+ )
36
+ end
37
+
38
+ def self.bot?(member)
39
+ member['is_bot'] || member['is_app_user'] || member['id'] == 'USLACKBOT' ? true : false
40
+ end
41
+
42
+ def self.positive_int(value)
43
+ int = value.to_i
44
+ int.positive? ? int : nil
45
+ end
46
+
47
+ def best_name
48
+ return real_name unless real_name.to_s.empty?
49
+ return handle unless handle.to_s.empty?
50
+
51
+ user_id.to_s
52
+ end
53
+
54
+ def deactivated_time
55
+ deactivated_at && Time.at(deactivated_at)
56
+ end
57
+
58
+ def date
59
+ deactivated_time&.strftime('%Y-%m-%d')
60
+ end
61
+
62
+ def month
63
+ deactivated_time&.strftime('%Y-%m')
64
+ end
65
+
66
+ # Matches the caller's pattern against every field a human would search
67
+ # by. Case sensitivity is the pattern's to declare, not this method's.
68
+ def matches?(pattern)
69
+ [handle, real_name, title, email, user_id].compact.any? { |field| pattern.match?(field) }
70
+ end
71
+
72
+ def to_cache
73
+ to_h.transform_keys(&:to_s)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Models
5
+ # How long someone was here: a start date from their Slack profile and the
6
+ # date their account was deactivated.
7
+ #
8
+ # Both ends are softer than they look. The start date is whatever an admin
9
+ # typed into a custom profile field, and the end is the account's `updated`
10
+ # timestamp. Neither is a payroll record, so this rounds to whole months
11
+ # and refuses to imply a precision it does not have.
12
+ Tenure = Data.define(:started_on, :ended_on) do
13
+ # @param started [String, nil] ISO date from the profile field
14
+ # @param ended [Time, nil] deactivation time
15
+ def self.build(started, ended)
16
+ date = parse_date(started)
17
+ return nil unless date
18
+
19
+ new(started_on: date, ended_on: ended ? to_date(ended) : nil)
20
+ end
21
+
22
+ def self.parse_date(value)
23
+ text = value.to_s.strip
24
+ return nil unless /\A\d{4}-\d{2}-\d{2}\z/.match?(text)
25
+
26
+ Date.iso8601(text)
27
+ rescue Date::Error
28
+ nil
29
+ end
30
+
31
+ def self.to_date(time)
32
+ Date.new(time.year, time.month, time.day)
33
+ end
34
+
35
+ # Nil when the account is still active, or when the end predates the
36
+ # start — a start date typed in after the fact can land anywhere, and a
37
+ # negative tenure is a data entry error, not a fact about a person.
38
+ def months
39
+ return nil unless ended_on && ended_on >= started_on
40
+
41
+ ended_on.day < started_on.day ? month_span - 1 : month_span
42
+ end
43
+
44
+ def month_span
45
+ ((ended_on.year - started_on.year) * 12) + (ended_on.month - started_on.month)
46
+ end
47
+ private :month_span
48
+
49
+ # "6y 2mo", "11mo", "<1mo" — whole months only.
50
+ def to_s
51
+ total = months
52
+ return '' unless total
53
+ return '<1mo' if total.zero?
54
+
55
+ years, rest = total.divmod(12)
56
+ [years.positive? ? "#{years}y" : nil, rest.positive? ? "#{rest}mo" : nil].compact.join(' ')
57
+ end
58
+
59
+ # True when the length cannot be worked out: an active account, or an
60
+ # end date that predates the start.
61
+ def unknown? = months.nil?
62
+
63
+ def started = started_on.iso8601
64
+ end
65
+ end
66
+ end
@@ -232,7 +232,7 @@ module Slk
232
232
  end
233
233
 
234
234
  def parse_success_response(response)
235
- result = JSON.parse(response.body)
235
+ result = parse_body(response.body)
236
236
  raise_rate_limit(response) if result['error'] == 'ratelimited'
237
237
  unless result['ok']
238
238
  message = result['error'] || 'Unknown error'
@@ -240,6 +240,17 @@ module Slk
240
240
  end
241
241
 
242
242
  result
243
+ end
244
+
245
+ # Every caller treats a response as a Hash and digs into it. Slack
246
+ # sending a bare array, string or null is not something any of them can
247
+ # act on, so it fails here as an API error rather than as a TypeError
248
+ # somewhere downstream.
249
+ def parse_body(body)
250
+ result = JSON.parse(body)
251
+ return result if result.is_a?(Hash)
252
+
253
+ raise ApiError.new('Unexpected response shape from Slack API', code: :invalid_response)
243
254
  rescue JSON::ParserError
244
255
  raise ApiError.new('Invalid JSON response from Slack API', code: :invalid_json)
245
256
  end
@@ -135,6 +135,19 @@ module Slk
135
135
  end
136
136
  end
137
137
 
138
+ # The meta cache holds everything that is neither a user nor a channel:
139
+ # start dates, the deactivation roster, resolved profiles. "Clear all
140
+ # caches" was not telling the truth while this was left behind.
141
+ def clear_meta_cache(workspace_name = nil)
142
+ if workspace_name
143
+ @meta_cache.delete(workspace_name)
144
+ FileUtils.rm_f(meta_cache_file(workspace_name))
145
+ else
146
+ @meta_cache.clear
147
+ Dir.glob(@paths.cache_file('meta-*.json')).each { |f| FileUtils.rm_f(f) }
148
+ end
149
+ end
150
+
138
151
  # Subteam cache methods
139
152
  def get_subteam(workspace_name, subteam_id)
140
153
  load_subteam_cache(workspace_name)
@@ -199,6 +212,12 @@ module Slk
199
212
  @on_warning&.call("#{cache_type} cache corrupted for #{workspace_name}: #{e.message}. Cache will be rebuilt.")
200
213
  safely_delete_file(file)
201
214
  {}
215
+ rescue SystemCallError, IOError => e
216
+ # An unreadable cache file is the same situation as a corrupt one:
217
+ # carry on without it. Not deleted, because a file we cannot read is
218
+ # one we probably cannot remove either, and it may not be ours.
219
+ @on_warning&.call("#{cache_type} cache unreadable for #{workspace_name}: #{e.message}. Continuing without it.")
220
+ {}
202
221
  end
203
222
 
204
223
  def safely_delete_file(file)
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Services
5
+ # Slack has no "who left" endpoint. What it does have is users.list, which
6
+ # returns deactivated accounts with `deleted: true` and an `updated` epoch
7
+ # for the last change to the account — and for a deactivated account that
8
+ # change is nearly always the deactivation itself.
9
+ #
10
+ # The full roster is one API call per 1000 members, so the derived result
11
+ # (deactivated members plus headcounts, not the whole roster) is cached in
12
+ # the workspace meta cache with a TTL.
13
+ class DeactivationScanner
14
+ CACHE_KEY = 'deactivations_v1'
15
+ DEFAULT_TTL = 21_600 # 6 hours
16
+ REQUIRED_KEYS = %w[fetched_at member_count human_count active_count records].freeze
17
+
18
+ Report = Data.define(:records, :member_count, :human_count, :active_count, :fetched_at) do
19
+ def deactivated_count = records.size
20
+
21
+ def bots = records.count(&:bot)
22
+ end
23
+
24
+ def initialize(users_api:, workspace_name:, cache_store: nil, ttl: DEFAULT_TTL, on_debug: nil)
25
+ @users_api = users_api
26
+ @workspace_name = workspace_name
27
+ @cache = cache_store
28
+ @ttl = ttl
29
+ @on_debug = on_debug
30
+ end
31
+
32
+ # @return [Report] every deactivated account, newest deactivation first
33
+ def scan(refresh: false)
34
+ build_report(cached(refresh: refresh) || store(collect))
35
+ end
36
+
37
+ private
38
+
39
+ def cached(refresh:)
40
+ return nil if refresh
41
+
42
+ data = MetaCache.read(@cache, @workspace_name, CACHE_KEY, ttl: @ttl)
43
+ return data if usable?(data)
44
+
45
+ @on_debug&.call('deactivations cache is unusable; re-fetching the roster') if data
46
+ nil
47
+ end
48
+
49
+ # A truncated or older-shaped entry would otherwise be read field by
50
+ # field into a report of zero active members and zero departures — a
51
+ # confident, wrong answer. Missing anything required means refetch.
52
+ def usable?(data)
53
+ data.is_a?(Hash) && data['records'].is_a?(Array) && REQUIRED_KEYS.all? { |key| data[key] }
54
+ end
55
+
56
+ def store(data)
57
+ MetaCache.write(@cache, @workspace_name, CACHE_KEY, data)
58
+ data
59
+ end
60
+
61
+ def collect
62
+ members = fetch_members
63
+ deleted = members.select { |m| m['deleted'] }
64
+
65
+ counts(members).merge(
66
+ 'fetched_at' => Time.now.to_i,
67
+ 'records' => deleted.map { |m| Models::Deactivation.from_api(m).to_cache }
68
+ )
69
+ end
70
+
71
+ def counts(members)
72
+ humans = members.reject { |m| Models::Deactivation.bot?(m) }
73
+
74
+ {
75
+ 'member_count' => members.size,
76
+ 'human_count' => humans.size,
77
+ 'active_count' => humans.count { |m| !m['deleted'] }
78
+ }
79
+ end
80
+
81
+ def fetch_members
82
+ @users_api.list_all do |total|
83
+ @on_debug&.call("users.list: #{total} members fetched")
84
+ end
85
+ end
86
+
87
+ def build_report(data)
88
+ Report.new(
89
+ records: sorted_records(data['records'] || []),
90
+ member_count: data['member_count'].to_i,
91
+ human_count: data['human_count'].to_i,
92
+ active_count: data['active_count'].to_i,
93
+ fetched_at: data['fetched_at']&.to_i
94
+ )
95
+ end
96
+
97
+ # Newest first; accounts with no usable timestamp sort to the bottom
98
+ # rather than pretending to be from 1970.
99
+ def sorted_records(raw)
100
+ raw.map { |hash| Models::Deactivation.from_cache(hash) }
101
+ .sort_by { |record| -(record.deactivated_at || 0) }
102
+ end
103
+ end
104
+ end
105
+ end
@@ -7,6 +7,8 @@ module Slk
7
7
  module MetaCache
8
8
  module_function
9
9
 
10
+ # A write failure here is deliberately dropped: fetch's caller wants the
11
+ # value, and callers that need to report a cold cache use write directly.
10
12
  def fetch(cache_store, workspace_name, key, ttl: nil, refresh: false)
11
13
  cached = read(cache_store, workspace_name, key, ttl: ttl) unless refresh
12
14
  return cached if cached
@@ -22,10 +24,23 @@ module Slk
22
24
  cache_store.get_meta(workspace_name, key, ttl: ttl)
23
25
  end
24
26
 
27
+ # A cache write that fails must never cost the caller the work it just
28
+ # did — a full or read-only disk means "no cache", not "no answer", and
29
+ # some of these writes sit behind minutes of rate-limited API calls.
30
+ #
31
+ # A nil or false value is treated as nothing to store, since no caller
32
+ # caches a negative this way — the start date lookup caches per-user
33
+ # nils inside a Hash, which is a value like any other.
34
+ #
35
+ # @return [Exception, nil] the write failure, for callers that want to
36
+ # mention it; nil when the write succeeded or there was nothing to do
25
37
  def write(cache_store, workspace_name, key, value)
26
- return unless cache_store && workspace_name && value
38
+ return nil unless cache_store && workspace_name && value
27
39
 
28
40
  cache_store.set_meta(workspace_name, key, value)
41
+ nil
42
+ rescue SystemCallError, IOError => e
43
+ e
29
44
  end
30
45
  end
31
46
  end