chronicle-etl 0.2.4 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby.yml +35 -0
  3. data/.gitignore +3 -0
  4. data/.rubocop.yml +31 -1
  5. data/Guardfile +7 -0
  6. data/README.md +21 -14
  7. data/Rakefile +4 -2
  8. data/chronicle-etl.gemspec +18 -10
  9. data/exe/chronicle-etl +1 -1
  10. data/lib/chronicle/etl/cli/connectors.rb +53 -7
  11. data/lib/chronicle/etl/cli/jobs.rb +59 -24
  12. data/lib/chronicle/etl/cli/main.rb +18 -16
  13. data/lib/chronicle/etl/cli/subcommand_base.rb +2 -2
  14. data/lib/chronicle/etl/cli.rb +7 -0
  15. data/lib/chronicle/etl/config.rb +1 -1
  16. data/lib/chronicle/etl/configurable.rb +150 -0
  17. data/lib/chronicle/etl/exceptions.rb +14 -1
  18. data/lib/chronicle/etl/extraction.rb +12 -0
  19. data/lib/chronicle/etl/extractors/csv_extractor.rb +32 -31
  20. data/lib/chronicle/etl/extractors/extractor.rb +25 -13
  21. data/lib/chronicle/etl/extractors/file_extractor.rb +17 -32
  22. data/lib/chronicle/etl/extractors/helpers/filesystem_reader.rb +104 -0
  23. data/lib/chronicle/etl/extractors/json_extractor.rb +37 -0
  24. data/lib/chronicle/etl/extractors/stdin_extractor.rb +6 -1
  25. data/lib/chronicle/etl/job.rb +30 -29
  26. data/lib/chronicle/etl/job_definition.rb +45 -7
  27. data/lib/chronicle/etl/job_log.rb +10 -0
  28. data/lib/chronicle/etl/job_logger.rb +23 -20
  29. data/lib/chronicle/etl/loaders/csv_loader.rb +5 -1
  30. data/lib/chronicle/etl/loaders/loader.rb +5 -2
  31. data/lib/chronicle/etl/loaders/rest_loader.rb +9 -5
  32. data/lib/chronicle/etl/loaders/stdout_loader.rb +6 -1
  33. data/lib/chronicle/etl/loaders/table_loader.rb +51 -7
  34. data/lib/chronicle/etl/logger.rb +48 -0
  35. data/lib/chronicle/etl/models/attachment.rb +14 -0
  36. data/lib/chronicle/etl/models/base.rb +23 -7
  37. data/lib/chronicle/etl/models/entity.rb +9 -3
  38. data/lib/chronicle/etl/registry/connector_registration.rb +62 -0
  39. data/lib/chronicle/etl/registry/registry.rb +52 -0
  40. data/lib/chronicle/etl/registry/self_registering.rb +25 -0
  41. data/lib/chronicle/etl/runner.rb +58 -7
  42. data/lib/chronicle/etl/serializers/jsonapi_serializer.rb +25 -0
  43. data/lib/chronicle/etl/serializers/serializer.rb +27 -0
  44. data/lib/chronicle/etl/transformers/image_file_transformer.rb +247 -0
  45. data/lib/chronicle/etl/transformers/null_transformer.rb +10 -1
  46. data/lib/chronicle/etl/transformers/transformer.rb +41 -10
  47. data/lib/chronicle/etl/utils/binary_attachments.rb +21 -0
  48. data/lib/chronicle/etl/utils/progress_bar.rb +3 -1
  49. data/lib/chronicle/etl/utils/text_recognition.rb +15 -0
  50. data/lib/chronicle/etl/version.rb +1 -1
  51. data/lib/chronicle/etl.rb +8 -2
  52. metadata +146 -34
  53. data/.ruby-version +0 -1
  54. data/Gemfile.lock +0 -91
  55. data/lib/chronicle/etl/catalog.rb +0 -108
  56. data/lib/chronicle/etl/utils/jsonapi.rb +0 -28
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ostruct"
4
+
5
+ module Chronicle
6
+ module ETL
7
+ # A mixin that gives a class
8
+ # a {Chronicle::ETL::Configurable::ClassMethods#setting} macro to define
9
+ # settings and their properties (require, type, etc)
10
+ #
11
+ # @example Basic usage
12
+ # class Test < Chronicle::ETL::Extractor
13
+ # include Chronicle::ETL::Configurable
14
+ # setting :when, type: :date, required: true
15
+ # end
16
+ #
17
+ # t = Test.new(when: '2022-02-24')
18
+ # t.config.when
19
+ module Configurable
20
+ # An individual setting for this Configurable
21
+ Setting = Struct.new(:default, :required, :type)
22
+ private_constant :Setting
23
+
24
+ # Collection of user-supplied options for this Configurable
25
+ class Config < OpenStruct
26
+ # Config values that aren't nil, as a hash
27
+ def compacted_h
28
+ to_h.compact
29
+ end
30
+ end
31
+
32
+ # @private
33
+ def self.included(klass)
34
+ klass.extend(ClassMethods)
35
+ klass.include(InstanceMethods)
36
+ klass.prepend(Initializer)
37
+ end
38
+
39
+ # Initializer method for classes that have Configurable mixed in
40
+ module Initializer
41
+ # Make sure this class has a default @config ready to use
42
+ def initialize(*args)
43
+ @config = initialize_default_config
44
+ super
45
+ end
46
+ end
47
+
48
+ # Instance methods for classes that have Configurable mixed in
49
+ module InstanceMethods
50
+ attr_reader :config
51
+
52
+ # Take given options and apply them to this class's settings
53
+ # and make them available in @config and validates that they
54
+ # conform to setting rules
55
+ def apply_options(options)
56
+ options.transform_keys!(&:to_sym)
57
+
58
+ options.each do |name, value|
59
+ setting = self.class.all_settings[name]
60
+ raise(Chronicle::ETL::ConfigurationError, "Unrecognized setting: #{name}") unless setting
61
+
62
+ @config[name] = coerced_value(setting, value)
63
+ end
64
+ validate_config
65
+ options
66
+ end
67
+
68
+ # Name of all settings available to this class
69
+ def self.settings
70
+ self.class.all_settings.keys
71
+ end
72
+
73
+ private
74
+
75
+ def initialize_default_config
76
+ self.class.config_with_defaults
77
+ end
78
+
79
+ def validate_config
80
+ missing = (self.class.all_required_settings.keys - @config.compacted_h.keys)
81
+ raise Chronicle::ETL::ConfigurationError, "Missing options: #{missing}" if missing.count.positive?
82
+ end
83
+
84
+ def coerced_value(setting, value)
85
+ setting.type ? __send__("coerce_#{setting.type}", value) : value
86
+ end
87
+
88
+ def coerce_string(value)
89
+ value.to_s
90
+ end
91
+
92
+ def coerce_time(value)
93
+ # TODO: handle durations like '3h'
94
+ if value.is_a?(String)
95
+ Time.parse(value)
96
+ else
97
+ value
98
+ end
99
+ end
100
+ end
101
+
102
+ # Class methods for classes that have Configurable mixed in
103
+ module ClassMethods
104
+ # Macro for creating a setting on a class {::Chronicle::ETL::Configurable}
105
+ #
106
+ # @param [String] name Name of the setting
107
+ # @param [Boolean] required whether setting is required
108
+ # @param [Object] default Default value
109
+ # @param [Symbol] type Type
110
+ #
111
+ # @example Basic usage
112
+ # setting :when, type: :date, required: true
113
+ #
114
+ # @see ::Chronicle::ETL::Configurable
115
+ def setting(name, default: nil, required: false, type: nil)
116
+ s = Setting.new(default, required, type)
117
+ settings[name] = s
118
+ end
119
+
120
+ # Collect all settings defined on this class and its ancestors (that
121
+ # have Configurable mixin included)
122
+ def all_settings
123
+ if superclass.include?(Chronicle::ETL::Configurable)
124
+ superclass.all_settings.merge(settings)
125
+ else
126
+ settings
127
+ end
128
+ end
129
+
130
+ # Filters settings to those that are required.
131
+ def all_required_settings
132
+ all_settings.select { |_name, setting| setting.required } || {}
133
+ end
134
+
135
+ def settings
136
+ @settings ||= {}
137
+ end
138
+
139
+ def setting_exists?(name)
140
+ all_settings.keys.include? name
141
+ end
142
+
143
+ def config_with_defaults
144
+ s = all_settings.transform_values(&:default)
145
+ Config.new(s)
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -2,7 +2,9 @@ module Chronicle
2
2
  module ETL
