tools.win_pg_dump_controller

Yohn Y. 2022-01-30 Child:a22dd63ba19e

0:be791d354d2a Browse Files

..init

.hgignore README.md examples/config.ini win_pg_dump_controller/__init__.py win_pg_dump_controller/__main__.py win_pg_dump_controller/config.py win_pg_dump_controller/error.py win_pg_dump_controller/executor.py win_pg_dump_controller/log_controller.py win_pg_dump_controller/store_controller.py

     1.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     1.2 +++ b/.hgignore	Sun Jan 30 22:17:39 2022 +0300
     1.3 @@ -0,0 +1,2 @@
     1.4 +syntax: glob
     1.5 +.idea/*
     2.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     2.2 +++ b/README.md	Sun Jan 30 22:17:39 2022 +0300
     2.3 @@ -0,0 +1,55 @@
     2.4 +Мотивация
     2.5 +=========
     2.6 +
     2.7 +Есть прекрасная платформа `windows`. И есть в ней сервисы разные, но нет консоли нормальной. Либо `cmd`,
     2.8 +либо `powershell` и крутись как хочешь?
     2.9 +
    2.10 +Для того чтобы не крутиться хотя бы в вопросах рещервных копий `PostgreSQL` создан этот модуль.
    2.11 +
    2.12 +Почему не другие решения? Поскольку хорошее решение требует нормальной инфраструктуры, а не `PostgreSQL` на `Windows`.
    2.13 +В нормальной инфраструктуре это всё делается проще и скрипты уже есть. Но здесь `windows`
    2.14 +
    2.15 +Кроме того, имея эту основу можно попытаться сделать нечто более сложное.
    2.16 +
    2.17 +
    2.18 +Как пользоваться?
    2.19 +=================
    2.20 +
    2.21 +Запуск модуля через `c:\python38\python.exe -m win_pg_dump_controller c:\etc\pg_backup.config`.
    2.22 +`c:\etc\pg_backup.config` файл в формате `INI`. Пример файла в `examples`
    2.23 +
    2.24 +
    2.25 +Описание Конфигурационного файла
    2.26 +--------------------------------
    2.27 +
    2.28 +### Секции
    2.29 +
    2.30 +* `main` - основная конфигурация скрипта
    2.31 +* `common` - общие параметры заданий
    2.32 +* `${Имя задания}` - параметры задания. `${Имя задания}` задаётся пользователем, чтобы ему было понятно. 
    2.33 +  Оно фигурирует в журналах, используется в файлах резерных копий. Поэтому есть смысл избегать в нём русских букв
    2.34 +  и специальных символов
    2.35 +
    2.36 +
    2.37 +### Параметры в `main`
    2.38 +
    2.39 +* `pg_bin_path` - расположение директории `bin` нужной инстраляции `PostgreSQL`. 
    2.40 +   Можно поискать файл `pg_dump.exe` и внести сюда ту директорию, в которой он лежит
    2.41 +* `pg_dump_flags` - если не хватает флагов `pg_dump`, недостающие можно перечислить здесь.
    2.42 +* `log_dir` - каталог, куда будут писаться логи всего что происходит. Если не задан, логи писаться не будут.
    2.43 +* `teir1_days` - период самых последних резервных копий. Количество дней, за которые они вообще чиститься не будут.
    2.44 +   По умолчанию 7 дней.
    2.45 +* `teir2_copies_interval` - архивный период резервных копий, количество дней между сохранёнными копиями. То есть 
    2.46 +  за этот период будет храниться только одна, самая старая копия. По умолчанию 7 дней
    2.47 +* `tier2_store_days` - за этим количеством дней копии чистятся. По умолчанию 30 дней
    2.48 +* `keep_logs_days` - количество дней за который хранить журналы.
    2.49 +
    2.50 +
    2.51 +### Параметры `common` и раздела задач
    2.52 +
    2.53 +* `host_name` - имя или IP узла баз данных. По умолчанию `127.0.0.1`
    2.54 +* `db_name` - имя базы данных
    2.55 +* `user_name` - имя пользователя для подключения к серверу
    2.56 +* `passwd` - пароль пользователя
    2.57 +* `port` - порт сервера баз данных
    2.58 +* `dst_dir` - каталог, к котором будут хранится резервные копии задания.
     3.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     3.2 +++ b/examples/config.ini	Sun Jan 30 22:17:39 2022 +0300
     3.3 @@ -0,0 +1,13 @@
     3.4 +[main]
     3.5 +pg_bin_path = c:\program files\postgreSQL\14\bin
     3.6 +log_dir=c:\var\dblog
     3.7 +
     3.8 +[common]
     3.9 +host_name=192.168.0.1
    3.10 +port=5433
    3.11 +dst_dir=c:\var\backup
    3.12 +
    3.13 +[todolist]
    3.14 +db_name=my_prod_database
    3.15 +user_name=some_user
    3.16 +passwd=P@ssW0rd
    3.17 \ No newline at end of file
     4.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     4.2 +++ b/win_pg_dump_controller/__init__.py	Sun Jan 30 22:17:39 2022 +0300
     4.3 @@ -0,0 +1,1 @@
     4.4 +# coding: utf-8
     5.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     5.2 +++ b/win_pg_dump_controller/__main__.py	Sun Jan 30 22:17:39 2022 +0300
     5.3 @@ -0,0 +1,38 @@
     5.4 +# coding: utf-8
     5.5 +
     5.6 +from .log_controller import LogController
     5.7 +from .config import Config
     5.8 +from .executor import backup
     5.9 +from .error import Error
    5.10 +
    5.11 +config = Config()
    5.12 +log_controller = LogController(config)
    5.13 +
    5.14 +log = log_controller.get_logger('main')
    5.15 +try:
    5.16 +    log_t = log.get_timing()
    5.17 +
    5.18 +    log(log_t(f'Начало процесса'))
    5.19 +
    5.20 +    for task in config.tasks:
    5.21 +        log(log_t(f'Обработка: {task.name}'))
    5.22 +        backup(task, config, log_controller)
    5.23 +
    5.24 +        log(log_t(f'Завершение обработки: {task.name}'))
    5.25 +
    5.26 +    log(log_t('Очистка старых журналов...'))
    5.27 +    log_controller.clean()
    5.28 +    log(log_t('Завершено'))
    5.29 +
    5.30 +except Error as e:
    5.31 +    log.err(str(e))
    5.32 +    print('FAIL')
    5.33 +    exit(1)
    5.34 +
    5.35 +except:
    5.36 +    log.excpt('Неизвестная ошибка')
    5.37 +    print('FAIL')
    5.38 +    exit(2)
    5.39 +
    5.40 +else:
    5.41 +    print('ok')
    5.42 \ No newline at end of file
     6.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     6.2 +++ b/win_pg_dump_controller/config.py	Sun Jan 30 22:17:39 2022 +0300
     6.3 @@ -0,0 +1,103 @@
     6.4 +# coding: utf-8
     6.5 +from configparser import ConfigParser, SectionProxy
     6.6 +from sys import argv
     6.7 +from os.path import isdir
     6.8 +from typing import Optional, Iterable
     6.9 +
    6.10 +
    6.11 +from .error import Error
    6.12 +
    6.13 +
    6.14 +class ConfigError(Error):
    6.15 +    pass
    6.16 +
    6.17 +
    6.18 +COMMON_SECTION = 'common'
    6.19 +MAIN = 'main'
    6.20 +DEFAULT_PG_PORT = 5432
    6.21 +
    6.22 +
    6.23 +class DBTask(object):
    6.24 +    def __init__(self, task_name: str, host_name: str, db_name: str, user_name: str, passwd: Optional[str],
    6.25 +                 dst_dir: str, port: int = DEFAULT_PG_PORT):
    6.26 +        self.host_name = host_name
    6.27 +        self.db_name = db_name
    6.28 +        self.user_name = user_name
    6.29 +        self.passwd = passwd
    6.30 +        self.dst_dir = dst_dir
    6.31 +        self.name = task_name
    6.32 +        self.port = port
    6.33 +
    6.34 +        if not (self.db_name or self.user_name or self.dst_dir or self.host_name):
    6.35 +            raise ConfigError(f'Some important config parameters not set: '
    6.36 +                              f'db_name="{db_name}" '
    6.37 +                              f'user_name="{user_name}" '
    6.38 +                              f'dst_dir="{dst_dir}" '
    6.39 +                              f'host_name="{host_name}"')
    6.40 +
    6.41 +
    6.42 +class CommonTaskDescription(object):
    6.43 +    def __init__(self, host_name: Optional[str], user_name: Optional[str],
    6.44 +                 passwd: Optional[str], dst_dir: Optional[str], port: int = DEFAULT_PG_PORT):
    6.45 +        self.user_name = user_name
    6.46 +        self.passwd = passwd
    6.47 +        self.dst_dir = dst_dir
    6.48 +        self.host_name = host_name
    6.49 +        self.port = port
    6.50 +
    6.51 +    def parse_section(self, config_section: SectionProxy) -> DBTask:
    6.52 +        db_name = config_section.get('db_name')
    6.53 +        user_name = config_section.get('user_name', self.user_name)
    6.54 +        passwd = config_section.get('passwd', self.passwd)
    6.55 +        dst_dir = config_section.get('dst_dir', self.dst_dir)
    6.56 +        host_name = config_section.get('host_name', self.host_name)
    6.57 +        port = config_section.getint('port', self.port)
    6.58 +
    6.59 +        if not isdir(dst_dir):
    6.60 +            raise ConfigError(f'Destionation directory not exists for "{config_section.name}": {dst_dir}')
    6.61 +
    6.62 +        return DBTask(task_name=config_section.name, host_name=host_name, db_name=db_name,
    6.63 +                      user_name=user_name, passwd=passwd, port=port, dst_dir=dst_dir)
    6.64 +
    6.65 +    @classmethod
    6.66 +    def parse_config(cls, config: ConfigParser) -> Iterable[DBTask]:
    6.67 +        if not config.has_section(COMMON_SECTION):
    6.68 +            tmpl = cls(host_name=None, user_name=None, passwd=None, dst_dir=None)
    6.69 +
    6.70 +        else:
    6.71 +            _section = config['common']
    6.72 +            host_name = _section.get('host_name', '127.0.0.1')
    6.73 +            user_name = _section.get('user_name')
    6.74 +            passwd = _section.get('passwd')
    6.75 +            dst_dir = _section.get('dst_dir')
    6.76 +            port = _section.getint('port', DEFAULT_PG_PORT)
    6.77 +
    6.78 +            tmpl = cls(host_name=host_name, user_name=user_name, passwd=passwd, dst_dir=dst_dir, port=port)
    6.79 +
    6.80 +        for _section in filter(lambda x: x not in (COMMON_SECTION, MAIN), config.sections()):
    6.81 +            yield tmpl.parse_section(config[_section])
    6.82 +
    6.83 +
    6.84 +class Config(object):
    6.85 +    def __init__(self):
    6.86 +        try:
    6.87 +            config_file = argv[1]
    6.88 +        except IndexError:
    6.89 +            raise ConfigError(f'No config file specified')
    6.90 +
    6.91 +        _config = ConfigParser()
    6.92 +        _config.read(config_file)
    6.93 +
    6.94 +        _main_section = _config[MAIN]
    6.95 +        self.pg_bin_path = _main_section.get('pg_bin_path')
    6.96 +        self.pg_dump_flags = _main_section.get('pg_dump_flags', '')
    6.97 +        self.log_dir = _main_section.get('log_dir')
    6.98 +        self.teir1_days = _main_section.getint('teir1_days', 7)
    6.99 +        self.teir2_copies_interval = _main_section.getint('teir2_copies_interval', 7)
   6.100 +        self.tier2_store_days = _main_section.getint('tier2_store_days', 30)
   6.101 +        self.keep_logs_days = _main_section.getint('keep_logs_days', 30)
   6.102 +
   6.103 +        if not isdir(self.pg_bin_path):
   6.104 +            raise ConfigError(f'No valid directory with binnary files of PostgreSQL is set: {self.pg_bin_path}')
   6.105 +
   6.106 +        self.tasks = list(CommonTaskDescription.parse_config(_config))
     7.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     7.2 +++ b/win_pg_dump_controller/error.py	Sun Jan 30 22:17:39 2022 +0300
     7.3 @@ -0,0 +1,9 @@
     7.4 +# coding: utf-8
     7.5 +
     7.6 +class Error(Exception):
     7.7 +    @staticmethod
     7.8 +    def err_fmt(e: Exception) -> str:
     7.9 +        return f'{type(e)}({e})'
    7.10 +
    7.11 +    def __str__(self):
    7.12 +        return f'{super().__str__()} - [{type(self).__name__}]'
     8.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     8.2 +++ b/win_pg_dump_controller/executor.py	Sun Jan 30 22:17:39 2022 +0300
     8.3 @@ -0,0 +1,64 @@
     8.4 +# coding: utf-8
     8.5 +
     8.6 +import subprocess as sp
     8.7 +from os.path import join as p_join
     8.8 +from os import environ
     8.9 +
    8.10 +from .store_controller import StoreController
    8.11 +from .log_controller import LogController
    8.12 +from .config import Config, DBTask
    8.13 +from .error import Error
    8.14 +
    8.15 +
    8.16 +class ExecutorError(Error):
    8.17 +    pass
    8.18 +
    8.19 +
    8.20 +def backup(task: DBTask, config: Config, log_controller: LogController):
    8.21 +    log = log_controller.get_logger(f'{task.name}.executor')
    8.22 +    log_t = log.get_timing()
    8.23 +    log(log_t(f'Начинаем копирование {task.name}'))
    8.24 +
    8.25 +
    8.26 +    stor = StoreController(task)
    8.27 +    backup_item = stor.new_item()
    8.28 +
    8.29 +    log(log_t(f'Копируется "{task.db_name}" -> "{backup_item.get_path()}" '
    8.30 +              f'с параметрами pg_dump "{config.pg_dump_flags}"'))
    8.31 +
    8.32 +    pg_dump = p_join(config.pg_bin_path, 'pg_dump.exe')
    8.33 +    pg_dump = (
    8.34 +        f'"{pg_dump}" {config.pg_dump_flags} -F c -v --clean '
    8.35 +        f'-U "{task.user_name}" -h "{task.host_name}" -p "{task.port}" '
    8.36 +        f'-f "{backup_item.get_path()}" {task.db_name}'
    8.37 +    )
    8.38 +
    8.39 +    environ['PGPASSWORD'] = task.passwd
    8.40 +
    8.41 +    pglog_name = log_controller.get_filename(task.name)
    8.42 +    pglog_fd = sp.DEVNULL if pglog_name is None else open(pglog_name, 'w')
    8.43 +
    8.44 +    log(log_t(f'Пробуем запустить: {pg_dump}'))
    8.45 +
    8.46 +    try:
    8.47 +        cmd = sp.Popen(pg_dump, stderr=sp.STDOUT, stdout=pglog_fd, shell=True)
    8.48 +        cmd.wait()
    8.49 +
    8.50 +        log(log_t(f'Команда выполнена, статус возврата "{cmd.returncode}", журнал: "{pglog_name}"'))
    8.51 +
    8.52 +        if cmd.returncode != 0:
    8.53 +            raise ExecutorError(f'Не нулевой код возврата, подробности в "{pglog_name}"')
    8.54 +
    8.55 +    except:
    8.56 +        stor.remove(backup_item)
    8.57 +        raise
    8.58 +
    8.59 +    else:
    8.60 +        stor.add_item(backup_item)
    8.61 +
    8.62 +    log(log_t('Очистка старых копий'))
    8.63 +
    8.64 +    log(log_t('\n'.join(map(lambda x: f'- {x}', stor.clean(config.teir1_days, config.teir2_copies_interval,
    8.65 +                                           config.tier2_store_days)))))
    8.66 +
    8.67 +    stor.save_index()
     9.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     9.2 +++ b/win_pg_dump_controller/log_controller.py	Sun Jan 30 22:17:39 2022 +0300
     9.3 @@ -0,0 +1,167 @@
     9.4 +# coding: utf-8
     9.5 +"""\
     9.6 +Модуль логирования
     9.7 +
     9.8 +Метки в журнале о уровне сообщения:
     9.9 +  "`": Debug
    9.10 +  ".": Info
    9.11 +  "*": Warning
    9.12 +  "!": Error
    9.13 +  "#": Alert
    9.14 +"""
    9.15 +
    9.16 +from time import time, ctime
    9.17 +from datetime import timedelta, datetime
    9.18 +from sys import exc_info
    9.19 +from os.path import join as p_join
    9.20 +from os import listdir, remove as file_remove, stat
    9.21 +from traceback import extract_tb, extract_stack
    9.22 +from typing import Optional
    9.23 +
    9.24 +from .config import Config
    9.25 +
    9.26 +
    9.27 +class Timing(object):
    9.28 +    def __init__(self, name: Optional[str] = None):
    9.29 +        if name is None:
    9.30 +            self.prefix = ''
    9.31 +        else:
    9.32 +            self.prefix = f'{name} :: '
    9.33 +        self.tsAll = time()
    9.34 +        self.ts = self.tsAll
    9.35 +
    9.36 +    def get_time(self):
    9.37 +        return time() - self.ts
    9.38 +
    9.39 +    def reset(self):
    9.40 +        self.ts = time()
    9.41 +        self.tsAll = self.ts
    9.42 +
    9.43 +    def __str__(self):
    9.44 +        ts = time()
    9.45 +        return self.prefix + '%s(%.4f)' % (timedelta(seconds=(ts - self.tsAll)), ts - self.ts)
    9.46 +
    9.47 +    def __call__(self, msg):
    9.48 +        _buf = f'{self} | {msg}'
    9.49 +        self.ts = time()
    9.50 +        return _buf
    9.51 +
    9.52 +
    9.53 +class BaseLogger(object):
    9.54 +    def __init__(self, appname='main'):
    9.55 +        self.appname = appname
    9.56 +
    9.57 +    @staticmethod
    9.58 +    def _write(itr_content):
    9.59 +        raise NotImplemented()
    9.60 +
    9.61 +    def __call__(self, msg):
    9.62 +        self._write(map(
    9.63 +            lambda x: '%3s | %s :: %s' % ('.', self.appname, x),
    9.64 +            str(msg).splitlines()
    9.65 +        ))
    9.66 +
    9.67 +    def err(self, msg):
    9.68 +        self._write(map(
    9.69 +            lambda x: '%3s | %s :: %s' % ('!', self.appname, x),
    9.70 +            str(msg).splitlines()
    9.71 +        ))
    9.72 +
    9.73 +    def warn(self, msg):
    9.74 +        self._write(map(
    9.75 +            lambda x: '%3s | %s :: %s' % ('*', self.appname, x),
    9.76 +            str(msg).splitlines()
    9.77 +        ))
    9.78 +
    9.79 +    def alert(self, msg):
    9.80 +        self._write(map(
    9.81 +            lambda x: '%3s | %s :: %s' % ('#', self.appname, x),
    9.82 +            str(msg).splitlines()
    9.83 +        ))
    9.84 +
    9.85 +    def debug(self, msg):
    9.86 +        self._write(map(
    9.87 +            lambda x: '%3s | %s :: %s' % ('`', self.appname, x),
    9.88 +            str(msg).splitlines()
    9.89 +        ))
    9.90 +
    9.91 +    @staticmethod
    9.92 +    def get_timing(name: Optional[str] = None):
    9.93 +        return Timing(name)
    9.94 +
    9.95 +    def sublog(self, name):
    9.96 +        return self.__class__(f'{self.appname}/{name}')
    9.97 +
    9.98 +    def excpt(self, msg, e_class=None, e_obj=None, e_tb=None, stack_skip=0):
    9.99 +        if e_class is None:
   9.100 +            e_class, e_obj, e_tb = exc_info()
   9.101 +
   9.102 +        tb_data_tb = list(extract_tb(e_tb))[::-1]
   9.103 +        tb_data_stack = list(extract_stack())[::-1][(2 + stack_skip):]
   9.104 +        self.err(msg)
   9.105 +        self.err('--- EXCEPTION ---')
   9.106 +        self.err(' %s (%s)' % (e_class.__name__, e_obj))
   9.107 +        self.err('--- TRACEBACK ---')
   9.108 +        for _tbFile, _tbLine, _tbFunc, _tbText in tb_data_tb:
   9.109 +            self.err('File: %s, line %s in %s' % (_tbFile, _tbLine, _tbFunc))
   9.110 +            self.err('   %s' % _tbText)
   9.111 +        self.err('>>> Exception Handler <<<')
   9.112 +        for _tbFile, _tbLine, _tbFunc, _tbText in tb_data_stack:
   9.113 +            self.err('File: %s, line %s in %s' % (_tbFile, _tbLine, _tbFunc))
   9.114 +            self.err('   %s' % _tbText)
   9.115 +        self.err('--- END EXCEPTION ---')
   9.116 +
   9.117 +
   9.118 +class NullLogger(BaseLogger):
   9.119 +    @staticmethod
   9.120 +    def _write(itr_content):
   9.121 +        pass
   9.122 +
   9.123 +
   9.124 +class FileLogger(BaseLogger):
   9.125 +    def __init__(self, filename: str, appname: str = 'main'):
   9.126 +        super().__init__(appname)
   9.127 +        self.fd = open(filename, 'a')
   9.128 +
   9.129 +    def _write(self, itr_content):
   9.130 +        cur_time = ctime()
   9.131 +        for i in itr_content:
   9.132 +            self.fd.write(f'{cur_time}{i}\n')
   9.133 +
   9.134 +        self.fd.flush()
   9.135 +
   9.136 +    def __del__(self):
   9.137 +        try:
   9.138 +            self.fd.close()
   9.139 +        except:
   9.140 +            pass
   9.141 +
   9.142 +
   9.143 +class LogController(object):
   9.144 +    def __init__(self, config: Config):
   9.145 +        self.log_dir = config.log_dir
   9.146 +        self.keep_logs_days = config.keep_logs_days
   9.147 +
   9.148 +    @staticmethod
   9.149 +    def _get_timeprefix() -> str:
   9.150 +        return datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
   9.151 +
   9.152 +    def get_logger(self, name: str) -> BaseLogger:
   9.153 +        if self.log_dir is not None:
   9.154 +            return FileLogger(p_join(self.log_dir, f'{self._get_timeprefix()} - {name}.log'), appname=name)
   9.155 +        else:
   9.156 +            return NullLogger(appname=name)
   9.157 +
   9.158 +    def get_filename(self, name: str) -> Optional[str]:
   9.159 +        if self.log_dir is not None:
   9.160 +            return str(p_join(self.log_dir, f'{self._get_timeprefix()} - {name}.log'))
   9.161 +
   9.162 +    def clean(self):
   9.163 +        if self.log_dir is not None:
   9.164 +            now = time()
   9.165 +            for f in listdir(self.log_dir):
   9.166 +                f_path = p_join(self.log_dir, f)
   9.167 +                f_stat = stat(f_path)
   9.168 +
   9.169 +                if divmod(now - f_stat.st_ctime, 86400)[0] > self.keep_logs_days:
   9.170 +                    file_remove(f_path)
    10.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
    10.2 +++ b/win_pg_dump_controller/store_controller.py	Sun Jan 30 22:17:39 2022 +0300
    10.3 @@ -0,0 +1,150 @@
    10.4 +# coding: utf-8
    10.5 +
    10.6 +import json
    10.7 +from datetime import datetime
    10.8 +from os.path import exists, join as p_join, basename
    10.9 +from os import remove as file_remove
   10.10 +from typing import Dict, List
   10.11 +
   10.12 +from .error import Error
   10.13 +from .config import DBTask
   10.14 +
   10.15 +
   10.16 +class StoreError(Error):
   10.17 +    pass
   10.18 +
   10.19 +
   10.20 +class StoreControllerBase(object):
   10.21 +    def get_filename(self, filename: str) -> str:
   10.22 +        raise NotImplemented()
   10.23 +
   10.24 +
   10.25 +class IndexItem(object):
   10.26 +    def __init__(self, controller: StoreControllerBase, time: int, filename: str):
   10.27 +        self._controller = controller
   10.28 +        self.filename = filename
   10.29 +        self.time = time
   10.30 +        self._my_path = None
   10.31 +
   10.32 +    def get_path(self):
   10.33 +        if self._my_path is None:
   10.34 +            self._my_path = self._controller.get_filename(self.filename)
   10.35 +
   10.36 +        return self._my_path
   10.37 +
   10.38 +    def get_datetime(self):
   10.39 +        return datetime.fromtimestamp(self.time)
   10.40 +
   10.41 +    def is_exists(self) -> bool:
   10.42 +        return exists(self.get_path())
   10.43 +
   10.44 +    def to_dict(self) -> dict:
   10.45 +        return dict((i, getattr(self, i)) for i in ('filename', 'time'))
   10.46 +
   10.47 +    @classmethod
   10.48 +    def from_dict(cls, controller: StoreControllerBase, d: Dict):
   10.49 +        return cls(
   10.50 +            controller=controller,
   10.51 +            **d
   10.52 +        )
   10.53 +
   10.54 +    @classmethod
   10.55 +    def new(cls, controller: StoreControllerBase, short_filename: str):
   10.56 +        time = datetime.now()
   10.57 +        time_prefix = time.strftime('%Y-%m-%d_%H-%M-%S')
   10.58 +        filename = f'{time_prefix} - {short_filename}'
   10.59 +
   10.60 +        return cls(
   10.61 +            controller=controller,
   10.62 +            time=int(time.timestamp()),
   10.63 +            filename=filename
   10.64 +        )
   10.65 +
   10.66 +
   10.67 +class StoreController(StoreControllerBase):
   10.68 +    def __init__(self, task: DBTask):
   10.69 +        self.task = task
   10.70 +        self.index_name = self.get_filename(f'{task.name}.index')
   10.71 +        self.idx: Dict[int, IndexItem] = {}
   10.72 +
   10.73 +        if exists(self.index_name):
   10.74 +            self.load_index()
   10.75 +
   10.76 +    def get_filename(self, filename: str) -> str:
   10.77 +        return str(p_join(self.task.dst_dir, filename))
   10.78 +
   10.79 +    def load_index(self) -> None:
   10.80 +        with open(self.index_name, 'r') as IN:
   10.81 +            result = {}
   10.82 +            for itm in json.load(IN):
   10.83 +                itm = IndexItem.from_dict(self, itm)
   10.84 +                if itm.is_exists():
   10.85 +                    result[itm.time] = itm
   10.86 +
   10.87 +        self.idx = result
   10.88 +
   10.89 +    def save_index(self) -> None:
   10.90 +        with open(self.index_name, 'w') as OUT:
   10.91 +            json.dump(list(map(lambda x: x.to_dict(), self.idx.values())), OUT)
   10.92 +
   10.93 +    def new_item(self) -> IndexItem:
   10.94 +        return IndexItem.new(self, f'{self.task.name}.backup')
   10.95 +
   10.96 +    def add_item(self, item: IndexItem) -> None:
   10.97 +        item_path = item.get_path()
   10.98 +        if not item.is_exists():
   10.99 +            raise StoreError(f'Storing to index file not found: {item.get_path()}')
  10.100 +
  10.101 +        if item.time in self.idx and self.idx[item.time].filename != item.filename:
  10.102 +            if self.idx[item.time].is_exists():
  10.103 +                file_remove(self.idx[item.time].get_path())
  10.104 +
  10.105 +        self.idx[item.time] = item
  10.106 +
  10.107 +    def remove(self, item: IndexItem) -> None:
  10.108 +        if item.time in self.idx:
  10.109 +            del self.idx[item.time]
  10.110 +
  10.111 +        if item.is_exists():
  10.112 +            file_remove(item.get_path())
  10.113 +
  10.114 +    def __iter__(self):
  10.115 +        for i in sorted(self.idx):
  10.116 +            yield self.idx[i]
  10.117 +
  10.118 +    def clean(self, tier1_days: int, tier2_copies_interval: int, tier2_store_days: int) -> List[str]:
  10.119 +        to_remove = []
  10.120 +        tier2_idx = {}
  10.121 +        now = datetime.now()
  10.122 +
  10.123 +        for item in self:
  10.124 +            if not item.is_exists():
  10.125 +                to_remove.append(item)
  10.126 +
  10.127 +            else:
  10.128 +                storing_days = (now - item.get_datetime()).days
  10.129 +
  10.130 +                if not storing_days <= tier1_days:
  10.131 +                    if storing_days > tier2_store_days:
  10.132 +                        to_remove.append(item)
  10.133 +
  10.134 +                    else:
  10.135 +                        # Магия: Делим старые резервные копии на эпохи. Эпоха - целая часть от деления количества
  10.136 +                        #        дней, которое лежит резервная копия, на интервал, за который нам нужно хранить
  10.137 +                        #        хотя бы одну копию. В одной эпохе старший файл вытесняет младший. Из вычислений
  10.138 +                        #        убираем период tier1
  10.139 +
  10.140 +                        storing_days -= tier1_days
  10.141 +
  10.142 +                        _epoch = divmod(storing_days, tier2_copies_interval)[0]
  10.143 +                        if _epoch in tier2_idx:
  10.144 +                            to_remove.append(tier2_idx[_epoch])
  10.145 +
  10.146 +                        tier2_idx[_epoch] = item
  10.147 +
  10.148 +        result = []
  10.149 +        for item in to_remove:
  10.150 +            file_remove(item.get_path())
  10.151 +            result.append(item.filename)
  10.152 +
  10.153 +        return result