Этот загадочный TIBStoredProc, или история танца с бубном

Там знак висит, "кирпич", там обрыв,
но ВАМ туда можно.
(из Семёна Альтова)

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

Сам по себе компонент был бы не страшен и даже удобен, проблема состоит в том, что в клиентской библиотеке gds32.dll от Борланд с незапамятных времен имеется баг оптимизации выполнения вызова API isc_dsql_execute2, состоящий в том, что информация о выходных параметрах хранимой процедуры после первого же её вызова теряется и, если в рамках одной транзакции выполняется несколько процедур, то зона памяти, выделенная под эти параметры, может использоваться неправильно. В результате возникают исключения различных типов – Access Violation in gds32 или несколько типов исключений с невразумительными сообщениями, имеющими общность в том, что все они говорят о неправильном XSQLDA.

Ещё в FIBC от Gregory H.Deatz содержался код, осуществляющий наивную попытку обойти исключение, сделав вид, что его не было. По информации, полученной от Сергея Бузаджи, этот код появился в период бесхозности набора компонент, когда автор уже заявил о том, что прекращает его поддержку, и является заплатой неизвестного автора. Выглядела эта заплата следующим образом (фрагмент TIBSQL.ExecQuery из ibsql.pas):
SQLExecProcedure:
begin
fetch_res := Call(isc_dsql_execute2(StatusVector, TRHandle, @FHandle,
Database.SQLDialect, FSQLParams.AsXSQLDA,
FSQLRecord.AsXSQLDA), False);
if (fetch_res <> 0) and (fetch_res <> isc_deadlock) then
begin
{ Sometimes a prepared stored procedure appears to get
off sync on the server ....This code is meant to try
to work around the problem simply by "retrying". This
need to be reproduced and fixed.
}
isc_dsql_prepare(StatusVector, TRHandle, @FHandle, 0,
PChar(FProcessedSQL.Text), 1, nil);
Call(isc_dsql_execute2(StatusVector, TRHandle, @FHandle,
Database.SQLDialect, FSQLParams.AsXSQLDA,
FSQLRecord.AsXSQLDA), False);
end;
end;

То есть, если попытка выполнить процедуру заканчивается исключением, любым, кроме isc_deadlock (которым в InterBase не совсем корректно называется любой конфликт блокировок), втихомолку осуществляется повтор попытки и, чем бы это ни кончилось, управление спокойно передаётся пользовательской программе, которая продолжает работу, не подозревая о том, что в случае исключения в gds32 процедура на самом деле выполнилась два раза.

Слава богу, повторный вызов практически никогда не "помогает" и исключение возникает повторно. Если пользовательская программа реагирует на него RollBack (а обычно так и бывает, поскольку баг gds32.dll, вокруг которого ведутся эти танцы с бубном, проявляется при вызове двух и более процедур с разным количеством параметров в одной транзакции), то ничего страшного не происходит, за исключением траты времени и нервов на поиск причин и объяснения с конечным пользователем. Но если случайно память при работе с выходными параметрами после повторного вызова оказывается заполненой таким образом, что её содержимое интерпретируется без возникновения исключения, то что? Правильно, процедура на самом деле выполнилась два раза, исключение-то возникает на клиенте, при работе с возвращёнными успешно выполненной процедурой параметрами. Самое печальное то, что мы-то об этом ничего не знаем.

Если сама процедура или инициируемые ей триггерные цепочки делают что-то вроде
Update SomeTable Set SomeAttribute=2
то опять же ничего страшного, а если
Update SomeTable Set SomeAttribute=SomeAttribute+2 ?

Снова мы догадались – у нас втихомолку испорчены данные.

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

В точно таком же виде этот фрагмент кода перекочевал в IBX4.2, распространявшийся в составе первых выпусков Delphi 5. Через некоторое время разработчики прикладных приложений заметили, что с процедурами что-то не так и стали рекомендовать друг другу не пользоваться ими вообще, потому что там что-то не то, а что – непонятно. Наиболее многочисленные баг-репорты вокруг TIBStoredProc формулировались "компонент не замечает лок-конфликтов", поскольку их возникновение – рутинное явление в жизни приложений, обслуживаемое клиентской частью. В результате в версии 4.42 код был пересмотрен, но довольно занятно – было всего лишь добавлено извещение приложений о конфликтах:
SQLExecProcedure:
begin
fetch_res := Call(isc_dsql_execute2(StatusVector, TRHandle, @FHandle,
Database.SQLDialect, FSQLParams.AsXSQLDA,
FSQLRecord.AsXSQLDA), False);
if (fetch_res <> 0) then
begin
if (fetch_res <> isc_lock_conflict) then
begin
{ Sometimes a prepared stored procedure appears to get
off sync on the server ....This code is meant to try
to work around the problem simply by "retrying". This
need to be reproduced and fixed.
}
isc_dsql_prepare(StatusVector, TRHandle, @FHandle, 0,
PChar(FProcessedSQL.Text), Database.SQLDialect, nil);
Call(isc_dsql_execute2(StatusVector, TRHandle, @FHandle,
Database.SQLDialect, FSQLParams.AsXSQLDA,
FSQLRecord.AsXSQLDA), True);
end
else
IBDataBaseError; // go ahead and raise the lock conflict
end;

Следует обратить внимание, что здесь включена обработка не isc_deadlock, а isc_lock_conflict. Документация по InterBase 6 гласит:
isc_deadlock – Deadlock:
Transaction conflicted with another transaction; wait and try again
isc_lock_conflict – Lock conflict:
Transaction unable to obtain the locks it needed; wait and try again