3
3
  class Error < StandardError; end;
4
4
 
5
- class InvalidTransformedRecordError < Error; end
5
+ class ConfigurationError < Error; end;
6
+
7
+ class RunnerTypeError < Error; end
6
8
 
7
9
  class ConnectorNotAvailableError < Error
8
10
  def initialize(message, provider: nil, name: nil)
@@ -15,5 +17,16 @@ module Chronicle
15
17
 
16
18
  class ProviderNotAvailableError < ConnectorNotAvailableError; end
17
19
  class ProviderConnectorNotAvailableError < ConnectorNotAvailableError; end
20
+
21
+ class TransformationError < Error
22
+ attr_reader :transformation
23
+
24
+ def initialize(message=nil, transformation:)
25
+ super(message)
26
+ @transformation = transformation
27
+ end
28
+ end
29
+
30
+ class UntransformableRecordError < TransformationError; end
18
31
  end
19
32
  end
@@ -0,0 +1,12 @@
1
+ module Chronicle
2
+ module ETL
3
+ class Extraction
4
+ attr_accessor :data, :meta
5
+
6
+ def initialize(data: {}, meta: {})
7
+ @data = data
8
+ @meta = meta
9
+ end
10
+ end
11
+ end
12
+ end
@@ -1,41 +1,42 @@
1
1
  require 'csv'
