schema_reaper 2.0.0 → 2.1.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +3 -0
  3. data/.schema_reaper.yml.example +17 -0
  4. data/CHANGELOG.md +144 -0
  5. data/PRO.md +11 -6
  6. data/README.md +421 -71
  7. data/RELEASE_CHECKLIST.md +13 -2
  8. data/lib/generators/schema_reaper/install/install_generator.rb +11 -11
  9. data/lib/generators/schema_reaper/install/templates/initializer.rb.tt +8 -0
  10. data/lib/schema_reaper/alert_config.rb +21 -1
  11. data/lib/schema_reaper/alert_state.rb +35 -0
  12. data/lib/schema_reaper/analyzers/always_null_column.rb +12 -5
  13. data/lib/schema_reaper/analyzers/base.rb +66 -2
  14. data/lib/schema_reaper/analyzers/dead_column.rb +12 -6
  15. data/lib/schema_reaper/analyzers/dead_table.rb +10 -6
  16. data/lib/schema_reaper/analyzers/duplicate_foreign_key.rb +53 -0
  17. data/lib/schema_reaper/analyzers/duplicate_index.rb +1 -1
  18. data/lib/schema_reaper/analyzers/missing_fk_index.rb +10 -4
  19. data/lib/schema_reaper/analyzers/sequence_exhaustion.rb +55 -0
  20. data/lib/schema_reaper/analyzers/single_value_column.rb +17 -5
  21. data/lib/schema_reaper/analyzers/unused_index.rb +11 -6
  22. data/lib/schema_reaper/baseline.rb +95 -10
  23. data/lib/schema_reaper/cli/baseline_support.rb +45 -0
  24. data/lib/schema_reaper/cli.rb +69 -14
  25. data/lib/schema_reaper/config.rb +28 -0
  26. data/lib/schema_reaper/doctor.rb +151 -0
  27. data/lib/schema_reaper/explain.rb +102 -0
  28. data/lib/schema_reaper/gem_awareness.rb +31 -1
  29. data/lib/schema_reaper/inflect.rb +36 -0
  30. data/lib/schema_reaper/introspect/postgres.rb +258 -55
  31. data/lib/schema_reaper/migration_generator.rb +90 -4
  32. data/lib/schema_reaper/notifier.rb +69 -8
  33. data/lib/schema_reaper/railtie.rb +1 -1
  34. data/lib/schema_reaper/reporters/sarif.rb +15 -6
  35. data/lib/schema_reaper/reporters/slack.rb +67 -0
  36. data/lib/schema_reaper/runner.rb +58 -6
  37. data/lib/schema_reaper/runtime.rb +94 -11
  38. data/lib/schema_reaper/scan_job.rb +24 -2
  39. data/lib/schema_reaper/schema.rb +32 -3
  40. data/lib/schema_reaper/schema_locator.rb +75 -0
  41. data/lib/schema_reaper/static/cache.rb +66 -0
  42. data/lib/schema_reaper/static/scanner.rb +174 -6
  43. data/lib/schema_reaper/tasks/schema_reaper.rake +2 -2
  44. data/lib/schema_reaper/version.rb +1 -1
  45. data/lib/schema_reaper.rb +18 -1
  46. data/schema_reaper.gemspec +1 -2
  47. metadata +13 -4
@@ -48,7 +48,7 @@ module SchemaReaper
48
48
 
49
49
  say_status :warning,
50
50
  "schema_reaper is Gemfile-scoped to group: :development. Most production deploy " \
51
- "pipelines strip dev/test groups (`bundle install --without development test`), so " \
51
+ "pipelines strip dev/test groups (`BUNDLE_WITHOUT=development:test`), so " \
52
52
  "the scheduled scan will not run in production until you remove that restriction.",
53
53
  :red
54
54
  end
@@ -57,7 +57,8 @@ module SchemaReaper
57
57
  return if mailer_from_configured?
58
58
 
59
59
  say_status :warning,
60
- "no ApplicationMailer default `from:` detected. SchemaReaper::Mailer falls back to a " \
60
+ "no ApplicationMailer default `from:` detected (nor config.action_mailer.default_options). " \
61
+ "SchemaReaper::Mailer falls back to a " \
61
62
  "placeholder sender in that case, which most real SMTP relays reject or spam-flag -- " \
