Class: EducationForm::CreateDailySpoolFiles

Inherits:
Object
  • Object
show all
Includes:
SentryLogging, Sidekiq::Job
Defined in:
app/sidekiq/education_form/create_daily_spool_files.rb

Constant Summary collapse

MAX_RETRIES =
5
WINDOWS_NOTEPAD_LINEBREAK =
"\r\n"
STATSD_KEY =
'worker.education_benefits_claim'
STATSD_FAILURE_METRIC =
"#{STATSD_KEY}.failed_spool_file".freeze
LIVE_FORM_TYPES =
%w[1990 1995 1990e 5490 1990n 5495 0993 0994 10203 1990S].map { |t| "22-#{t.upcase}" }.freeze
AUTOMATED_DECISIONS_STATES =
[nil, 'denied', 'processed'].freeze

Instance Method Summary collapse

Methods included from SentryLogging

#log_exception_to_sentry, #log_message_to_sentry, #non_nil_hash?, #normalize_level, #rails_logger, #set_sentry_metadata

Instance Method Details

#email_staging_spool_files(contents) ⇒ Object (private)



234
235
236
# File 'app/sidekiq/education_form/create_daily_spool_files.rb', line 234

def email_staging_spool_files(contents)
  CreateStagingSpoolFilesMailer.build(contents).deliver_now
end

#federal_holiday?Boolean (private)

Returns:

  • (Boolean)


176
177
178
179
180
181
182
183
184
# File 'app/sidekiq/education_form/create_daily_spool_files.rb', line 176

def federal_holiday?
  holiday = Holidays.on(Time.zone.today, :us, :observed)
  if holiday.empty?
    false
  else
    log_info("Skipping on a Holiday: #{holiday.first[:name]}")
    true
  end
end

#format_application(data, rpo: 0) ⇒ Object

Previously there was data.saved_claim.valid? check but this was causing issues for forms when

  1. on submission the submission data is validated against vets-json-schema

  2. vets-json-schema is updated in vets-api

  3. during spool file creation the schema is then again validated against vets-json-schema

  4. submission is no longer valid due to changes in step 2



155
156
157
158
159
160
161
162
# File 'app/sidekiq/education_form/create_daily_spool_files.rb', line 155

def format_application(data, rpo: 0)
  form = EducationForm::Forms::Base.build(data)
  track_form_type("22-#{data.form_type}", rpo)
  form
rescue => e
  inform_on_error(data, rpo, e)
  nil
end

#format_records(grouped_data) ⇒ Object

Convert the records into instances of their form representation. The conversion into ‘spool file format’ takes place here, rather than when we’re writing the files so we can hold the connection open for a shorter period of time.



80
81
82
83
84
85
86
87
88
89
# File 'app/sidekiq/education_form/create_daily_spool_files.rb', line 80

def format_records(grouped_data)
  raw_groups = grouped_data.each do |region, v|
    region_id = EducationFacility.facility_for(region:)
    grouped_data[region] = v.map do |record|
      format_application(record, rpo: region_id)
    end.compact
  end
  # delete any regions that only had malformed claims before returning
  raw_groups.delete_if { |_, v| v.empty? }
end

#group_submissions_by_region(records) ⇒ Object



72
73
74
# File 'app/sidekiq/education_form/create_daily_spool_files.rb', line 72

def group_submissions_by_region(records)
  records.group_by { |r| r.regional_processing_office.to_sym }
end

#inform_on_error(claim, region, error = nil) ⇒ Object



164
165
166
167
168
169
170
171
172
# File 'app/sidekiq/education_form/create_daily_spool_files.rb', line 164

def inform_on_error(claim, region, error = nil)
  StatsD.increment("#{STATSD_KEY}.failed_formatting.#{region}.22-#{claim.form_type}")
  exception = if error.present?
                FormattingError.new("Could not format #{claim.confirmation_number}.\n\n#{error}")
              else
                FormattingError.new("Could not format #{claim.confirmation_number}")
              end
  log_exception(exception, nil, send_email: false)
end

#local_or_staging_env?Boolean (private)

Returns:

  • (Boolean)


238
239
240
# File 'app/sidekiq/education_form/create_daily_spool_files.rb', line 238

def local_or_staging_env?
  Rails.env.eql?('development') || Settings.hostname.eql?('staging-api.va.gov')
end

#log_exception(exception, region = nil, send_email: true) ⇒ Object (private)



208
209
210
211
212
# File 'app/sidekiq/education_form/create_daily_spool_files.rb', line 208

def log_exception(exception, region = nil, send_email: true)
  log_exception_to_sentry(exception)
  log_to_slack(exception.to_s)
  log_to_email(region) if send_email
end

#log_info(message) ⇒ Object (private)



214
215
216
217
# File 'app/sidekiq/education_form/create_daily_spool_files.rb', line 214

def log_info(message)
  logger.info(message)
  log_to_slack(message)
end

#log_submissions(records, filename, region) ⇒ Object (private)

Useful for debugging which records were or were not sent over successfully, in case of network failures.



188
189
190
# File 'app/sidekiq/education_form/create_daily_spool_files.rb', line 188

def log_submissions(records, filename, region)
  log_info("Writing #{records.count} application(s) to #{filename} for region: #{region}")
end

#log_to_email(region) ⇒ Object (private)



228
229
230
231
232
# File 'app/sidekiq/education_form/create_daily_spool_files.rb', line 228

def log_to_email(region)
  return unless Flipper.enabled?(:spool_testing_error_3) && !FeatureFlipper.staging_email?

  CreateDailySpoolFilesMailer.build(region).deliver_now
end

#log_to_slack(message) ⇒ Object (private)



