Class: Raif::ArchiveJob
- Inherits:
-
ApplicationJob
- Object
- ApplicationJob
- ApplicationJob
- Raif::ArchiveJob
- Defined in:
- app/jobs/raif/archive_job.rb
Overview
Abstract base for the archive-and-cull jobs. Subclasses (see Raif::ArchiveModelCompletionsJob, Raif::ArchiveTasksJob) name the resource and its eligibility rules; everything below - batching, partition fairness, upload, the tainted-partition abort and the cull transaction - is shared, so the safety invariants have exactly one implementation. Enabling, scheduling, storage requirements and partitioning are documented for operators at https://docs.raif.ai/learn_more/archiving.
The invariants this job exists to hold:
- At-least-once archiving + never-delete-unarchived. A batch is deleted only after this run uploaded it, and a re-run after any crash writes a new object under a new key rather than resuming or overwriting. There is no persisted in-flight state to repair, ever.
- The accepted cost of that is duplicate objects: a crash between the upload and the Raif::Archive insert leaves an object that no row references (those rows were not deleted, so they re-archive next run). Storage policy must therefore apply to whole prefixes and never be derived from raif_archives rows, which is also what makes Raif::Archive.purge_partition! complete.
- Inert unless the host opts in: archive_enabled, an archive_storage adapter and a retention period must all be set, or perform returns without touching anything.
- With partitioning, every object holds records from exactly one partition (see Raif::ArchivePartition). Per-partition erasure rests on that, so a record whose partition changes mid-upload taints the object and aborts the cull.
Subclasses implement the "Subclass contract" class methods below and the stamp_cost_events! hook; the dependent-resource hooks are optional.
Direct Known Subclasses
Constant Summary collapse
- BATCH_RECORD_LIMIT =
Caps are constants, not config: public config is API; add a knob only when a host demonstrably needs it. Read through self, so a subclass can override one. A batch closes when EITHER cap is hit; the byte cap keeps wildly varying payloads (agent message arrays can be enormous) from building a multi-GB tempfile.
25_000- BATCH_UNCOMPRESSED_BYTE_LIMIT =
512.megabytes
- QUIESCENCE_PERIOD =
Rows updated more recently than this are ineligible for archiving, regardless of age.
24.hours
- PARTITIONS_PER_PASS =
Distinct partitions fetched per round-robin pass. The listing query is a GROUP BY over the full multi-guard eligibility scope, the one potentially expensive query in the fairness design, so it is capped.
100
Class Method Summary collapse
-
.archived_class ⇒ Object
The archived ActiveRecord class.
- .base_scope(cutoff) ⇒ Object
-
.dry_run(cutoff: retention_period&.ago) ⇒ Object
Counts what a run under the given cutoff would archive (split by terminal state), plus the subclass's per-guard exclusions among cutoff-aged records.
-
.dry_run_exclusions(_cutoff) ⇒ Object
Per-guard exclusion counts for dry_run, as a Hash.
-
.eligible_scope(_cutoff) ⇒ Object
Every guard that makes a record safe to archive AND delete.
-
.key_prefix ⇒ Object
Key prefix when partitioning is unset.
- .partition_column ⇒ Object
- .quiescent_scope(cutoff) ⇒ Object
-
.resource_key_segment ⇒ Object
Resource segment below a partition prefix: raif-archives/partitions/
/ / - .resource_table ⇒ Object
-
.retention_config_name ⇒ Object
The Raif.config accessor behind retention_period, for error messages.
-
.retention_period ⇒ Object
The configured retention duration, or nil to keep this resource forever.
- .terminal_scope(cutoff) ⇒ Object
- .ungrouped_fallback? ⇒ Boolean
-
.validate_partition_column! ⇒ Object
Column existence is validated here, at job/dry_run execution time, never at boot: Raif::Configuration#validate! is deliberately DB-free so blank-database boots (db:create, db:migrate, asset precompile) keep working.
Instance Method Summary collapse
Class Method Details
.archived_class ⇒ Object
The archived ActiveRecord class. Must expose created_at, updated_at, completed_at and failed_at: the terminal/nonterminal split runs in shared code.
62 63 64 |
# File 'app/jobs/raif/archive_job.rb', line 62 def archived_class raise NotImplementedError, "#{name} must implement .archived_class" end |
.base_scope(cutoff) ⇒ Object
149 150 151 |
# File 'app/jobs/raif/archive_job.rb', line 149 def base_scope(cutoff) archived_class.where(resource_table[:created_at].lt(cutoff)) end |
.dry_run(cutoff: retention_period&.ago) ⇒ Object
Counts what a run under the given cutoff would archive (split by terminal state), plus the subclass's per-guard exclusions among cutoff-aged records. Writes nothing; re-runnable anytime. Operators run this before enabling archiving (it defaults to the configured retention period so it can be previewed while archive_enabled is still false).
108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 |
# File 'app/jobs/raif/archive_job.rb', line 108 def dry_run(cutoff: retention_period&.ago) raise ArgumentError, "Provide a cutoff: or set Raif.config.#{retention_config_name}" if cutoff.nil? validate_partition_column! eligible = eligible_scope(cutoff) result = { cutoff: cutoff, eligible: eligible.count, eligible_terminal: eligible.where("completed_at IS NOT NULL OR failed_at IS NOT NULL").count, eligible_nonterminal: eligible.where(completed_at: nil, failed_at: nil).count, excluded_by_quiescence: base_scope(cutoff).where(resource_table[:updated_at].gteq(self::QUIESCENCE_PERIOD.ago)).count }.merge(dry_run_exclusions(cutoff)) add_partition_report!(result, eligible) result end |
.dry_run_exclusions(_cutoff) ⇒ Object
Per-guard exclusion counts for dry_run, as a Hash. Optional.
96 97 98 |
# File 'app/jobs/raif/archive_job.rb', line 96 def dry_run_exclusions(_cutoff) {} end |
.eligible_scope(_cutoff) ⇒ Object
Every guard that makes a record safe to archive AND delete. Called on every pass, and again under row locks inside the cull transaction, so it must be a pure relation with no side effects.
91 92 93 |
# File 'app/jobs/raif/archive_job.rb', line 91 def eligible_scope(_cutoff) raise NotImplementedError, "#{name} must implement .eligible_scope" end |
.key_prefix ⇒ Object
Key prefix when partitioning is unset.
78 79 80 |
# File 'app/jobs/raif/archive_job.rb', line 78 def key_prefix raise NotImplementedError, "#{name} must implement .key_prefix" end |
.partition_column ⇒ Object
127 128 129 |
# File 'app/jobs/raif/archive_job.rb', line 127 def partition_column Raif.config.archive_partition_column end |
.quiescent_scope(cutoff) ⇒ Object
157 158 159 |
# File 'app/jobs/raif/archive_job.rb', line 157 def quiescent_scope(cutoff) base_scope(cutoff).where(resource_table[:updated_at].lt(self::QUIESCENCE_PERIOD.ago)) end |
.resource_key_segment ⇒ Object
Resource segment below a partition prefix:
raif-archives/partitions/
84 85 86 |
# File 'app/jobs/raif/archive_job.rb', line 84 def resource_key_segment raise NotImplementedError, "#{name} must implement .resource_key_segment" end |
.resource_table ⇒ Object
161 162 163 |
# File 'app/jobs/raif/archive_job.rb', line 161 def resource_table archived_class.arel_table end |
.retention_config_name ⇒ Object
The Raif.config accessor behind retention_period, for error messages.
73 74 75 |
# File 'app/jobs/raif/archive_job.rb', line 73 def retention_config_name raise NotImplementedError, "#{name} must implement .retention_config_name" end |
.retention_period ⇒ Object
The configured retention duration, or nil to keep this resource forever. Read fresh on every call: hosts reconfigure at boot.
68 69 70 |
# File 'app/jobs/raif/archive_job.rb', line 68 def retention_period raise NotImplementedError, "#{name} must implement .retention_period" end |
.terminal_scope(cutoff) ⇒ Object
153 154 155 |
# File 'app/jobs/raif/archive_job.rb', line 153 def terminal_scope(cutoff) base_scope(cutoff).where("completed_at IS NOT NULL OR failed_at IS NOT NULL") end |
.ungrouped_fallback? ⇒ Boolean
131 132 133 |
# File 'app/jobs/raif/archive_job.rb', line 131 def ungrouped_fallback? Raif.config.archive_partition_fallback.equal?(Raif::ArchivePartition::UNGROUPED) end |
.validate_partition_column! ⇒ Object
Column existence is validated here, at job/dry_run execution time, never at boot: Raif::Configuration#validate! is deliberately DB-free so blank-database boots (db:create, db:migrate, asset precompile) keep working. Each archived resource's job validates the column on its own resource.
140 141 142 143 144 145 146 147 |
# File 'app/jobs/raif/archive_job.rb', line 140 def validate_partition_column! return if partition_column.nil? return if archived_class.column_names.include?(partition_column.to_s) raise Raif::Errors::InvalidConfigError, "Raif.config.archive_partition_column is :#{partition_column}, but #{archived_class.table_name} " \ "has no #{partition_column} column" end |
Instance Method Details
#perform(max_records: 100_000, max_objects: 500, max_runtime: 30.minutes) ⇒ Object
206 207 208 209 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 246 247 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 'app/jobs/raif/archive_job.rb', line 206 def perform(max_records: 100_000, max_objects: 500, max_runtime: 30.minutes) return unless Raif.config.archive_enabled return if Raif.config.archive_storage.nil? retention_period = self.class.retention_period return if retention_period.nil? # Defense in depth: Raif::Configuration#validate! enforces this floor # at boot, but a destructive job must not trust that validation ran # (initializers can be skipped or misordered on a misconfigured node). # Cost/budget consumers aggregate by billing period, so a tiny # retention value must never be able to cull inside an open window. if retention_period < 1.month raise Raif::Errors::InvalidConfigError, "Raif.config.#{self.class.retention_config_name} must be at least 1 month (got #{retention_period.inspect})" end self.class.validate_partition_column! # Shared with Raif::Archive.purge_partition! and with every other # archive job; a second concurrent perform (or a purge in progress) # makes this run return immediately. Raif::ArchiveAdvisoryLock.acquire do # Frozen at job start so every batch in this run shares one cutoff. cutoff = retention_period.ago deadline = monotonic_now + max_runtime.to_f records_remaining = max_records objects_remaining = max_objects # Round-robin passes: at most one object per partition per pass, so # many small partitions all drain within a run while one # large-backlog partition cannot monopolize it. Each pass excludes # the partitions already visited this round, paging through EVERY # eligible partition before any is revisited (the listing cap alone # would relist the same oldest cohort while it still holds the # oldest rows, starving partitions beyond the cap). A pass that # lists nothing ends the round; a new round starts only if the # finished round culled something. With partitioning unset there is # exactly one pseudo-partition and this reduces to sequential # batching. The runtime budget is only checked between objects: a # started object always finishes, so a run always stops in a safe # state. visited = [] round_culled = false loop do selections = partition_selections(cutoff, visited) if selections.empty? break unless round_culled visited = [] round_culled = false next end selections.each do |partition, predicate, raws| break if records_remaining <= 0 || objects_remaining <= 0 || monotonic_now >= deadline # Marked visited even when nothing archives, or an emptied # partition would relist in every pass for the rest of the round. visited.concat(raws) ids = eligible_batch_ids(cutoff, partition, predicate, limit: [self.class::BATCH_RECORD_LIMIT, records_remaining].min) next if ids.empty? outcome = archive_batch!(ids, cutoff, partition: partition, partition_predicate: predicate) next if outcome.nil? records_remaining -= outcome[:records] objects_remaining -= 1 # A tainted batch (partition mutated during upload, object # cleaned up) spends budget but is not progress; the round # moves on to other partitions. round_culled ||= outcome[:culled] end break if records_remaining <= 0 || objects_remaining <= 0 || monotonic_now >= deadline end end end |