62
63
  "email reports would silently fail to arrive. If you plan to use the email channel " \
63
64
  "(config.emails in the initializer this generator just wrote), set " \
@@ -152,7 +153,7 @@ module SchemaReaper
152
153
  # `gem "schema_reaper", group: :development` (or the equivalent
153
154
  # `group :development do ... end` block form) in the app's own Gemfile
154
155
  # -- see README's current install snippet. A dev-scoped gem is absent
155
- # from `bundle install --without development test`, which most
156
+ # from `BUNDLE_WITHOUT=development:test` installs, which most
156
157
  # production deploy pipelines run, so the scheduled scan silently
157
158
  # never runs.
158
159
  #
@@ -171,15 +172,14 @@ module SchemaReaper
171
172
  false # a Gemfile we can't parse shouldn't block the rest of the generator
172
173
  end
173
174
 
174
- # Checked against ApplicationMailer specifically, not SchemaReaper::Mailer
175
- # -- at generate time the app is already booted (ActionMailer, and
176
- # ApplicationMailer if the app has one, are loaded), so this reads the
177
- # same `default_params[:from]` chain SchemaReaper::Mailer itself will
178
- # inherit from once it's loaded.
175
+ # Checked against the class SchemaReaper::Mailer will inherit from --
176
+ # ApplicationMailer when the app has one, ActionMailer::Base otherwise
177
+ # (where `config.action_mailer.default_options = { from: ... }` lands).
178
+ # At generate time the app is already booted, so this reads the same
179
+ # `default_params[:from]` chain Mailer itself will see.
179
180
  def mailer_from_configured?
180
- return false unless defined?(::ApplicationMailer) # no mailer to inherit from at all
181
-
182
- ::ApplicationMailer.default_params[:from].present?
181
+ parent = defined?(::ApplicationMailer) ? ::ApplicationMailer : ::ActionMailer::Base
182
+ parent.default_params[:from].present?
183
183
  rescue StandardError
184
184
  true # can't determine -- don't nag over something we're unsure about
185
185
  end
@@ -15,4 +15,12 @@
15
15
  SchemaReaper::AlertConfig.configure do |config|
16
16
  # config.emails = %w[dev1@example.com dev2@example.com]
17
17
  # config.webhook_url = "https://hooks.slack.com/services/T000/B000/XXXX"
18
+
19
+ # :changes sends a report only when findings appeared or were resolved
20
+ # since the last one (needs Rails.cache); :always sends every run.
21
+ # config.alert_on = :changes
22
+
23
+ # :auto uses Slack Block Kit for hooks.slack.com URLs and a plain
24
+ # {"text": <markdown>} body for anything else. Force with :slack or :text.
25
+ # config.webhook_format = :auto
18
26
  end
@@ -30,11 +30,31 @@ module SchemaReaper
30
30
  end
31
31
  end
32
32
 
33
- attr_accessor :emails, :webhook_url
33
+ # alert_on: :always -- send every scheduled report (the 2.0 behaviour)
34
+ # :changes -- send only when findings appeared or were
35
+ # resolved since the last report
36
+ # webhook_format: :auto -- Slack Block Kit for hooks.slack.com, plain
37
+ # {"text": <markdown>} for anything else
38
+ # :slack / :text to force one
39
+ attr_accessor :emails, :webhook_url, :alert_on, :webhook_format
34
40
 
35
41
  def initialize
36
42
  @emails = []
37
43
  @webhook_url = nil
44
+ @alert_on = :always
45
+ @webhook_format = :auto
46
+ end
47
+
48
+ def changes_only?
49
+ alert_on.to_s == "changes"
50
+ end
51
+
52
+ def slack_blocks?
53
+ case webhook_format.to_s
54
+ when "slack" then true
55
+ when "text" then false
56
+ else webhook_url.to_s.include?("hooks.slack.com")
57
+ end
38
58
  end
39
59
 