2
- class Chronicle::ETL::CsvExtractor < Chronicle::ETL::Extractor
3
- DEFAULT_OPTIONS = {
4
- headers: true,
5
- filename: $stdin
6
- }.freeze
7
-
8
- def initialize(options = {})
9
- super(DEFAULT_OPTIONS.merge(options))
10
- end
11
2
 
12
- def extract
13
- csv = initialize_csv
14
- csv.each do |row|
15
- result = row.to_h
16
- yield result
17
- end
18
- end
3
+ module Chronicle
4
+ module ETL
5
+ class CSVExtractor < Chronicle::ETL::Extractor
6
+ include Extractors::Helpers::FilesystemReader
19
7
 
20
- def results_count
21
- CSV.read(@options[:filename], headers: @options[:headers]).count if read_from_file?
22
- end
8
+ register_connector do |r|
9
+ r.description = 'input as CSV'
10
+ end
23
11
 
24
- private
12
+ setting :headers, default: true
13
+ setting :filename, default: $stdin
25
14
 
26
- def initialize_csv
27
- headers = @options[:headers].is_a?(String) ? @options[:headers].split(',') : @options[:headers]
15
+ def extract
16
+ csv = initialize_csv
17
+ csv.each do |row|
18
+ yield Chronicle::ETL::Extraction.new(data: row.to_h)
19
+ end
20
+ end
28
21
 
29
- csv_options = {
30
- headers: headers,
31
- converters: :all
32
- }
22
+ def results_count
23
+ CSV.read(@config.filename, headers: @config.headers).count unless stdin?(@config.filename)
24
+ end
33
25
 
34
- stream = read_from_file? ? File.open(@options[:filename]) : @options[:filename]
35
- CSV.new(stream, **csv_options)
36
- end
26
+ private
27
+
28
+ def initialize_csv
29
+ headers = @config.headers.is_a?(String) ? @config.headers.split(',') : @config.headers
37
30
 
38
- def read_from_file?
39
- @options[:filename] != $stdin
31
+ csv_options = {
32
+ headers: headers,
33
+ converters: :all
34
+ }
35
+
36
+ open_from_filesystem(filename: @config.filename) do |file|
37
+ return CSV.new(file, **csv_options)
38
+ end
39
+ end
40
+ end
40
41
  end
41
42
  end
@@ -4,38 +4,50 @@ module Chronicle
4
4
  module ETL
5
5
  # Abstract class representing an Extractor for an ETL job
6
6
  class Extractor
7
- extend Chronicle::ETL::Catalog
7
+ extend Chronicle::ETL::Registry::SelfRegistering
8
+ include Chronicle::ETL::Configurable
9
+
10
+ setting :since, type: :date
11
+ setting :until, type: :date
12
+ setting :limit
13
+ setting :load_after_id
14
+ setting :filename
8
15
 
