Повышение параллелизма UnitTest'ов utPLSQL в Oracle

Могут ли девять женщин родить ребёнка за один месяц…?

Старинная индейская мудрость.

30382df5d17a7ccf51605d1a050ffd12.png

Быстрое развитие проекта несет в себе множество сложностей — большая вероятность сломать старый функционал или привнести новые баги. Одним из способов поддержания качества кода в хорошем состоянии, является наличие UnitTest’ов для существующего кода и обязательность создания Unit тестов для нового функционала.

Чем больше покрытие кода Unit тестами, тем выше качество. Но следствием увеличения покрытия кода Unit тестами, является увеличение времени работы самих Unit тестов, что негативно сказывается на скорости рабочего процесса.

В статье моего коллеги — https://habr.com/ru/companies/sportmaster_lab/articles/718472 описан механизм запуска Oracle UnitTest’ов с использованием библиотеки utPLSQL, в параллельном режиме. Попробуем достигнуть максимума — скомбинируем UnitTest«ы таким образом, чтобы достигнуть наибольшего быстродействия.

Библиотека utPLSQL объединяет пакеты, содержавшие Unit тесты, по логическим группам — suit’ам в терминах utPLSQL. Для этого в коде PL/SQL используется аннотация следующего вида:

--%suite (The name of my test suite)

https://www.utplsql.org/utPLSQL/latest/userguide/annotations.html

При первой реализации, параллельное выполнение Unit тестов было разбито именно по логическим группам — suit’ам. Данное разбиение может быть не оптимально по времени выполнения. Рассмотрим способы оптимизации, исходя их того, что мы не можем менять пакеты PL\SQL Oracle, в которых содержаться Unit тесты, но можем менять группы пакетов, запускаемых параллельно.

Очевидно, что наибольшее быстродействие достигается, когда все пакеты выполняются параллельно. Но, это не это не самое оптимальное решение, поскольку пакеты, выполняющиеся быстро, можно и нужно объединять вместе, для уменьшения степени параллелизма и уменьшения нагрузки на систему.

Сформируем алгоритм разбиения пакетов:

  • на основе статистики, полученной при выполнении Unit тестов ранее, выбираем самый долго исполняющийся пакет — он выступает якорем, определяющим верхнюю границу времени исполнения, групп пакетов, которые должны быть объединены вместе. То есть первая группа — это пакет (пакеты) с максимальным временем исполнения;

  • объединяем остальные пакеты в группы, таким образом, чтобы суммарное время выполнения было меньше максимального времени исполнения.

Такой алгоритм позволяет уменьшить количество параллельно работающих групп и достичь общего времени исполнения всех UnitTest«ов, равного времени исполнения максимального пакета.

В первой реализации для разбиения использовался запрос из табличной функции utPLSQL ut_runner.get_suites_info:

http://www.utplsql.org/utPLSQL/v3.1.3/userguide/querying_suites.html? trk=article-ssr-frontend-pulse_little-text-block

Представление, получающее разбиение Unit тестов по наборам пакетов:

/***************************************************************/
/*         Справочник разделений Unit тестов по наборам         */
create or replace force view v_utp_suit_packages as   
with tests as
(
    select 
        t.*,
        replace(regexp_substr(t.path, '\S*\.'), '.', '') as suite
    from 
        table(utp.ut_runner.get_suites_info()) t
)
select
    row_number() over(order by t.suite)                             as pie,
    t.suite,
    listagg(object_name, ', ') within group (order by object_name)  as packages,
    count(1) over ()                                                as total
from
    tests t
where
    item_type = 'UT_SUITE'
group by
    t.suite;
comment on table v_utp_suit_packages            is 'Справочник разделений Unit тестов по наборам';
comment on column v_utp_suit_packages.pie       is 'Номер теста';
comment on column v_utp_suit_packages.suite     is 'Название suitа';
comment on column v_utp_suit_packages.packages  is 'Список пакетов suitа';
comment on column v_utp_suit_packages.total     is 'Общее кол-во групп тестов разбитых по suit';            

Напишем конвейерную табличную функцию, которая при отсутствии статистики, или при переданном флаге, что не нужно использовать статистику, будет возвращать данные из вышеуказанного представления v_utp_suit_packages. В противном случае процедура возвращает динамически сформированные группы пакетов на основании статистики.

Прототип функции выглядит следующим образом:

type t_utp_suit_packages_tbl is table of v_utp_suit_packages%rowtype;

