Могут ли девять женщин родить ребёнка за один месяц...?
Старинная индейская мудрость.
Быстрое развитие проекта несет в себе множество сложностей — большая вероятность сломать старый функционал или привнести новые баги. Одним из способов поддержания качества кода в хорошем состоянии, является наличие 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:
Представление, получающее разбиение 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);
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;
.................
Результат оптимизации:
Соглашусь с теми, кто скажет, что разбиение UntiTest’ов по suit’ам – это не оптимальное решение. Но даже такое решение, позволившие распараллелить выполнение UntiTest’ов, на момент внедрения, позволило укорить работу в три раза! На текущий день количество UntiTest’ов продолжает расти. Количество suit’ов выросло с 6 до 18.
Поскольку разбиение на suit’ы – это очень индивидуальное разбиение, зависящее от команды, бизнес направлений в проекте и т.п., поэтому мои цифры по оптимизации могут отличаться от Ваших. Однозначно, решение отказаться от разбиения по suit’ам, и использовать статистику, ведет к уменьшению времени исполнения всех UntiTest’ов.
В моем случае время выполнения UntiTest’ов сократилось примерно на 30%. Такое небольшое ускорение, вызвано тем, что периодически проводится ревизия и ручное разбиение suit’ов, время исполнения, которых существенно отличается от других.
Мне кажется, что это очень неплохой результат, так как данная оптимизация позволяет исключить ручное вмешательство разработчиков. Считаю необходимым расширять свои навыки, уходить от шаблонных решений при использовании PL\SQL.
P.S. Замечания и предложения только приветствуются.
P.S.S. Старался использовать в статье, как можно меньше больших кусков кода, но избежать этого не удалось. Если будет запрос, постараюсь выложить весь код на GitHub.