9
16
  # Construct a new instance of this extractor. Options are passed in from a Runner
10
- # == Paramters:
17
+ # == Parameters:
11
18
  # options::
12
19
  # Options for configuring this Extractor
13
20
  def initialize(options = {})
14
- @options = options.transform_keys!(&:to_sym)
15
- handle_continuation
21
+ apply_options(options)
16
22
  end
17
23
 
18
- # Entrypoint for this Extractor. Called by a Runner. Expects a series of records to be yielded
19
- def extract
20
- raise NotImplementedError
21
- end
24
+ # Hook called before #extract. Useful for gathering data, initailizing proxies, etc
25
+ def prepare; end
22
26
 
23
27
  # An optional method to calculate how many records there are to extract. Used primarily for
24
28
  # building the progress bar
25
29
  def results_count; end
26
30
 
31
+ # Entrypoint for this Extractor. Called by a Runner. Expects a series of records to be yielded
32
+ def extract
33
+ raise NotImplementedError
34
+ end
35
+
27
36
  private
28
37
 
29
- def handle_continuation
30
- return unless @options[:continuation]
38
+ # TODO: reimplemenet this
39
+ # def handle_continuation
40
+ # return unless @config.continuation
31
41
 
32
- @options[:load_since] = @options[:continuation].highest_timestamp if @options[:continuation].highest_timestamp
33
- @options[:load_after_id] = @options[:continuation].last_id if @options[:continuation].last_id
34
- end
42
+ # @config.since = @config.continuation.highest_timestamp if @config.continuation.highest_timestamp
43
+ # @config.load_after_id = @config.continuation.last_id if @config.continuation.last_id
44
+ # end
35
45
  end
36
46
  end
37
47
  end
38
48
 
49
+ require_relative 'helpers/filesystem_reader'
39
50
  require_relative 'csv_extractor'
40
51
  require_relative 'file_extractor'
52
+ require_relative 'json_extractor'
41
53
  require_relative 'stdin_extractor'
@@ -3,49 +3,34 @@ require 'pathname'
3
3
  module Chronicle
4
4
  module ETL
5
5
  class FileExtractor < Chronicle::ETL::Extractor
6
- def extract
7
- if file?
8
- extract_file do |data, metadata|
9
- yield(data, metadata)
10
- end
11
- elsif directory?
12
- extract_from_directory do |data, metadata|
13
- yield(data, metadata)
14
- end
15
- end
16
- end
6
+ include Extractors::Helpers::FilesystemReader
17
7
 
18
- def results_count
19
- if file?
20
- return 1
21
- else
22
- search_pattern = File.join(@options[:filename], '**/*.eml')
23
- Dir.glob(search_pattern).count
24
- end
8
+ register_connector do |r|
9
+ r.description = 'file or directory of files'
25
10
  end
26
11
 
27
- private
12
+ # TODO: consolidate this with @config.filename
13
+ setting :dir_glob_pattern
28
14
 
29
- def extract_from_directory
30
- search_pattern = File.join(@options[:filename], '**/*.eml')
31
- filenames = Dir.glob(search_pattern)
15
+ def extract
32
16
  filenames.each do |filename|
33
- file = File.open(filename)
34
- yield(file.read, {filename: file})
17
+ yield Chronicle::ETL::Extraction.new(data: filename)
35
18
  end
36
19
  end
37
20
 
38
- def extract_file
39
- file = File.open(@options[:filename])
40
- yield(file.read, {filename: @options[:filename]})
21
+ def results_count
22
+ filenames.count
41
23
  end
42
24
 
43
- def directory?
44
- Pathname.new(@options[:filename]).directory?
45
- end
25
+ private
46
26
 
47
- def file?
48
- Pathname.new(@options[:filename]).file?
27
+ def filenames
28
+ @filenames ||= filenames_in_directory(
29
+ path: @config.filename,
30
+ dir_glob_pattern: @config.dir_glob_pattern,
31
+ load_since: @config.since,
32
+ load_until: @config.until
33
+ )
49
34
  end
50
35
  end
51
36
  end