/*********************************************************/
/*             Получить разбиение UTP пакетов            */
function get_utp_packages
(
    p_use_statistic     in number default 0,
    p_statistic_uuid    in raw default null
)
return t_utp_suit_packages_tbl pipelined
is
    v_count                     number;
    ...
begin
    select
        count(*),
    into
        v_count,
    from
        -- Таблица с результатами тестов
        utp_tests t
    where
        t.uuid = nvl(p_statistic_uuid, uuid);
    end;
    if p_use_statistic = 0 or v_count = 0 then
        -- Используем разбиение UTP тестов по наборам (suit)
        for v_rec in
        (
            select
                t.pie,
                t.suite,
                t.packages,
                t.total
            from
                v_utp_suit_packages t
        )
        loop
            pipe row (v_rec);
        end loop;
    else
        -- Используем данные статистики
        ...
    end if;
end get_utp_packages;

Создание табличной функции позволяет нам бесшовно модифицировать наш пакет, который обеспечивает параллельный запуск наших UnitTest«ов.

Вернемся назад, и коротко опишем реализацию пакета tst_utils:

create or replace package tst_utils is

/***************************************************************/
/* Выполнение UTP тестов в параллельном режиме */
procedure execute_parallel_utp;

/***************************************************************/
/* Выполнение группы UTP тестов разбитого по набору p_start_id */
procedure execute_utp
(
    p_start_id          in number,
    p_end_id            in number,
    p_uuid              in raw
);

/******************************************************/
/* Функция выводит результаты тестов из utp_tests */
function get_test_result_clob
(
    p_uuid  utp_tests.uuid%type
) return clob;

end tst_utils;
/
create or replace package body tst_utils is

/***************************************************************/
/* Выполнение UTP тестов в параллельном режиме */
procedure execute_parallel_utp
is
    v_uuid              raw(16);
    v_task_name         varchar(4000) := 'utp_parallel_run';
    v_chunk_sql         varchar(4000) := q'[
        select
            t.pie,
            t.total
        from
            v_utp_suit_packages t
]';
  v_sql                 varchar(4000) := q'[
        begin
            tst_utils.execute_utp
            (
                p_start_id          => :start_id , 
                p_end_id            => :end_id,
                p_uuid              => '$uuid',
                p_statistic_uuid    => $statistic_uuid
            );
        end;
]';
    -- Подготовка данных
    v_uuid := sys_guid();
    v_SQL := replace(v_sql, '$uuid', v_uuid);
    v_task_name := v_task_name || v_uuid;
    select
        total
    into
        v_parallel_count
    from
        v_utp_suit_packages
    fetch first 1 row only;	
    -- Выполнение UTP тестов в параллель
    service_utils.run_parallel_sql
    (
        p_task_name      => v_task_name,
        p_chunk_sql      => v_chunk_sql,
        p_task_sql       => v_sql,
        p_parallel_level => v_parallel_count
    );
    -- Вывод результаты работы в dbms_output
    print_clob_to_output
    (
        p_clob =>
            get_test_result_clob
            (
                p_uuid => v_uuid
            )
    );
end execute_parallel_utp;

/***************************************************************/
/* Выполнение группы Unit тестов разбитого по набору p_start_id */
procedure execute_utp
(
    p_start_id      in number,
    p_end_id        in number,
    p_uuid          in raw
)
is
    v_packages  varchar(4000);
    v_start     timestamp(3);
    v_buffer    DBMS_OUTPUT.chararr;
    v_num_lines PLS_INTEGER;
    v_result    clob;
    v_error     number;
begin
    v_start := systimestamp;
    select
        packages
    into
        v_packages
    from
        v_utp_suit_packages
    where
        pie = p_start_id 
        and p_end_id is not null;
    -- Выполняем Unit тесты
    utp.ut.run
    ( 
        a_paths => utp.ut_varchar2_list(v_packages), 
        a_reporter => utp.ut_junit_reporter() 
    );
    -- Собираем результат из dbms_output
    v_num_lines := 4000;
    dbms_output.get_lines(v_buffer, v_num_lines);
    for i in 1..v_buffer.count loop
        v_result := v_result || v_buffer(i);
    end loop;
    -- Сохраняем результат в итоговую таблицу
    insert into 
        utp_tests ( uuid, pie, start_ts, end_ts, error_code, result_data )
    values
        ( p_uuid, p_start_id, v_start, systimestamp, 0, v_result);
    commit;