219
220
221
222
223
224
225
226
# File 'app/sidekiq/education_form/create_daily_spool_files.rb', line 219

def log_to_slack(message)
  return unless Flipper.enabled?(:spool_testing_error_2)

  client = SlackNotify::Client.new(webhook_url: Settings.edu.slack.webhook_url,
                                   channel: '#vsa-education-logs',
                                   username: "#{self.class.name} - #{Settings.vsp_environment}")
  client.notify(message)
end

#performObject

Setting the default value to the ‘unprocessed` scope is safe because the execution of the query itself is deferred until the data is accessed by the code inside of the method.



30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# File 'app/sidekiq/education_form/create_daily_spool_files.rb', line 30

def perform
  retry_count = 0

  begin
    records = EducationBenefitsClaim
              .unprocessed.joins(:saved_claim).includes(:education_stem_automated_decision).where(
                saved_claims: {
                  form_id: LIVE_FORM_TYPES
                },
                education_stem_automated_decisions: { automated_decision_state: AUTOMATED_DECISIONS_STATES }
              )
    return false if federal_holiday?

    if records.count.zero?
      log_info('No records to process.')
      return true
    elsif retry_count.zero?
      log_info("Processing #{records.count} application(s)")
    end

    # Group the formatted records into different regions
    regional_data = group_submissions_by_region(records)
    formatted_records = format_records(regional_data)
    # Create a remote file for each region, and write the records into them
    writer = SFTPWriter::Factory.get_writer(Settings.edu.sftp).new(Settings.edu.sftp, logger:)
    write_files(writer, structured_data: formatted_records)
  rescue => e
    StatsD.increment("#{STATSD_FAILURE_METRIC}.general")
    if retry_count < MAX_RETRIES
      log_exception(DailySpoolFileError.new("Error creating spool files.\n\n#{e}
                                             Retry count: #{retry_count}. Retrying..... "))
      retry_count += 1
      sleep(10 * retry_count) # exponential backoff for retries
      retry
    else
      log_exception(DailySpoolFileError.new("Error creating spool files.
                                             Job failed after #{MAX_RETRIES} retries \n\n#{e}"))
    end
  end
  true
end

#statsObject (private)



204
205
206
# File 'app/sidekiq/education_form/create_daily_spool_files.rb', line 204

def stats
  @stats ||= Hash.new(Hash.new(0))
end

#track_form_type(type, rpo) ⇒ Object (private)



200
201
202
# File 'app/sidekiq/education_form/create_daily_spool_files.rb', line 200

def track_form_type(type, rpo)
  stats[rpo][type] += 1
end

#track_submissions(region_id) ⇒ Object (private)

Useful for alerting and monitoring the numbers of successfully send submissions per-rpo, rather than the number of records that were prepared to be sent.



194
195
196
197
198
# File 'app/sidekiq/education_form/create_daily_spool_files.rb', line 194

def track_submissions(region_id)
  stats[region_id].each do |type, count|
    StatsD.gauge("#{STATSD_KEY}.transmissions.#{region_id}.#{type}", count)
  end
end

#write_files(writer, structured_data:) ⇒ Object

Write out the combined spool files for each region along with recording and tracking successful transfers. Creates or updates an SpoolFileEvent for tracking and to prevent multiple files per RPO per date during retries



94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'app/sidekiq/education_form/create_daily_spool_files.rb', line 94

def write_files(writer, structured_data:)
  structured_data.each do |region, records|
    region_id = EducationFacility.facility_for(region:)
    filename = "#{region_id}_#{Time.zone.now.strftime('%m%d%Y_%H%M%S')}_vetsgov.spl"
    spool_file_event = SpoolFileEvent.build_event(region_id, filename)

    if spool_file_event.successful_at.present?
      log_info("A spool file for #{region_id} on #{Time.zone.now.strftime('%m%d%Y')} was already created")
    else
      log_submissions(records, filename, region)
      # create the single textual spool file
      contents = records.map(&:text).join(EducationForm::CreateDailySpoolFiles::WINDOWS_NOTEPAD_LINEBREAK)

      begin
        writer.write(contents, filename)

        ## Testing to see if writer is the cause for retry attempt failures
        ## If we get to this message, it's not the writer object
        log_info("Successfully wrote #{records.count} applications to filename: #{filename} for region: #{region}")

        # send copy of staging spool files to testers
        # This mailer is intended to only work for development, staging and NOT production
        # Rails.env will return 'production' on the development & staging servers and  which
        # will trip the unwary. To be safe, use Settings.hostname
        email_staging_spool_files(contents) if local_or_staging_env?

        # track and update the records as processed once the file has been successfully written
        track_submissions(region_id)

        records.each { |r| r.record.update(processed_at: Time.zone.now) }
        spool_file_event.update(number_of_submissions: records.count, successful_at: Time.zone.now)
      rescue => e
        StatsD.increment("#{STATSD_FAILURE_METRIC}.#{region_id}")
        attempt_msg = if spool_file_event.retry_attempt.zero?
                        'initial attempt'
                      else
                        "attempt #{spool_file_event.retry_attempt}"
                      end
        exception = DailySpoolFileError.new("Error creating #{filename} during #{attempt_msg}.\n\n#{e}")
        log_exception(exception, region)
        if spool_file_event.retry_attempt < MAX_RETRIES
          spool_file_event.update(retry_attempt: spool_file_event.retry_attempt + 1)

          # Reinstantiate the writer before retrying
          writer = SFTPWriter::Factory.get_writer(Settings.edu.sftp).new(Settings.edu.sftp, logger:)
          retry
        else
          next
        end
      end
    end
  end
ensure
  writer.close
end