@@ -0,0 +1,104 @@
1
+ require 'pathname'
2
+
3
+ module Chronicle
4
+ module ETL
5
+ module Extractors
6
+ module Helpers
7
+ module FilesystemReader
8
+
9
+ def filenames_in_directory(...)
10
+ filenames = gather_files(...)
11
+ if block_given?
12
+ filenames.each do |filename|
13
+ yield filename
14
+ end
15
+ else
16
+ filenames
17
+ end
18
+ end
19
+
20
+ def read_from_filesystem(filename:, yield_each_line: true, dir_glob_pattern: '**/*')
21
+ open_files(filename: filename, dir_glob_pattern: dir_glob_pattern) do |file|
22
+ if yield_each_line
23
+ file.each_line do |line|
24
+ yield line
25
+ end
26
+ else
27
+ yield file.read
28
+ end
29
+ end
30
+ end
31
+
32
+ def open_from_filesystem(filename:, dir_glob_pattern: '**/*')
33
+ open_files(filename: filename, dir_glob_pattern: dir_glob_pattern) do |file|
34
+ yield file
35
+ end
36
+ end
37
+
38
+ def results_count
39
+ raise NotImplementedError
40
+ # if file?
41
+ # return 1
42
+ # else
43
+ # search_pattern = File.join(@options[:filename], '**/*')
44
+ # Dir.glob(search_pattern).count
45
+ # end
46
+ end
47
+
48
+ private
49
+
50
+ def gather_files(path:, dir_glob_pattern: '**/*', load_since: nil, load_until: nil, smaller_than: nil, larger_than: nil, sort: :mtime)
51
+ search_pattern = File.join(path, '**', dir_glob_pattern)
52
+ files = Dir.glob(search_pattern)
53
+
54
+ files = files.keep_if {|f| (File.mtime(f) > load_since)} if load_since
55
+ files = files.keep_if {|f| (File.mtime(f) < load_until)} if load_until
56
+
57
+ # pass in file sizes in bytes
58
+ files = files.keep_if {|f| (File.size(f) < smaller_than)} if smaller_than
59
+ files = files.keep_if {|f| (File.size(f) > larger_than)} if larger_than
60
+
61
+ # TODO: incorporate sort argument
62
+ files.sort_by{ |f| File.mtime(f) }
63
+ end
64
+
65
+ def select_files_in_directory(path:, dir_glob_pattern: '**/*')
66
+ raise IOError.new("#{path} is not a directory.") unless directory?(path)
67
+
68
+ search_pattern = File.join(path, dir_glob_pattern)
69
+ Dir.glob(search_pattern).each do |filename|
70
+ yield(filename)
71
+ end
72
+ end
73
+
74
+ def open_files(filename:, dir_glob_pattern:)
75
+ if stdin?(filename)
76
+ yield $stdin
77
+ elsif directory?(filename)
78
+ search_pattern = File.join(filename, dir_glob_pattern)
79
+ filenames = Dir.glob(search_pattern)
80
+ filenames.each do |filename|
81
+ file = File.open(filename)
82
+ yield(file)
83
+ end
84
+ elsif file?(filename)
85
+ yield File.open(filename)
86
+ end
87
+ end
88
+
89
+ def stdin?(filename)
90
+ filename == $stdin
91
+ end
92
+
93
+ def directory?(filename)
94
+ Pathname.new(filename).directory?
95
+ end
96
+
97
+ def file?(filename)
98
+ Pathname.new(filename).file?
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,37 @@
1
+ module Chronicle
2
+ module ETL
3
+ class JsonExtractor < Chronicle::ETL::Extractor
4
+ include Extractors::Helpers::FilesystemReader
5
+
6
+ register_connector do |r|
7
+ r.description = 'input as JSON'
8
+ end
9
+
10
+ setting :filename, default: $stdin
11
+ setting :jsonl, default: true
12
+
13
+ def extract
14
+ load_input do |input|
15
+ parsed_data = parse_data(input)
16
+ yield Chronicle::ETL::Extraction.new(data: parsed_data) if parsed_data
17
+ end
18
+ end
19
+
20
+ def results_count
21
+ end
22
+
23
+ private
24
+
25
+ def parse_data data
26
+ JSON.parse(data)
27
+ rescue JSON::ParserError => e
28
+ end
29
+
30
+ def load_input
31
+ read_from_filesystem(filename: @options[:filename]) do |data|
32
+ yield data
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -1,9 +1,14 @@
1
1
  module Chronicle