exception
    when others then 
        v_result := sqlerrm;
        v_error := sqlcode;
        insert into  
            utp_tests ( uuid, pie, start_ts, end_ts, error_code, result_data )
        values
            ( p_uuid, p_start_id, v_start, systimestamp, v_error, v_result );
        commit;
end execute_utp;

end tst_utils;
/

Часть процедур сознательно опущена, поскольку они не нужны для описания идеи подхода.

Процедура execute_parallel_utp:

  • на основании представления v_utp_suit_packages разбивает пакеты Oracle на группы;

  • используя механизм Oracle dbms_parallel_execute (вызов dbms_parallel_execute скрыт в service_utils.run_parallel_sql) создает параллельные задания и выполняет их в процедурах execute_utp;

  • ожидает выполнения заданий;

  • выводит результат работы UnitTest«ов в буфер dbms_output.

Главное, что можно видеть из выше указанного фрагмента, что в процедурах execute_parallel_utp и execute_utp используется представление v_utp_suit_packages для выбора, какие UnitTest«ы должны быть обработаны.

Если мы заменим представление v_utp_suit_packages, на результат конвейерной табличной функции get_utp_packages (select * from table (tst_utils.get_utp_packages)) — то внешние системы, использующие вызовы UnitTest«ов не потребуют изменений. Не нужно изменять конвейер CI/CD — все изменения и вся магия остается внутри пакета tst_utils.

Заголовок процедуры execute_parallel_utp изменится следующим образом:

procedure execute_parallel_utp
(
    -- Флаг, используемый для определения нужно или нет использовать статистику 
    p_use_statistic  in  number   default 1
)
is
    v_uuid              raw(16);
    v_task_name         varchar(4000) := 'utp_parallel_run';
    v_chunk_sql         varchar(4000) := q'[
        select
            t.pie,
            t.total
        from
            table(tst_utils.get_utp_packages($use_statistic)) t
]';
  v_sql                 varchar(4000) := q'[
        begin
            tst_utils.execute_utp
            (
                p_start_id          => :start_id , 
                p_end_id            => :end_id,
                p_uuid              => '$uuid',
                p_statistic_uuid    => $statistic_uuid
            );
        end;
]';
...........

Процедура execute_utp примет следующий вид:

procedure execute_utp
(
    p_start_id          in number,
    p_end_id            in number,
    p_uuid              in raw,
    p_statistic_uuid    in raw default null
)
is
    v_packages      varchar(4000);
    v_suite         varchar(4000);
    v_start         timestamp(3);
.................
begin
    v_start := systimestamp;
    if p_statistic_uuid is null then
        select
            t.packages,
            t.suite
        into
            v_packages,
            v_suite
        from
            v_utp_suit_packages t
        where
            t.pie = p_start_id
            and p_end_id is not null;
    else
        select
            t.packages,
            t.suite
        into
            v_packages,
            v_suite
        from
            table(tst_utils.get_utp_packages(1, p_statistic_uuid)) t
        where
            t.pie = p_start_id
            and p_end_id is not null;
    end if;
.................

Результат оптимизации:

cbce30c2b659517e0faed5c6766715ee.jpg

Соглашусь с теми, кто скажет, что разбиение UntiTest«ов по suit«ам — это не оптимальное решение. Но даже такое решение, позволившие распараллелить выполнение UntiTest«ов, на момент внедрения, позволило укорить работу в три раза! На текущий день количество UntiTest«ов продолжает расти. Количество suit«ов выросло с 6 до 18.

Поскольку разбиение на suit«ы — это очень индивидуальное разбиение, зависящее от команды, бизнес направлений в проекте и т.п., поэтому мои цифры по оптимизации могут отличаться от Ваших.  Однозначно, решение отказаться от разбиения по suit«ам, и использовать статистику, ведет к уменьшению времени исполнения всех UntiTest«ов.

В моем случае время выполнения UntiTest«ов сократилось примерно на 30%. Такое небольшое ускорение, вызвано тем, что периодически проводится ревизия и ручное разбиение suit«ов, время исполнения, которых существенно отличается от других.

Мне кажется, что это очень неплохой результат, так как данная оптимизация позволяет исключить ручное вмешательство разработчиков. Считаю необходимым расширять свои навыки, уходить от шаблонных решений при использовании PL\SQL.

P.S. Замечания и предложения только приветствуются.

P.S. S. Старался использовать в статье, как можно меньше больших кусков кода, но избежать этого не удалось. Если будет запрос, постараюсь выложить весь код на GitHub.

© Habrahabr.ru