Class: ScoutAgent::Database::MissionLog

Inherits:
ScoutAgent::Database show all
Defined in:
lib/scout_agent/database/mission_log.rb

Overview

This database encapsulates the main function of the Scout agent: running missions. Details of the current plan and the missions of that plan are stored in these tables. As missions are executed, they build up reports which are also held here until they can be pushed to the Scout server.

Constant Summary collapse

DEFAULT_TIMEOUT =

A default number of seconds a mission is allowed to run before it is halted.

60
DEFAULT_INTERVAL =

The default number of minutes before a mission is run again.

3
REPORTS_LIMIT =

A size limit for the reports table to prevent data from building up.

3000

Instance Attribute Summary

Attributes inherited from ScoutAgent::Database

#log

Instance Method Summary collapse

Methods inherited from ScoutAgent::Database

#initialize, load, #locked?, #maintain, #migrate, #path, path, #prepare_connection, #query, #read_from_sqlite, #read_locked?, #schema_version, #write_locked?, #write_to_sqlite

Constructor Details

This class inherits a constructor from ScoutAgent::Database

Instance Method Details

#complete_mission(mission) ⇒ Object

Marks mission as complete in the database by recording its last_run_at Time and setting a next_run_at Time.



216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/scout_agent/database/mission_log.rb', line 216

def complete_mission(mission)
  write_to_sqlite do |sqlite|
    run_time = Time.now
    params   = [ run_time.to_db_s,
                 ( run_time +
                   mission[:interval] * 60 ).to_db_s(:trim_seconds),
                 mission[:id] ]
    sqlite.execute(<<-END_UPDATE_MISSION.trim, *params)
    UPDATE missions SET last_run_at = ?, next_run_at = ? WHERE id = ?
    END_UPDATE_MISSION
  end
  true  # it's safe to continue
rescue Amalgalite::SQLite3::Error => error  # failed to update mission
  log.error("Database complete mission error:  #{error.message}.")
  false  # warn the caller that the mission will still match
end

#current_missionObject

Returns the current mission (id, timeout, interval, last_run_at, name, code, options, and memory) that should be run. The options and memory fields are JSON parsed when possible. If there are no missions scheduled to run at this time, nil is returned.



165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
# File 'lib/scout_agent/database/mission_log.rb', line 165

def current_mission
  mission = read_from_sqlite { |sqlite|
    return nil unless plan = current_plan
    params = [plan[:id], Time.now.to_db_s]
    sqlite.first_row_from(<<-END_FIND_MISSION.trim, *params)
    SELECT   id, timeout, interval, last_run_at, name,
             code, options, memory
    FROM     missions
    WHERE    plan_id = ? AND next_run_at <= ?
    ORDER BY ROWID
    LIMIT    1
    END_FIND_MISSION
  }
  if mission.empty?
    nil  # not found
  else
    mission[:last_run_at] = Time.from_db_s(mission[:last_run_at])
    %w[options memory].each do |serialized|
      begin
        mission[serialized] = JSON.parse(mission[serialized].to_s)
      rescue JSON::ParserError
        log.warn("Mission #{serialized} could not be parsed.")
        mission[serialized] = { }
      end
    end
    mission
  end
rescue Amalgalite::SQLite3::Error => error  # failed to retrieve mission
  log.error("Database current mission error:  #{error.message}.")
  nil  # not found
end

#current_planObject

Returns the last known plan (id and last_modified date) or nil if none exists.



71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/scout_agent/database/mission_log.rb', line 71

def current_plan
  plan = read_from_sqlite { |sqlite|
    sqlite.first_row_from(<<-END_FIND_PLAN.trim)
    SELECT   ROWID AS id, last_modified
    FROM     plans
    ORDER BY ROWID DESC
    LIMIT    1
    END_FIND_PLAN
  }
  plan.empty? ? nil : plan
rescue Amalgalite::SQLite3::Error => error  # failed to retrieve plan
  log.error("Database current plan error:  #{error.message}.")
  nil  # not found
end

#current_reportsObject

This method returns an Array of all reports (type, fields, created_at, and plugin_id) that should be submitted to the Scout server. The report fields will be JSON parsed when possible and created_at is converted to a proper Time object.

The act of reading these reports also triggers their removal from the database so we avoid sending duplicates to the server. This does mean that we lose data if anything goes wrong in the sending process. This is considered an acceptable risk, because even a delete-after-a-successful-send stragety is subject to duplication (the request might timeout but eventually complete on the server, for example). If anything goes wrong with the reading or deletion, the entire process is canceled and an empty Array is returned.



311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
# File 'lib/scout_agent/database/mission_log.rb', line 311