40
60
  def emails?
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaReaper
4
+ # Remembers which finding ids the last delivered report contained, so the
5
+ # next one can say what changed -- and, with alert_on: :changes, stay quiet
6
+ # when nothing did. Kept in Rails.cache (the manual trigger's cooldown
7
+ # already relies on it) because production filesystems are often
8
+ # ephemeral. Without a cache every report is treated as the first.
9
+ class AlertState
10
+ KEY = "schema_reaper:last_alert_ids"
11
+
12
+ def initialize(cache: default_cache)
13
+ @cache = cache
14
+ end
15
+
16
+ # @return [Array<String>, nil] nil when no earlier report is known
17
+ def previous_ids
18
+ @cache&.read(KEY)
19
+ rescue StandardError
20
+ nil
21
+ end
22
+
23
+ def remember(ids)
24
+ @cache&.write(KEY, ids.sort)
25
+ rescue StandardError
26
+ nil
27
+ end
28
+
29
+ private
30
+
31
+ def default_cache
32
+ defined?(Rails) && Rails.respond_to?(:cache) ? Rails.cache : nil
33
+ end
34
+ end
35
+ end
@@ -20,6 +20,7 @@ module SchemaReaper
20
20
  table.columns.filter_map do |col|
21
21
  next unless col.always_null?
22
22
  next if config.always_keep_columns.include?(col.name)
23
+ next if ignored_column?(col.name)
23
24
  next if gem_reserved?(table.name, col.name)
24
25
 
25
26
  finding(
@@ -27,17 +28,23 @@ module SchemaReaper
27
28
  table: table.name,
28
29
  column: col.name,
29
30
  severity: :high,
30
- confidence: 0.85,
31
+ confidence: sampled_stats?(table) ? 0.7 : 0.85,
31
32
  bytes_per_row: col.bytes,
32
- evidence: [
33
- "pg_stats.null_frac = 1.0 across ~#{table.row_count} row(s)",
34
- "column carries no data"
35
- ],
33
+ evidence: evidence_for(table),
36
34
  suggested_fix: "verify with `SELECT count(#{col.name}) FROM #{table.name}` " \
37
35
  "then stage a removal"
38
36
  )
39
37
  end
40
38
  end
39
+
40
+ def evidence_for(table)
41
+ ev = ["pg_stats.null_frac = 1.0 across ~#{table.row_count} row(s)"]
42
+ ev << if sampled_stats?(table)
43
+ "estimated from a #{STATS_SAMPLE_ROWS}-row ANALYZE sample -- a few non-null rows could be missed"
44
+ else
45
+ "column carries no data"
46
+ end
47
+ end
41
48
  end
42
49
  end
43
50
  end
@@ -11,8 +11,14 @@ module SchemaReaper
11
11
  # gem_columns - Hash{table_name => Set<column_name>} reserved by gems
12
12
  # gem_owned_tables - Set<table_name> created and owned outright by a gem
13
13
  # config - Config
