# # Fluent # # Copyright (C) 2013 FURUHASHI Sadayuki # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # require "fluent/plugin/input" module Fluent::Plugin require 'active_record' class SQLInput < Input Fluent::Plugin.register_input('sql', self) desc 'RDBMS host' config_param :host, :string desc 'RDBMS port' config_param :port, :integer, default: nil desc 'RDBMS driver name.' config_param :adapter, :string desc 'RDBMS database name' config_param :database, :string desc 'RDBMS login user name' config_param :username, :string, default: nil desc 'RDBMS login password' config_param :password, :string, default: nil, secret: true desc 'RDBMS socket path' config_param :socket, :string, default: nil desc 'PostgreSQL schema search path' config_param :schema_search_path, :string, default: nil desc 'path to a file to store last rows' config_param :state_file, :string, default: nil desc 'prefix of tags of events. actual tag will be this_tag_prefix.tables_tag (optional)' config_param :tag_prefix, :string, default: nil desc 'interval to run SQLs (optional)' config_param :select_interval, :time, default: 60 desc 'limit of number of rows for each SQL(optional)' config_param :select_limit, :time, default: 500 class TableElement include Fluent::Configurable config_param :table, :string config_param :tag, :string, default: nil config_param :update_column, :string, default: nil config_param :time_column, :string, default: nil config_param :primary_key, :string, default: nil def configure(conf) super end def init(tag_prefix, base_model, router) @router = router @tag = "#{tag_prefix}.#{@tag}" if tag_prefix # creates a model for this table table_name = @table primary_key = @primary_key @model = Class.new(base_model) do self.table_name = table_name self.inheritance_column = '_never_use_' self.primary_key = primary_key if primary_key #self.include_root_in_json = false def read_attribute_for_serialization(n) v = send(n) if v.respond_to?(:to_msgpack) v elsif v.is_a? Time v.strftime('%Y-%m-%d %H:%M:%S.%6N%z') else v.to_s end end end # ActiveRecord requires model class to have a name. class_name = table_name.gsub(/\./, "_").singularize.camelize base_model.const_set(class_name, @model) # Sets model_name otherwise ActiveRecord causes errors model_name = ActiveModel::Name.new(@model, nil, class_name) @model.define_singleton_method(:model_name) { model_name } # if update_column is not set, here uses primary key unless @update_column pk = @model.columns_hash[@model.primary_key] unless pk raise "Composite primary key is not supported. Set update_column parameter to section." end @update_column = pk.name end end # emits next records and returns the last record of emitted records def emit_next_records(last_record, limit) relation = @model if last_record && last_update_value = last_record[@update_column] relation = relation.where("#{@update_column} > ?", last_update_value) end relation = relation.order("#{@update_column} ASC") relation = relation.limit(limit) if limit > 0 now = Fluent::Engine.now me = Fluent::MultiEventStream.new relation.each do |obj| record = obj.serializable_hash rescue nil if record if @time_column && tv = obj.read_attribute(@time_column) if tv.is_a?(Time) time = tv.to_i else time = Time.parse(tv.to_s).to_i rescue now end else time = now end me.add(time, record) last_record = record end end last_record = last_record.dup if last_record # some plugin rewrites record :( @router.emit_stream(@tag, me) return last_record end end def configure(conf) super unless @state_file $log.warn "'state_file PATH' parameter is not set to a 'sql' source." $log.warn "this parameter is highly recommended to save the last rows to resume tailing." end @tables = conf.elements.select {|e| e.name == 'table' }.map {|e| te = TableElement.new te.configure(e) te } if config['all_tables'] @all_tables = true end end SKIP_TABLE_REGEXP = /\Aschema_migrations\Z/i def start @state_store = @state_file.nil? ? MemoryStateStore.new : StateStore.new(@state_file) config = { adapter: @adapter, host: @host, port: @port, database: @database, username: @username, password: @password, socket: @socket, schema_search_path: @schema_search_path, } # creates subclass of ActiveRecord::Base so that it can have different # database configuration from ActiveRecord::Base. @base_model = Class.new(ActiveRecord::Base) do # base model doesn't have corresponding phisical table self.abstract_class = true end # ActiveRecord requires the base_model to have a name. Here sets name # of an anonymous class by assigning it to a constant. In Ruby, class has # a name of a constant assigned first SQLInput.const_set("BaseModel_#{rand(1 << 31)}", @base_model) # Now base_model can have independent configuration from ActiveRecord::Base @base_model.establish_connection(config) if @all_tables # get list of tables from the database @tables = @base_model.connection.tables.map do |table_name| if table_name.match(SKIP_TABLE_REGEXP) # some tables such as "schema_migrations" should be ignored nil else te = TableElement.new te.configure({ 'table' => table_name, 'tag' => table_name, 'update_column' => nil, }) te end end.compact end # ignore tables if TableElement#init failed @tables.reject! do |te| begin te.init(@tag_prefix, @base_model, router) log.info "Selecting '#{te.table}' table" false rescue => e log.warn "Can't handle '#{te.table}' table. Ignoring.", error: e log.warn_backtrace e.backtrace true end end @stop_flag = false @thread = Thread.new(&method(:thread_main)) end def shutdown @stop_flag = true $log.debug "Waiting for thread to finish" @thread.join end def thread_main until @stop_flag sleep @select_interval begin conn = @base_model.connection conn.active? || conn.reconnect! rescue => e log.warn "can't connect to database. Reconnect at next try" next end @tables.each do |t| begin last_record = @state_store.last_records[t.table] @state_store.last_records[t.table] = t.emit_next_records(last_record, @select_limit) @state_store.update! rescue => e log.error "unexpected error", error: e log.error_backtrace e.backtrace end end end end class StateStore def initialize(path) require 'yaml' @path = path if File.exists?(@path) @data = YAML.load_file(@path) if @data == false || @data == [] # this happens if an users created an empty file accidentally @data = {} elsif !@data.is_a?(Hash) raise "state_file on #{@path.inspect} is invalid" end else @data = {} end end def last_records @data['last_records'] ||= {} end def update! File.open(@path, 'w') {|f| f.write YAML.dump(@data) } end end class MemoryStateStore def initialize @data = {} end def last_records @data['last_records'] ||= {} end def update! end end end end