2
2
  module ETL
3
3
  class StdinExtractor < Chronicle::ETL::Extractor
4
+ register_connector do |r|
5
+ r.description = 'stdin'
6
+ end
7
+
4
8
  def extract
5
9
  $stdin.read.each_line do |line|
6
- yield line
10
+ data = { line: line.strip }
11
+ yield Chronicle::ETL::Extraction.new(data: data)
7
12
  end
8
13
  end
9
14
  end
@@ -1,6 +1,11 @@
1
+ require 'forwardable'
1
2
  module Chronicle
2
3
  module ETL
3
4
  class Job
5
+ extend Forwardable
6
+
7
+ def_delegators :@job_definition, :dry_run?
8
+
4
9
  attr_accessor :name,
5
10
  :extractor_klass,
6
11
  :extractor_options,
@@ -12,32 +17,30 @@ module Chronicle
12
17
  # TODO: build a proper id system
13
18
  alias id name
14
19
 
15
- def initialize(definition)
16
- definition = definition.definition # FIXME
17
- @name = definition[:name]
18
- @extractor_klass = load_klass(:extractor, definition[:extractor][:name])
19
- @extractor_options = definition[:extractor][:options] || {}
20
-
21
- @transformer_klass = load_klass(:transformer, definition[:transformer][:name])
22
- @transformer_options = definition[:transformer][:options] || {}
23
-
24
- @loader_klass = load_klass(:loader, definition[:loader][:name])
25
- @loader_options = definition[:loader][:options] || {}
20
+ def initialize(job_definition)
21
+ @job_definition = job_definition
22
+ @name = @job_definition.definition[:name]
23
+ @extractor_options = @job_definition.extractor_options
24
+ @transformer_options = @job_definition.transformer_options
25
+ @loader_options = @job_definition.loader_options
26
26
 
27
- set_continuation if load_continuation?
27
+ set_continuation if use_continuation?
28
28
  yield self if block_given?
29
29
  end
30
30
 
31
31
  def instantiate_extractor
32
- instantiate_klass(:extractor)
32
+ @extractor_klass = @job_definition.extractor_klass
33
+ @extractor_klass.new(@extractor_options)
33
34
  end
34
35
 
35
- def instantiate_transformer(data)
36
- instantiate_klass(:transformer, data)
36
+ def instantiate_transformer(extraction)
37
+ @transformer_klass = @job_definition.transformer_klass
38
+ @transformer_klass.new(extraction, @transformer_options)
37
39
  end
38
40
 
39
41
  def instantiate_loader
40
- instantiate_klass(:loader)
42
+ @loader_klass = @job_definition.loader_klass
43
+ @loader_klass.new(@loader_options)
41
44
  end
42
45
 
43
46
  def save_log?
@@ -45,26 +48,24 @@ module Chronicle
45
48
  return !id.nil?
46
49
  end
47
50
 
48
- private
49
-
50
- def instantiate_klass(phase, *args)
51
- options = self.send("#{phase.to_s}_options")
52
- args = args.unshift(options)
53
- klass = self.send("#{phase.to_s}_klass")
54
- klass.new(*args)
51
+ def to_s
52
+ output = "Job"
53
+ output += " '#{name}'".bold if name
54
+ output += "\n"
55
+ output += " → Extracting from #{@job_definition.extractor_klass.description}\n"
56
+ output += " → Transforming #{@job_definition.transformer_klass.description}\n"
57
+ output += " → Loading to #{@job_definition.loader_klass.description}\n"
55
58
  end
56
59
 
57
- def load_klass phase, identifier
58
- Chronicle::ETL::Catalog.phase_and_identifier_to_klass(phase, identifier)
59
- end
60
+ private
60
61
 
61
62
  def set_continuation
62
- continuation = Chronicle::ETL::JobLogger.load_latest(@job_id)
63
+ continuation = Chronicle::ETL::JobLogger.load_latest(@id)
63
64
  @extractor_options[:continuation] = continuation
64
65
  end
65
66
 
66
- def load_continuation?
67
- save_log?
67
+ def use_continuation?
68
+ @job_definition.incremental?
68
69
  end
69
70
  end
70
71
  end