14
- Context = Struct.new(:schema, :used_tokens, :runtime, :gem_columns, :gem_owned_tables, :config,
14
+ # scan - Static::Result (optional; per-model detail
15
+ # for column_scope: model)
16
+ Context = Struct.new(:schema, :used_tokens, :runtime, :gem_columns, :gem_owned_tables, :config, :scan,
15
17
  keyword_init: true) do
18
+ def scan
19
+ self[:scan] || Static::Result.from_tokens(used_tokens || [])
20
+ end
21
+
16
22
  def runtime
17
23
  self[:runtime] || Runtime::Report.empty
18
24
  end
@@ -28,6 +34,11 @@ module SchemaReaper
28
34
 
29
35
  # Shared plumbing for analyzers: schema access and token lookup helpers.
30
36
  class Base
37
+ # ANALYZE reads a sample of 300 x default_statistics_target rows (30,000
38
+ # at the default of 100). Above that, pg_stats' null_frac and n_distinct
39
+ # are estimates: a value held by a handful of rows can be missed.
40
+ STATS_SAMPLE_ROWS = 30_000
41
+
31
42
  def self.type
32
43
  name.split("::").last.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym
33
44
  end
@@ -61,8 +72,61 @@ module SchemaReaper
61
72
  ctx.used_tokens.include?(token.to_s.downcase)
62
73
  end
63
74
 
75
+ # Whether code references `column` on `table`. With column_scope: model,
76
+ # a name seen only in model files counts just for the tables those
77
+ # models are, or mention (an association, a class name), so Order's
78
+ # `status` no longer hides a dead `status` on users.
79
+ def column_used?(table, column)
80
+ return used?(column) unless config.column_scope == :model
81
+
82
+ name = column.to_s.downcase
83
+ scan = ctx.scan
84
+ return true if scan.shared.include?(name)
85
+
86
+ forms = table_forms(table)
87
+ scan.models.any? do |model|
88
+ model.tokens.include?(name) && (model.tables.include?(table) || forms.any? { |f| model.tokens.include?(f) })
89
+ end
90
+ end
91
+
92
+ def table_forms(table)
93
+ bare = table.to_s.split(".").last
94
+ singular = Inflect.singularize(bare)
95
+ [bare, singular, singular.delete("_")].uniq
96
+ end
97
+
98
+ # Every column of a table a gem owns outright is the gem's business.
64
99
  def gem_reserved?(table, column)
65
- ctx.gem_columns.fetch(table, []).include?(column)
100
+ gem_owned_table?(table) || ctx.gem_columns.fetch(table, []).include?(column)
101
+ end
102
+
103
+ def sampled_stats?(table)
104
+ table.row_count.to_i > STATS_SAMPLE_ROWS
105
+ end
106
+
107
+ # The `ignore.columns` list (names or /regex/) applies to every
108
+ # column-level analyzer, not only dead_column.
109
+ def ignored_column?(name)
110
+ config.ignored_column?(name)
111
+ end
112
+
113
+ # The removal command for an index. DROP INDEX CONCURRENTLY is refused
114
+ # on a partitioned index ("cannot drop partitioned index ...
115
+ # concurrently"), and its per-partition indexes can't be dropped on
116
+ # their own while attached, so the only path is a plain drop, which
117
+ # takes an ACCESS EXCLUSIVE lock on the parent and every partition.
118
+ def remove_index_fix(table, index)
119
+ base = "remove_index #{sym(table.name)}, name: :#{index.name}"
120
+ return "#{base} (use algorithm: :concurrently in production)" unless index.partitioned
121
+
122
+ "#{base} (partitioned index: CONCURRENTLY is not possible -- this locks the table " \
123
+ "and all partitions while it runs, so schedule it for a quiet window)"
124
+ end
125
+
126
+ # A table name as a Ruby symbol literal for suggested fixes: :users, or
127
+ # :"billing.invoices" for a table outside the public schema.
128
+ def sym(name)
129
+ name.to_sym.inspect
66
130
  end
67
131
 
68
132
  def gem_owned_table?(table)
@@ -4,12 +4,12 @@ module SchemaReaper
4
4
  module Analyzers
5
5
  # Flags columns present in the schema but never referenced in code. When a
6
6
  # runtime usage log is supplied, its signal is fused in: a column unseen in
7
- # BOTH code and >= 14 observed days of runtime reaches high confidence.
7
+ # BOTH code and at least `min_age_days` (default 14) observed days of
8
+ # runtime reaches high confidence.
8
9
  class DeadColumn < Base
9
10
  Registry.register(self)
10
11
 
11
- STATIC_ONLY_CAP = 0.6
12
- RUNTIME_MIN_DAYS = 14
12
+ STATIC_ONLY_CAP = 0.6
13
13
 
14
14
  def call
15
15
  schema.tables.reject { |t| config.ignore_tables.include?(t.name) }
@@ -21,7 +21,7 @@ module SchemaReaper
21
21
  def dead_in(table)
22
22
  table.columns.filter_map do |col|
23
23
  next if keep?(table, col)
24
- next if used?(col.name)
24
+ next if column_used?(table.name, col.name)
25
25
  next if runtime.read?(table.name, col.name)
26
26
 
27
27
  finding(
@@ -33,7 +33,7 @@ module SchemaReaper
33
33
  bytes_per_row: col.bytes,
34
34
  evidence: evidence_for(table, col),
35
35
  suggested_fix: "Stage removal: `self.ignored_columns += %w[#{col.name}]` on the " \
36
- "model, deploy, then `remove_column :#{table.name}, :#{col.name}`."
36
+ "model, deploy, then `remove_column #{sym(table.name)}, :#{col.name}`."
37
37
  )
38
38
  end
39
39
  end
@@ -48,7 +48,7 @@ module SchemaReaper
48
48
  end
49
49
 
50
50
  def confidence_for(col)
51
- if runtime.present? && runtime.observed_days >= RUNTIME_MIN_DAYS
51
+ if runtime.present? && runtime.observed_days >= min_runtime_days
52
52
  col.null ? 0.9 : 0.8
53
53
  else
54
54
  base = col.null ? 0.5 : 0.4
@@ -56,6 +56,12 @@ module SchemaReaper
56
56
  end
57
57
  end
58
58
 
59
+ # How many days of runtime data it takes before "never read at runtime"
60
+ # is trusted. A config built without the key (nil) keeps the default.
61
+ def min_runtime_days
62
+ config.min_age_days || Config::DEFAULTS["min_age_days"]
63
+ end
64
+
59
65
  def evidence_for(table, col)
60
66
  ev = ["no `#{col.name}` reference found in scanned code"]
61
67
  ev << "column is nullable" if col.null
@@ -24,13 +24,13 @@ module SchemaReaper
24
24
 
25
25
  def ignored?(table)
26
26
  config.ignore_tables.include?(table.name) ||
27
- table.name.start_with?("active_storage_", "action_text_", "action_mailbox_") ||
27
+ table.name.split(".").last.start_with?("active_storage_", "action_text_", "action_mailbox_") ||
28
28
  gem_owned_table?(table.name)
29
29
  end
30
30
 
31
31
  def dead(table)
32
32
  return if referenced?(table)
33
- return if runtime.accessed.any? { |k| k.start_with?("#{table.name}.") }
33
+ return if runtime.table_accessed?(table.name)
34
34
 
35
35
  finding(
36
36
  type: :dead_table,
@@ -40,17 +40,21 @@ module SchemaReaper
40
40
  confidence: confidence_for(table),
41
41
  bytes_per_row: table.columns.sum(&:bytes),
42
42
  evidence: evidence_for(table),
43
- suggested_fix: "confirm no external consumer, then `drop_table :#{table.name}`"
43
+ suggested_fix: "confirm no external consumer, then `drop_table #{sym(table.name)}`"
44
44
  )
45
45
  end
46
46
 
47
47
  # Match the table name and its singular/camelized model form.
48
+ # A table outside public ("billing.invoices") is still referenced by its
49
+ # bare name in code -- `self.table_name = "billing.invoices"` tokenises
50
+ # to billing + invoices, and the model is Invoice.
48
51
  def referenced?(table)
49
- singular = singularize(table.name)
50
- forms = [table.name, singular, camelize(table.name), camelize(singular)]
52
+ bare = table.name.split(".").last
53
+ singular = singularize(bare)
54
+ forms = [bare, singular, camelize(bare), camelize(singular)]
51
55
  return true if forms.any? { |form| used?(form) }
52
56
 
53
- embedded_in_identifier?(table.name)
57
+ embedded_in_identifier?(bare)
54
58
  end
55
59
 
56
60
  def singularize(name)
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaReaper
4
+ module Analyzers
5
+ # Two FOREIGN KEY constraints on the same columns pointing at the same
6
+ # key of the same table, with the same ON UPDATE / ON DELETE, MATCH and
7
+ # deferrability -- only then is either one redundant. (Same columns but a
8
+ # different ON DELETE is not a duplicate: dropping one changes what a
9
+ # parent delete does.) Usually a hand-written add_foreign_key layered on a
10
+ # `t.references ..., foreign_key: true` that already made one. Each copy is
11
+ # checked on every insert/update of the child and every delete of the
12
+ # parent, so the extra one is pure overhead.
13
+ class DuplicateForeignKey < Base
14
+ Registry.register(self)
15
+
16
+ def call
17
+ schema.tables.reject { |t| config.ignore_tables.include?(t.name) }
18
+ .flat_map { |t| duplicates_in(t) }
19
+ end
20
+
21
+ private
22
+
23
+ def duplicates_in(table)
24
+ table.foreign_key_constraints
25
+ .group_by { |fk| [fk.columns, fk.references, fk.behaviour] }
26
+ .values.select { |group| group.size > 1 }
27
+ .flat_map { |group| redundant(table, group) }
28
+ end
29
+
30
+ # Keep a validated one (a NOT VALID copy doesn't check existing rows),
31
+ # then the alphabetically first name -- Rails' fk_rails_<hash> sorts
32
+ # ahead of hand-named ones. Report the rest.
33
+ def redundant(table, group)
34
+ keep, *extra = group.sort_by { |fk| [fk.validated == false ? 1 : 0, fk.name] }
35
+ extra.map do |fk|
36
+ finding(
37
+ type: :duplicate_foreign_key,
38
+ table: table.name,
39
+ column: fk.columns.join(","),
40
+ index: fk.name,
41
+ severity: :low,
42
+ confidence: 0.9,
43
+ bytes_per_row: 0,
44
+ evidence: [
45
+ "#{fk.name} and #{keep.name} both constrain (#{fk.columns.join(", ")}) -> #{fk.references}"
46
+ ],
47
+ suggested_fix: "remove_foreign_key #{sym(table.name)}, name: :#{fk.name}"
48
+ )
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -40,7 +40,7 @@ module SchemaReaper
40
40
  confidence: 0.8,
41
41
  bytes_per_row: 0,
42
42
  evidence: [evidence_for(index, covering)],
43
- suggested_fix: "remove_index :#{table.name}, name: :#{index.name}"
43
+ suggested_fix: remove_index_fix(table, index)
44
44
  )
