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("    UPDATE missions SET last_run_at = ?, next_run_at = ? WHERE id = ?\n    END_UPDATE_MISSION\n  end\n  true  # it's safe to continue\nrescue Amalgalite::SQLite3::Error => error  # failed to update mission\n  log.error(\"Database complete mission error:  \#{error.message}.\")\n  false  # warn the caller that the mission will still match\nend\n".trim, *params)

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

#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("    SELECT   ROWID AS id, last_modified\n    FROM     plans\n    ORDER BY ROWID DESC\n    LIMIT    1\n    END_FIND_PLAN\n  }\n  plan.empty? ? nil : plan\nrescue Amalgalite::SQLite3::Error => error  # failed to retrieve plan\n  log.error(\"Database current plan error:  \#{error.message}.\")\n  nil  # not found\nend\n".trim)

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

#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("    UPDATE missions\n    SET    next_run_at = strftime('%Y-%m-%d %H:%M', 'now', 'localtime')\n    WHERE  id IN (\#{(['?'] * ids.size).join(', ')})\n    END_RESET_MISSIONS\n  end\nrescue Amalgalite::SQLite3::Error => error  # failed to reset missions\n  # do nothing:  missions will be run at their scheduled time\n  log.error(\"Database mission reset error:  \#{error.message}.\")\nend\n".trim, *ids)

#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("    SELECT next_run_at FROM missions ORDER BY next_run_at LIMIT 1\n    END_FIND_MISSION\n  }\n  if next_run = Time.from_db_s(next_run_at)\n    seconds = next_run - Time.now\n    seconds > 0 ? seconds : default\n  else\n    default\n  end\nrescue Amalgalite::SQLite3::Error => error  # failed to locate last run\n  log.error(\"Database next mission error:  \#{error.message}.\")\n  default  # use default\nend\n".trim)

#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("    UPDATE missions SET memory = ? WHERE id = ?\n    END_UPDATE_MISSION\n  end\nrescue Amalgalite::SQLite3::Error => error  # failed to update memory\n  # do nothing:  mission will receive previous memory state\n  log.error(\"Database memory update error:  \#{error.message}.\")\nend\n".trim, memory.to_json, mission_id)

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

#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
    "    CREATE TABLE plans (\n      last_modified REQUIRED_TEXT_TYPE COLLATE NOCASE\n    );\n    \n    CREATE TABLE missions (\n      plan_id     DEFAULT_INTEGER_TYPE 1,\n      id          INTEGER NOT NULL PRIMARY KEY,\n      name        TEXT COLLATE NOCASE,\n      code        REQUIRED_TEXT_TYPE,\n      options     TEXT,\n      memory      TEXT DEFAULT '{}',\n      timeout     POSITIVE_INTEGER_TYPE DEFAULT \#{DEFAULT_TIMEOUT},\n      interval    POSITIVE_INTEGER_TYPE DEFAULT \#{DEFAULT_INTERVAL},\n      last_run_at DATETIME_TYPE,\n      next_run_at REQUIRED_DATETIME_TYPE\n    );\n    FOREIGN_KEY_CHECK_TRIGGER missions plan_id plans ROWID\n    \n    CREATE TABLE reports (\n      mission_id INTEGER NOT NULL,\n      type       TEXT NOT NULL COLLATE NOCASE\n        CHECK(type IN ('report', 'hint', 'alert',  'error')),\n      fields     REQUIRED_TEXT_TYPE,\n      created_at DATETIME_TYPE\n    );\n    FOREIGN_KEY_CHECK_TRIGGER reports mission_id missions id\n    DEFAULT_LOCALTIME_TRIGGER reports created_at\n    LIMIT_TABLE_SIZE_TRIGGER  reports \#{REPORTS_LIMIT}\n    END_INITIAL_SCHEMA\n  end\nend\n".trim

#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("    INSERT INTO reports(mission_id, type, fields) VALUES(?, ?, ?)\n    END_INSERT_REPORT\n  end\n  true  # report successfully written\nrescue Amalgalite::SQLite3::Error => error  # failed to create report\n  log.error(\"Database write report error:  \#{error.message}.\")\n  false  # couldn't be written\nend\n".trim, *params)