def current_reports
  write_to_sqlite { |sqlite|
    # read the current reports
    begin
      report_ids = Array.new
      reports    = query(<<-END_FIND_REPORTS.trim) { |row|
      SELECT     reports.ROWID AS id, reports.type, reports.fields,
                 reports.created_at, missions.id AS plugin_id
      FROM       reports
      INNER JOIN missions ON reports.mission_id = missions.id
      ORDER BY   created_at
      LIMIT      500
      END_FIND_REPORTS
        begin
          row[:fields] = JSON.parse(row[:fields].to_s)
        rescue JSON::ParserError
          # skip the transform since we can't parse it
          log.warn("Report fields malformed.")
        end
        if created = Time.from_db_s(row[:created_at])
          row[:created_at] = created.utc.to_db_s
        else
          log.warn("Report timestamp missing.")
        end
        report_ids << row.delete_at(:id)
      }
    rescue Amalgalite::SQLite3::Error => error  # failed to find reports
      log.error("Database reports error:  #{error.message}.")
      return Array.new  # return empty results
    end
    return reports if reports.empty?
    # delete the reports we read
    begin
        sqlite.execute(<<-END_DELETE_REPORTS.trim, *report_ids)
        DELETE FROM reports
        WHERE       ROWID IN (#{(['?'] * report_ids.size).join(', ')})
        END_DELETE_REPORTS
    rescue Amalgalite::SQLite3::Error => error  # failed to remove reports
      # cancel sending this batch
      log.error("Database delivered reports error:  #{error.message}.")
      sqlite.rollback   # we can't submit unless we're sure they are gone
      return Array.new  # return empty results
    end
    reports  # the reports ready for sending
  }
rescue Amalgalite::SQLite3::Error => error  # failed to get a write lock
  # try again to read reports later
  log.error("Database reports locking error:  #{error.message}.")
end

#reset_missions(*ids) ⇒ Object

All passed mission ids are reset so they will be run again at the first available opportunity.



237
238
239
240
241
242
243
244
245
246
247
248
249
# File 'lib/scout_agent/database/mission_log.rb', line 237

def reset_missions(*ids)
  return if ids.empty?
  write_to_sqlite do |sqlite|
    sqlite.execute(<<-END_RESET_MISSIONS.trim, *ids)
    UPDATE missions
    SET    next_run_at = strftime('%Y-%m-%d %H:%M', 'now', 'localtime')
    WHERE  id IN (#{(['?'] * ids.size).join(', ')})
    END_RESET_MISSIONS
  end
rescue Amalgalite::SQLite3::Error => error  # failed to reset missions
  # do nothing:  missions will be run at their scheduled time
  log.error("Database mission reset error:  #{error.message}.")
end

#seconds_to_next_missionObject

Returns the number of seconds until another mission will be ready for running. If the count would be zero or less seconds, the DEFAULT_INTERVAL is returned (in seconds) to prevent the agent from entering a busy loop.



257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
# File 'lib/scout_agent/database/mission_log.rb', line 257

def seconds_to_next_mission
  default     = DEFAULT_INTERVAL * 60
  next_run_at = read_from_sqlite { |sqlite|
    sqlite.first_value_from(<<-END_FIND_MISSION.trim)
    SELECT next_run_at FROM missions ORDER BY next_run_at LIMIT 1
    END_FIND_MISSION
  }
  if next_run = Time.from_db_s(next_run_at)
    seconds = next_run - Time.now
    seconds > 0 ? seconds : default
  else
    default
  end
rescue Amalgalite::SQLite3::Error => error  # failed to locate last run
  log.error("Database next mission error:  #{error.message}.")
  default  # use default
end

#update_mission_memory(mission_id, memory) ⇒ Object

Given a mission_id and a memory Hash, this method updates a mission’s stored memory.



201
202
203
204
205
206
207
208
209
210
# File 'lib/scout_agent/database/mission_log.rb', line 201

def update_mission_memory(mission_id, memory)
  write_to_sqlite do |sqlite|
    sqlite.execute(<<-END_UPDATE_MISSION.trim, memory.to_json, mission_id)
    UPDATE missions SET memory = ? WHERE id = ?
    END_UPDATE_MISSION
  end
rescue Amalgalite::SQLite3::Error => error  # failed to update memory
  # do nothing:  mission will receive previous memory state
  log.error("Database memory update error:  #{error.message}.")
end

#update_plan(last_modified, missions) ⇒ Object

Given a new last_modified date (as a String) and an Array of missions, this method attemps and all-or-nothing update of the current plan and missions. The plan and any missions that were already present are simply updated. New missions are added and missions no longer in the list are removed. New missions receive a next_run_at Time of now.



93
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
149
150
151
152
153
# File 'lib/scout_agent/database/mission_log.rb', line 93

def update_plan(last_modified, missions)
  write_to_sqlite do |sqlite|
    begin
      sqlite.execute(<<-END_INSERT_PLAN.trim, last_modified)
      INSERT OR REPLACE INTO plans(ROWID, last_modified) VALUES(1, ?)
      END_INSERT_PLAN

      ids = missions.map { |m| m["id"] }
      sqlite.execute(<<-END_DELETE_MISSIONS.trim, *ids)
      DELETE FROM missions
      WHERE       id NOT IN (#{(['?'] * ids.size).join(', ')})
      END_DELETE_MISSIONS
    rescue Amalgalite::SQLite3::Error => error  # failed to update plan
      log.error("Database bad plan error:  #{error.message}.")
      sqlite.rollback  # these changes are all or nothing
      return           # try again to update plan later
    end
    missions.each do |mission|
      params = [ mission["name"],
                 mission["code"],
                 mission["options"].to_json,
                 mission["timeout"].to_s =~ /\A\d*[1-9]\z/  ?
                   mission["timeout"].to_i                  :
                   DEFAULT_TIMEOUT,
                 mission["interval"].to_s =~ /\A\d*[1-9]\z/ ?
                   mission["interval"].to_i                 :
                   DEFAULT_INTERVAL,
                 mission["id"],
                 Time.now.to_db_s(:trim_seconds) ]
      begin
        if sqlite.first_value_from(
             "SELECT id FROM missions WHERE id = ? LIMIT 1",
             mission["id"]
           )
          params.pop  # remove next_run_at
          sqlite.execute(<<-END_UPDATE_MISSION.trim, *params)
          UPDATE missions
          SET    name     = ?, code = ?, options = ?, timeout = ?,
                 interval = ?
          WHERE  id = ?
          END_UPDATE_MISSION
        else
          sqlite.execute(<<-END_INSERT_MISSION.trim, *params)
          INSERT INTO
            missions( name,     code, options,    timeout,
                      interval,   id, next_run_at )
            VALUES(      ?,        ?,           ?,      ?,
                         ?,        ?,           ? )
          END_INSERT_MISSION
        end
      rescue Amalgalite::SQLite3::Error => error  # failed to set mission
        # do nothing:  skip bad mission and move on
        log.error( "Database bad mission (#{mission['name']}) error:  " +
                   "#{error.message}." )
      end
    end
  end
rescue Amalgalite::SQLite3::Error => error  # failed to get a write lock
  # try again to update plan later
  log.error("Database mission update locking error:  #{error.message}.")
end

#update_schema(version = schema_version) ⇒ Object

Build a schema for storing plans, missions, and reports. The reports table is size controlled by trigger to prevent infinite data growth.



27
28
29
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
# File 'lib/scout_agent/database/mission_log.rb', line 27

def update_schema(version = schema_version)
  case version
  when 0
    <<-END_INITIAL_SCHEMA.trim
    CREATE TABLE plans (
      last_modified REQUIRED_TEXT_TYPE COLLATE NOCASE
    );
    
    CREATE TABLE missions (
      plan_id     DEFAULT_INTEGER_TYPE 1,
      id          INTEGER NOT NULL PRIMARY KEY,
      name        TEXT COLLATE NOCASE,
      code        REQUIRED_TEXT_TYPE,
      options     TEXT,
      memory      TEXT DEFAULT '{}',
      timeout     POSITIVE_INTEGER_TYPE DEFAULT #{DEFAULT_TIMEOUT},
      interval    POSITIVE_INTEGER_TYPE DEFAULT #{DEFAULT_INTERVAL},
      last_run_at DATETIME_TYPE,
      next_run_at REQUIRED_DATETIME_TYPE
    );
    FOREIGN_KEY_CHECK_TRIGGER missions plan_id plans ROWID
    
    CREATE TABLE reports (
      mission_id INTEGER NOT NULL,
      type       TEXT NOT NULL COLLATE NOCASE
        CHECK(type IN ('report', 'hint', 'alert',  'error')),
      fields     REQUIRED_TEXT_TYPE,
      created_at DATETIME_TYPE
    );
    FOREIGN_KEY_CHECK_TRIGGER reports mission_id missions id
    DEFAULT_LOCALTIME_TRIGGER reports created_at
    LIMIT_TABLE_SIZE_TRIGGER  reports #{REPORTS_LIMIT}
    END_INITIAL_SCHEMA
  end
end

#write_report(mission_id, type, fields) ⇒ Object

Adds a report for mission_id of type with fields to the database. Returns true if the write succeeded, or false if it did not.



283
284
285
286
287
288
289
290
291
292
293
294
# File 'lib/scout_agent/database/mission_log.rb', line 283

def write_report(mission_id, type, fields)
  write_to_sqlite do |sqlite|
    params = [mission_id, type, fields.to_json]
    sqlite.execute(<<-END_INSERT_REPORT.trim, *params)
    INSERT INTO reports(mission_id, type, fields) VALUES(?, ?, ?)
    END_INSERT_REPORT
  end
  true  # report successfully written
rescue Amalgalite::SQLite3::Error => error  # failed to create report
  log.error("Database write report error:  #{error.message}.")
  false  # couldn't be written
end