45
45
  end
46
46
 
@@ -32,6 +32,8 @@ module SchemaReaper
32
32
 
33
33
  def missing_in(table)
34
34
  fk_columns(table).filter_map do |col|
35
+ next if ignored_column?(col)
36
+
35
37
  type_col = polymorphic_type_for(table, col)
36
38
 
37
39
  # A bare index on the id alone doesn't cover a polymorphic pair --
@@ -56,7 +58,7 @@ module SchemaReaper
56
58
  end
57
59
 
58
60
  def finding_for(table, col, type_col, declared:)
59
- referent = declared ? nil : referent_table(col)
61
+ referent = declared ? nil : referent_table(col, table.name)
60
62
  confidence = confidence_for(declared, referent)
61
63
 
62
64
  finding(
@@ -97,9 +99,9 @@ module SchemaReaper
97
99
  end
98
100
 
99
101
  def fix_for(table, col, type_col)
100
- return "add_index :#{table.name}, :#{col}" unless type_col
102
+ return "add_index #{sym(table.name)}, :#{col}" unless type_col
101
103
 
102
- "add_index :#{table.name}, %i[#{type_col} #{col}]"
104
+ "add_index #{sym(table.name)}, %i[#{type_col} #{col}]"
103
105
  end
104
106
 
105
107
  def fk_columns(table)
@@ -109,7 +111,9 @@ module SchemaReaper
109
111
  # A table the column plausibly points at: account_id -> accounts,
110
112
  # company_id -> companies, address_id -> addresses. Absence is the signal
111
113
  # that a *_id name is not really a foreign key.
112
- def referent_table(col)
114
+ # A table outside public looks in its own schema first:
115
+ # billing.invoices.account_id -> billing.accounts, then accounts.
116
+ def referent_table(col, from_table)
113
117
  return nil unless col.end_with?("_id")
114
118
 
115
119
  base = col[0..-4]
@@ -117,6 +121,8 @@ module SchemaReaper
117
121
 
118
122
  candidates = [base, "#{base}s", "#{base}es"]
119
123
  candidates << "#{base[0..-2]}ies" if base.end_with?("y")
124
+ ns = from_table.include?(".") ? from_table.split(".").first : nil
125
+ candidates = candidates.map { |c| "#{ns}.#{c}" } + candidates if ns
120
126
  candidates.find { |name| table_names.include?(name) }
121
127
  end
122
128
 
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaReaper
4
+ module Analyzers
5
+ # A serial / IDENTITY sequence that has used up a large share of the values
6
+ # its column can hold. When it runs out, every INSERT into the table fails.
7
+ # The classic case is a pre-Rails-5.1 `integer` primary key, capped at
8
+ # 2,147,483,647 even when its sequence is bigint.
9
+ #
10
+ # Not dead weight -- a health check, like missing_fk_index -- but it is
11
+ # read straight from the schema scan the other analyzers already need.
12
+ class SequenceExhaustion < Base
13
+ Registry.register(self)
14
+
15
+ WARN_AT = 0.5
16
+ HIGH_AT = 0.75
17
+
18
+ def call
19
+ schema.sequences.filter_map do |seq|
20
+ next if config.ignore_tables.include?(seq.table)
21
+ next if seq.used_fraction < WARN_AT
22
+
23
+ finding_for(seq)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def finding_for(seq)
30
+ pct = (seq.used_fraction * 100).round(1)
31
+ finding(
32
+ type: :sequence_exhaustion,
33
+ table: seq.table,
34
+ column: seq.column,
35
+ severity: seq.used_fraction >= HIGH_AT ? :high : :medium,
36
+ confidence: 0.95, # read from the database, not inferred
37
+ bytes_per_row: 0,
38
+ evidence: [
39
+ "#{seq.name} is at #{seq.last_value} of #{seq.max_value} (#{pct}%) " \
40
+ "for #{seq.column_type} column #{seq.column}",
41
+ "INSERTs into #{seq.table} fail once it is exhausted"
42
+ ],
43
+ suggested_fix: fix_for(seq)
44
+ )
45
+ end
46
+
47
+ def fix_for(seq)
48
+ return "the sequence itself is near its MAXVALUE -- raise it with ALTER SEQUENCE" if seq.column_type == "bigint"
49
+
50
+ "change_column #{sym(seq.table)}, :#{seq.column}, :bigint " \
51
+ "(rewrites the table under an exclusive lock -- plan for it on a large table)"
52
+ end
53
+ end
54
+ end
55
+ end
@@ -24,6 +24,7 @@ module SchemaReaper
24
24
  next unless col.single_value?
25
25
  next if table.primary_key?(col.name)
26
26
  next if config.always_keep_columns.include?(col.name)
27
+ next if ignored_column?(col.name)
27
28
  next if gem_reserved?(table.name, col.name)
28
29
 
29
30
  finding(
@@ -31,16 +32,27 @@ module SchemaReaper
31
32
  table: table.name,
32
33
  column: col.name,
33
34
  severity: :low,
34
- confidence: 0.6,
35
+ confidence: sampled_stats?(table) ? 0.5 : 0.6,
35
36
  bytes_per_row: col.bytes,
36
- evidence: [
37
- "pg_stats.n_distinct = 1 across ~#{table.row_count} row(s)",
38
- "column has the same value in every row"
39
- ],
37
+ evidence: evidence_for(table, col),
40
38
  suggested_fix: "confirm the value is not a meaningful default before removing"
41
39
  )
42
40
  end
43
41
  end
42
+
43
+ # n_distinct counts non-null values only, so "one value" and "the same
44
+ # value in every row" are different claims when some rows are NULL.
45
+ def evidence_for(table, col)
46
+ nulls = col.null_fraction.to_f
47
+ ev = ["pg_stats.n_distinct = 1 across ~#{table.row_count} row(s)"]
48
+ ev << if nulls.positive?
49
+ "one distinct non-null value; #{(nulls * 100).round(1)}% of rows are NULL"
50
+ else
51
+ "column has the same value in every row"
52
+ end
53
+ ev << "estimated from a #{STATS_SAMPLE_ROWS}-row ANALYZE sample" if sampled_stats?(table)
54
+ ev
55
+ end
44
56
  end
45
57
  end
46
58
  end
@@ -34,15 +34,20 @@ module SchemaReaper
34
34
  severity: :medium,
35
35
  confidence: 0.55,
36
36
  bytes_per_row: 0,
37
- evidence: [
38
- "pg_stat_user_indexes.idx_scan = 0 for #{ix.name} (#{ix.columns.join(", ")})",
39
- "confirm stats have not been reset recently before dropping"
40
- ],
41
- suggested_fix: "remove_index :#{table.name}, name: :#{ix.name} " \
42
- "(use algorithm: :concurrently in production)"
37
+ evidence: evidence_for(ix),
38
+ suggested_fix: remove_index_fix(table, ix)
43
39
  )
44
40
  end
45
41
  end
42
+
43
+ def evidence_for(index)
44
+ [
45
+ "pg_stat_user_indexes.idx_scan = 0 for #{index.name} (#{index.columns.join(", ")})",
46
+ "confirm stats have not been reset recently before dropping",
47
+ "idx_scan is per server: if read replicas serve queries, check " \
48
+ "pg_stat_user_indexes on each replica too -- an index used only there looks unused here"
49
+ ]
50
+ end
46
51
  end
47
52
  end
48
53
  end