Причины, по которым сделана замена, остаются неясными. На этом прогресс IBX в данном направлении можно считать закончившимся, в наиболее поздних известных автору статьи версиях этот код изменён только в плане корректности принудительного Prepare перед повторным вызовом (указан параметр Database.SQLDialect)
SQLExecProcedure:
begin
fetch_res := Call(isc_dsql_execute2(StatusVector, TRHandle, @FHandle,
Database.SQLDialect, FSQLParams.AsXSQLDA,
FSQLRecord.AsXSQLDA), False);
if (fetch_res <> 0) then
begin
if (fetch_res <> isc_lock_conflict) then
begin
{ Sometimes a prepared stored procedure appears to get
off sync on the server ....This code is meant to try
to work around the problem simply by "retrying". This need
to be reproduced and fixed.
}
isc_dsql_prepare(StatusVector, TRHandle, @FHandle, 0,
PChar(FProcessedSQL.Text), Database.SQLDialect, nil);
Call(isc_dsql_execute2(StatusVector, TRHandle, @FHandle,
Database.SQLDialect, FSQLParams.AsXSQLDA,
FSQLRecord.AsXSQLDA), True);
end
else
IBDataBaseError; // go ahead and raise the lock conflict
end;

В gds32.dll Firebird первопричина всех неприятностей была устранена Ann W.Harrison где-то в районе 606 билда, это ноябрь 2001 года, Борланд и разработчик IBX были поставлены в известность и о самой причине и о недопустимости повторного вызова. Однако, по-видимому, кому-то в Борланд этот баг gds32 дорог как воспоминание молодости и он не исправлен и по сей день (похоже, что баг таки исправлен в IB 7.01, в январе 2003 года – http://qc.borland.com/wc/qcmain.aspx?d=3652. При этом код в IBX остался тем же самым и в IBX 7.09). Разработчик IBX оказался в достаточно сложном положении – с одной стороны, из корпоративной солидарности нельзя шуметь о баге, который он лично исправить не может, с другой – на претензии пользователей по странным исключениям при работе с процедурами надо реагировать. Повторный вызов, способный приносить только вред, но маскирующий несколько процентов исключений в gds32.dll, оставлен всё-таки на месте. Владельцы Борланд InterBase и, соответсвенно, gds32.dll от того же производителя могут проверить эффективность данного приёма на своих экземплярах комплекса IBX-gds32 путём простого теста – создать две процедуры:
Create Procedure P1(IPar Integer)
Returns (OPar Integer)
As
Begin
Opar=IPar;
End
Create Procedure P2(IPar Integer)
Returns (OPar1 Integer, OPar2 Integer)
As
Begin
OPar1=IPar;
OPar2=IPar+1;
End
И выполнить в приложении
Procedure TestSPCall;
Var I: Integer;
begin
IBTransaction1.StartTransaction;
For I:=1 to 10 do
begin
IBStoredProc1.ParamByName('IPar').AsInteger:=I;
// IBStoredProc1.Prepare;
IBStoredProc1.ExecProc;
// IBStoredProc1.UnPrepare;
IBStoredProc2.ParamByName('IPar').AsInteger:=I;
// IBStoredProc2.Prepare;
IBStoredProc2.ExecProc;
// IBStoredProc2.UnPrepare;
end;
IBTransaction1.Commit;
end
Здесь закомментированы безуспешные попытки воспользоваться настойчиво рекомендуемым представителями TeamB бубном где-то в том же ноябре 2001 года, во время подготовки простого и воспроизводимого баг-репорта для команды Firebird. Можно и раскомментировать.
 

Резюме

Если есть желание спать спокойно, любому владельцу IBX рекомендуется привести вызов хранимых процедур к виду
SQLExecProcedure:
begin
fetch_res := Call(isc_dsql_execute2(StatusVector, TRHandle, @FHandle,
Database.SQLDialect, FSQLParams.AsXSQLDA,
FSQLRecord.AsXSQLDA), True);
end;
то есть, ликвидировать повторный вызов, и перекомпилировать IBX и использующие его пакеты и программы. При этом разработчики на Firebird и Yaffil не заметят вообще никаких отрицательных эффектов, если, конечно, не обращаются к своим серверам через gds32.dll от Борланд (версий ниже 7.01, см. примечание выше), разработчики на InterBase будут на несколько процентов чаще получать загадочные AV in gds32, Wrong XSQLDA version, Bad or missed transaction handle и иже с ними. Но будут гарантированы от скрытых искажений данных. А ещё лучше разработчикам на InterBase отказаться от использования TIBStoredProc вообще, переписать свои вызываемые с клиента исполняемые хранимые процедуры на селективные и обращаться к ним
Select * From MyProcedure(...)
через TIBSQL или другие компоненты.

С наилучшими,
Александр Невский
Дополнение KDV:
Впервые с подобной ошибкой (incorrect XSQLDA version) я столкнулся еще при работе с BDE, примерно в 1997 году, и результатом стал совет не пользоваться prepare/unprepare при работе с TStoredProc. Впоследствии эта привычка перешла на компоненты прямого доступа, но, как видите, с ними в этом плане ситуация оказалась хуже (очевидно, в BDE (sqlint32.dll) до самой последней версии никому не приходило в голову устраивать "повторные вызовы" процедур).

Кроме этой ошибки тогда же обнаружилось, что через BDE.TStoredProc невозможно передавать значения null в параметрах. Я сейчас уже точно не помню, к какой ошибке это приводило, и исправлено ли это в нынешнем BDE, но первой идеей обхода проблемы была замена null на '', что отнюдь не является идеальным решением. В итоге TStoredProc был выкинут, во все процедуры, даже возвращающие только одно значение, был добавлен SUSPEND, и их вызов осуществлялся только через TQuery (select * from procedure).

Подпишитесь на новости Firebird в России

Подписаться