1module Groonga
2  module CommandLine
3    class Grndb
4      def initialize(argv)
5        @program_path, *@arguments = argv
6        @succeeded = true
7        @database_path = nil
8      end
9
10      def run
11        command_line_parser = create_command_line_parser
12        options = nil
13        begin
14          options = command_line_parser.parse(@arguments)
15        rescue Slop::Error => error
16          $stderr.puts(error.message)
17          $stderr.puts
18          $stderr.puts(command_line_parser.help_message)
19          return false
20        end
21        @succeeded
22      end
23
24      private
25      def create_command_line_parser
26        program_name = File.basename(@program_path)
27        parser = CommandLineParser.new(program_name)
28
29        parser.add_command("check") do |command|
30          command.description = "Check database"
31
32          options = command.options
33          options.banner += " DB_PATH"
34          options.string("--target", "Check only the target object.")
35
36          command.add_action do |options|
37            open_database(command, options) do |database, rest_arguments|
38              check(database, options, rest_arguments)
39            end
40          end
41        end
42
43        parser.add_command("recover") do |command|
44          command.description = "Recover database"
45
46          options = command.options
47          options.banner += " DB_PATH"
48          options.boolean("--force-truncate", "Force to truncate corrupted objects.")
49
50          command.add_action do |options|
51            open_database(command, options) do |database, rest_arguments|
52              recover(database, options, rest_arguments)
53            end
54          end
55        end
56
57        parser
58      end
59
60      def open_database(command, options)
61        arguments = options.arguments
62        if arguments.empty?
63          $stderr.puts("Database path is missing")
64          $stderr.puts
65          $stderr.puts(command.help_message)
66          @succeesed = false
67          return
68        end
69
70        database = nil
71        @database_path, *rest_arguments = arguments
72        begin
73          database = Database.open(@database_path)
74        rescue Error => error
75          $stderr.puts("Failed to open database: <#{@database_path}>")
76          $stderr.puts(error.message)
77          @succeeded = false
78          return
79        end
80
81        begin
82          yield(database, rest_arguments)
83        ensure
84          database.close
85        end
86      end
87
88      def failed(*messages)
89        messages.each do |message|
90          $stderr.puts(message)
91        end
92        @succeeded = false
93      end
94
95      def recover(database, options, arguments)
96        recoverer = Recoverer.new
97        recoverer.database = database
98        recoverer.force_truncate = options[:force_truncate]
99        begin
100          recoverer.recover
101        rescue Error => error
102          failed("Failed to recover database: <#{@database_path}>",
103                 error.message)
104        end
105      end
106
107      def check(database, options, arguments)
108        checker = Checker.new
109        checker.program_path = @program_path
110        checker.database_path = @database_path
111        checker.database = database
112        checker.on_failure = lambda do |message|
113          failed(message)
114        end
115
116        checker.check_database
117
118        target_name = options[:target]
119        if target_name
120          checker.check_one(target_name)
121        else
122          checker.check_all
123        end
124      end
125
126      class Checker
127        attr_writer :program_path
128        attr_writer :database_path
129        attr_writer :database
130        attr_writer :on_failure
131
132        def initialize
133          @context = Context.instance
134          @checked = {}
135        end
136
137        def check_database
138          check_database_orphan_inspect
139          check_database_locked
140          check_database_corrupt
141          check_database_dirty
142        end
143
144        def check_one(target_name)
145          target = @context[target_name]
146          if target.nil?
147            exist_p = open_database_cursor do |cursor|
148              cursor.any? do
149                cursor.key == target_name
150              end
151            end
152            if exist_p
153              failed_to_open(target_name)
154            else
155              message = "[#{target_name}] Not exist."
156              failed(message)
157            end
158            return
159          end
160
161          check_object_recursive(target)
162        end
163
164        def check_all
165          open_database_cursor do |cursor|
166            cursor.each do |id|
167              next if ID.builtin?(id)
168              next if builtin_object_name?(cursor.key)
169              next if @context[id]
170              failed_to_open(cursor.key)
171            end
172          end
173
174          @database.each do |object|
175            check_object(object)
176          end
177        end
178
179        private
180        def check_database_orphan_inspect
181          open_database_cursor do |cursor|
182            cursor.each do |id|
183              if cursor.key == "inspect" and @context[id].nil?
184                message =
185                  "Database has orphan 'inspect' object. " +
186                  "Remove it by '#{@program_path} recover #{@database_path}'."
187                failed(message)
188                break
189              end
190            end
191          end
192        end
193
194        def check_database_locked
195          return unless @database.locked?
196
197          message =
198            "Database is locked. " +
199            "It may be broken. " +
200            "Re-create the database."
201          failed(message)
202        end
203
204        def check_database_corrupt
205          return unless @database.corrupt?
206
207          message =
208            "Database is corrupt. " +
209            "Re-create the database."
210          failed(message)
211        end
212
213        def check_database_dirty
214          return unless @database.dirty?
215
216          last_modified = @database.last_modified
217          if File.stat(@database.path).mtime > last_modified
218            return
219          end
220
221          open_database_cursor do |cursor|
222            cursor.each do |id|
223              next if ID.builtin?(id)
224              path = "%s.%07x" % [@database.path, id]
225              next unless File.exist?(path)
226              return if File.stat(path).mtime > last_modified
227            end
228          end
229
230          message =
231            "Database wasn't closed successfully. " +
232            "It may be broken. " +
233            "Re-create the database."
234          failed(message)
235        end
236
237        def check_object(object)
238          return if @checked.key?(object.id)
239          @checked[object.id] = true
240
241          check_object_locked(object)
242          check_object_corrupt(object)
243        end
244
245        def check_object_locked(object)
246          case object
247          when IndexColumn
248            return unless object.locked?
249            message =
250              "[#{object.name}] Index column is locked. " +
251              "It may be broken. " +
252              "Re-create index by '#{@program_path} recover #{@database_path}'."
253            failed(message)
254          when Column
255            return unless object.locked?
256            name = object.name
257            message =
258              "[#{name}] Data column is locked. " +
259              "It may be broken. " +
260              "(1) Truncate the column (truncate #{name}) or " +
261              "clear lock of the column (lock_clear #{name}) " +
262              "and (2) load data again."
263            failed(message)
264          when Table
265            return unless object.locked?
266            name = object.name
267            message =
268              "[#{name}] Table is locked. " +
269              "It may be broken. " +
270              "(1) Truncate the table (truncate #{name}) or " +
271              "clear lock of the table (lock_clear #{name}) " +
272              "and (2) load data again."
273            failed(message)
274          end
275        end
276
277        def check_object_corrupt(object)
278          case object
279          when IndexColumn
280            return unless object.corrupt?
281            message =
282              "[#{object.name}] Index column is corrupt. " +
283              "Re-create index by '#{@program_path} recover #{@database_path}'."
284            failed(message)
285          when Column
286            return unless object.corrupt?
287            name = object.name
288            message =
289              "[#{name}] Data column is corrupt. " +
290              "(1) Truncate the column (truncate #{name} or " +
291              "'#{@program_path} recover --force-truncate #{@database_path}') " +
292              "and (2) load data again."
293            failed(message)
294          when Table
295            return unless object.corrupt?
296            name = object.name
297            message =
298              "[#{name}] Table is corrupt. " +
299              "(1) Truncate the table (truncate #{name} or " +
300              "'#{@program_path} recover --force-truncate #{@database_path}') " +
301              "and (2) load data again."
302            failed(message)
303          end
304        end
305
306        def check_object_recursive(target)
307          return if @checked.key?(target.id)
308
309          check_object(target)
310          case target
311          when Table
312            unless target.is_a?(Groonga::Array)
313              domain_id = target.domain_id
314              domain = @context[domain_id]
315              if domain.nil?
316                record = Record.new(@database, domain_id)
317                failed_to_open(record.key)
318              elsif domain.is_a?(Table)
319                check_object_recursive(domain)
320              end
321            end
322
323            target.column_ids.each do |column_id|
324              column = @context[column_id]
325              if column.nil?
326                record = Record.new(@database, column_id)
327                failed_to_open(record.key)
328              else
329                check_object_recursive(column)
330              end
331            end
332          when FixedSizeColumn, VariableSizeColumn
333            range_id = target.range_id
334            range = @context[range_id]
335            if range.nil?
336              record = Record.new(@database, range_id)
337              failed_to_open(record.key)
338            elsif range.is_a?(Table)
339              check_object_recursive(range)
340            end
341
342            lexicon_ids = []
343            target.indexes.each do |index_info|
344              index = index_info.index
345              lexicon_ids << index.domain_id
346              check_object(index)
347            end
348            lexicon_ids.uniq.each do |lexicon_id|
349              lexicon = @context[lexicon_id]
350              if lexicon.nil?
351                record = Record.new(@database, lexicon_id)
352                failed_to_open(record.key)
353              else
354                check_object(lexicon)
355              end
356            end
357          when IndexColumn
358            range_id = target.range_id
359            range = @context[range_id]
360            if range.nil?
361              record = Record.new(@database, range_id)
362              failed_to_open(record.key)
363              return
364            end
365            check_object(range)
366
367            target.source_ids.each do |source_id|
368              source = @context[source_id]
369              if source.nil?
370                record = Record.new(database, source_id)
371                failed_to_open(record.key)
372              elsif source.is_a?(Column)
373                check_object_recursive(source)
374              end
375            end
376          end
377        end
378
379        def open_database_cursor(&block)
380          flags =
381            TableCursorFlags::ASCENDING |
382            TableCursorFlags::BY_ID
383          TableCursor.open(@database, :flags => flags, &block)
384        end
385
386        def builtin_object_name?(name)
387          case name
388          when "inspect"
389            # Just for compatibility. It's needed for users who used
390            # Groonga master at between 2016-02-03 and 2016-02-26.
391            true
392          else
393            false
394          end
395        end
396
397        def failed(message)
398          @on_failure.call(message)
399        end
400
401        def failed_to_open(name)
402          message =
403            "[#{name}] Can't open object. " +
404            "It's broken. " +
405            "Re-create the object or the database."
406          failed(message)
407        end
408      end
409
410      class Recoverer
411        attr_writer :database
412        attr_writer :force_truncate
413
414        def initialize
415          @context = Context.instance
416        end
417
418        def recover
419          if @force_truncate
420            truncate_corrupt_objects
421          end
422          @database.recover
423        end
424
425        def truncate_corrupt_objects
426          @database.each do |object|
427            next unless object.corrupt?
428            logger = @context.logger
429            object_path = object.path
430            object_dirname = File.dirname(object_path)
431            object_basename = File.basename(object_path)
432            object.truncate
433            Dir.foreach(object_dirname) do |path|
434              if path.start_with?("#{object_basename}.")
435                begin
436                  File.unlink("#{object_dirname}/#{path}")
437                  message = "Corrupted <#{object_path}> related file is removed: <#{path}>"
438                  $stdout.puts(message)
439                  logger.log(Logger::Level::INFO.to_i, __FILE__, __LINE__, "truncate_corrupt_objects", message)
440                rescue Error => error
441                  message = "Failed to remove file which is related to corrupted <#{object_path}>: <#{path}>"
442                  $stderr.puts(message)
443                  logger.log_error(message)
444                end
445              end
446            end
447          end
448        end
449      end
450    end
451  end
452end
453