Учёт DEPO: шаг «Обработка» проверяет запуск джобы (есть, включена, расписание, запускалась недавно — по msdb), работа процедур (BJ_PING) — отдельной проверкой «Обработка событий»; логину bj — SQLAgentReaderRole для проверки

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-10-05 14:33:34 +03:00
parent 4beb42ed45
commit e31e35e663
6 changed files with 165 additions and 36 deletions
+139 -30
View File
@@ -777,7 +777,12 @@ END
ELSE
PRINT N'Джоба %[1]s уже есть — не изменялась.';
GO
`, depoJobName, f.DepoDB(), depoJobProcName(f), depoJobEvery(f))
-- Логину bj — чтение списка джоб и их истории: bj проверяет, что джоба запускается по расписанию.
IF USER_ID(N'%[5]s') IS NULL CREATE USER [%[5]s] FOR LOGIN [%[5]s];
IF IS_ROLEMEMBER(N'SQLAgentReaderRole', N'%[5]s') = 0 ALTER ROLE SQLAgentReaderRole ADD MEMBER [%[5]s];
GO
`, depoJobName, f.DepoDB(), depoJobProcName(f), depoJobEvery(f), pickStr(strings.TrimSpace(f.User), "bj_user"))
}
// depoJobGrantScript — права для варианта «внутри bj».
@@ -807,11 +812,121 @@ func depoJobPing(ctx context.Context, db *sql.DB, wait time.Duration) (bool, str
}
time.Sleep(3 * time.Second)
}
return false, fmt.Sprintf("проверочное событие BJ_PING не обработано за %s. Проверьте по порядку: 1) в базе DEPO есть процедуры обработки (FTV_M2M_JOB и FTV_M2M_PROCESS_*) — без них джоба запускается и падает; 2) джоба bj2depo есть и включена, служба SQL Server Agent запущена (история джобы в SSMS покажет ошибку); 3) пользователь, от имени которого работают процедуры (SYSDBA), есть в базе", wait)
return false, fmt.Sprintf("проверочное событие BJ_PING не обработано за %s — процедуры обработки не отработали. Проверьте: 1) в базе DEPO есть процедуры FTV_M2M_JOB и FTV_M2M_PROCESS_*; 2) пользователь, от имени которого они работают (SYSDBA), есть в базе; 3) ошибку последнего запуска джобы — в её истории в SSMS", wait)
}
// depoJobCheck — проверка готовности выбранного способа обработки.
func depoJobCheck(ctx context.Context, rc *RuntimeConfig) (string, bool) {
return depoJobRunnerCheck(ctx, rc, "", "")
}
// depoJobRunnerCheck — запускалка обработки (05.10.2026: шаг «Обработка» проверяет только её, а не процедуры):
// SQL Agent — джоба есть, включена, есть расписание и она запускалась недавно; «bj сам» — право EXECUTE.
// adminUser/adminPass — разовый логин администратора (если введён), иначе — логин bj (нужен SQLAgentReaderRole в msdb).
func depoJobRunnerCheck(ctx context.Context, rc *RuntimeConfig, adminUser, adminPass string) (string, bool) {
f := rc.Snapshot().Fancy
switch f.DepoJobMode {
case "bj":
db, err := depoEventsDB(rc)
if err != nil {
return "подключение к DEPO: " + err.Error(), false
}
defer db.Close()
var n int
_ = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM fn_my_permissions(@p,'OBJECT') WHERE permission_name='EXECUTE'", sql.Named("p", depoJobProcName(f))).Scan(&n)
if n == 0 {
return "у логина " + f.User + " нет права EXECUTE на " + depoJobProcName(f) + " — выполните на сервере: " + strings.ReplaceAll(depoJobGrantScript(f), "\n", " "), false
}
return fmt.Sprintf("право на %s есть; bj будет вызывать её каждые %d с", depoJobProcName(f), depoJobEvery(f)), true
case "sql-auto", "sql-manual":
return depoAgentJobStatus(ctx, f, adminUser, adminPass)
}
return "способ обработки не выбран", false
}
// depoAgentJobStatus — состояние джобы SQL Agent по msdb.
func depoAgentJobStatus(ctx context.Context, f FancyMSSQLSettings, adminUser, adminPass string) (string, bool) {
conn := f
if strings.TrimSpace(adminUser) != "" {
conn.User, conn.Password = strings.TrimSpace(adminUser), adminPass
}
mdb, err := fancyOpen(conn, "msdb")
if err != nil {
return "подключение к msdb: " + err.Error(), false
}
defer mdb.Close()
var (
jobEnabled, schedEnabled, subType, subInt sql.NullInt64
runDate, runTime, runStatus sql.NullInt64
runMsg sql.NullString
now time.Time
)
err = mdb.QueryRowContext(ctx, `SELECT j.enabled, s.enabled, s.freq_subday_type, s.freq_subday_interval,
h.run_date, h.run_time, h.run_status, h.message, GETDATE()
FROM msdb.dbo.sysjobs j
LEFT JOIN msdb.dbo.sysjobschedules js ON js.job_id = j.job_id
LEFT JOIN msdb.dbo.sysschedules s ON s.schedule_id = js.schedule_id
OUTER APPLY (SELECT TOP 1 run_date, run_time, run_status, message FROM msdb.dbo.sysjobhistory
WHERE job_id = j.job_id AND step_id = 0 ORDER BY instance_id DESC) h
WHERE j.name = @n`, sql.Named("n", depoJobName)).Scan(&jobEnabled, &schedEnabled, &subType, &subInt, &runDate, &runTime, &runStatus, &runMsg, &now)
if err == sql.ErrNoRows {
return "джобы " + depoJobName + " на сервере нет — создайте её (вариант «создаёт bj» с логином администратора или скрипт для администратора)", false
}
if err != nil {
return "список джоб недоступен логину " + conn.User + " (" + quikErrorDetail(err) + "). Чтобы bj мог проверять джобу, администратору SQL — один раз: " + strings.ReplaceAll(depoJobReaderGrant(f), "\n", " "), false
}
if jobEnabled.Int64 != 1 {
return "джоба " + depoJobName + " есть, но ВЫКЛЮЧЕНА — включите её в SQL Server Agent", false
}
if !schedEnabled.Valid || schedEnabled.Int64 != 1 {
return "у джобы " + depoJobName + " нет включённого расписания — она не будет запускаться", false
}
every := time.Duration(depoJobEvery(f)) * time.Second
if subType.Int64 == 2 && subInt.Int64 > 0 { // каждые N секунд
every = time.Duration(subInt.Int64) * time.Second
} else if subType.Int64 == 4 && subInt.Int64 > 0 { // каждые N минут
every = time.Duration(subInt.Int64) * time.Minute
}
sched := fmt.Sprintf("джоба %s включена, запускается каждые %s", depoJobName, humanEvery(every))
if !runDate.Valid || runDate.Int64 == 0 {
return sched + ", но ещё ни разу не запускалась — запущена ли служба SQL Server Agent?", false
}
d, t := runDate.Int64, runTime.Int64
last := time.Date(int(d/10000), time.Month(d/100%100), int(d%100), int(t/10000), int(t/100%100), int(t%100), 0, now.Location())
age := now.Sub(last)
limit := 3 * every
if limit < time.Minute {
limit = time.Minute
}
if age > limit {
return fmt.Sprintf("%s, но последний запуск был %s (%s назад) — запущена ли служба SQL Server Agent?", sched, last.Format("02.01 15:04:05"), humanDur(age)), false
}
msg := fmt.Sprintf("%s; последний запуск %s (%d с назад)", sched, last.Format("15:04:05"), int(age.Seconds()))
if runStatus.Int64 == 0 { // запуск есть, но обработка внутри упала — это не про джобу
txt := strings.TrimSpace(runMsg.String)
if r := []rune(txt); len(r) > 300 {
txt = string(r[:300]) + "…"
}
msg += ". Запуск завершился ошибкой обработки — это уже не про джобу, а про процедуры (см. «Обработка событий»): " + txt
}
return msg, true
}
func humanEvery(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%d с", int(d.Seconds()))
}
return fmt.Sprintf("%d мин", int(d.Minutes()))
}
// depoJobReaderGrant — право логину bj видеть джобы и их историю (для проверки джобы из bj).
func depoJobReaderGrant(f FancyMSSQLSettings) string {
return fmt.Sprintf("USE msdb;\nIF USER_ID(N'%[1]s') IS NULL CREATE USER [%[1]s] FOR LOGIN [%[1]s];\nIF IS_ROLEMEMBER(N'SQLAgentReaderRole', N'%[1]s') = 0 ALTER ROLE SQLAgentReaderRole ADD MEMBER [%[1]s];", f.User)
}
// depoProcCheck — работает ли сама обработка событий (процедуры FTV_M2M_*): таблицы, пользователь
// процедур, ошибки обработчика, проверочное событие BJ_PING. Отдельно от запускалки (джобы).
func depoProcCheck(ctx context.Context, rc *RuntimeConfig) (string, bool) {
f := rc.Snapshot().Fancy
db, err := depoEventsDB(rc)
if err != nil {
@@ -819,7 +934,7 @@ func depoJobCheck(ctx context.Context, rc *RuntimeConfig) (string, bool) {
}
defer db.Close()
if !depoTableExists(ctx, db, "FTV_M2M_EVENTS") {
return "в базе " + f.DepoDB() + " нет таблиц FTV_M2M* — создайте их: Настройка → Обмен Fancy → «Трансферные таблицы DEPO»", false
return "в базе " + f.DepoDB() + " нет таблиц FTV_M2M*", false
}
if ok, msg := depoRunAsCheck(ctx, db, f); !ok {
return msg, false
@@ -828,37 +943,13 @@ func depoJobCheck(ctx context.Context, rc *RuntimeConfig) (string, bool) {
return fmt.Sprintf("обработчик за последние сутки не смог обработать записей: %d (последняя: %s %s — %s). Устраните причину и верните их в очередь: Обмен с DEPO → «Вернуть в очередь»",
len(fails), fails[0].TableRu(), fails[0].Type, fails[0].Comment), false
}
switch f.DepoJobMode {
case "bj":
var n int
_ = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM fn_my_permissions(@p,'OBJECT') WHERE permission_name='EXECUTE'", sql.Named("p", depoJobProcName(f))).Scan(&n)
if n == 0 {
return "у логина " + f.User + " нет права EXECUTE на " + depoJobProcName(f) + " — выполните на сервере: " + strings.ReplaceAll(depoJobGrantScript(f), "\n", " "), false
}
start := time.Now()
if f.DepoJobMode == "bj" {
if _, err := execProc(ctx, db, depoJobProcName(f)); err != nil {
return "вызов " + depoJobProcName(f) + ": " + quikErrorDetail(err), false
}
return fmt.Sprintf("право есть, процедура %s выполнена за %s; bj будет вызывать её каждые %d с", depoJobProcName(f), time.Since(start).Round(time.Millisecond), depoJobEvery(f)), true
case "sql-auto", "sql-manual":
// 1) джоба видна через msdb?
var seen string
if mdb, err := fancyOpen(f, "msdb"); err == nil {
var enabled int
if e := mdb.QueryRowContext(ctx, "SELECT enabled FROM msdb.dbo.sysjobs WHERE name=@n", sql.Named("n", depoJobName)).Scan(&enabled); e == nil {
if enabled == 1 {
seen = "джоба " + depoJobName + " есть и включена; "
} else {
seen = "джоба " + depoJobName + " есть, но ВЫКЛЮЧЕНА; "
}
}
mdb.Close()
}
// 2) реально ли обрабатывает: BJ_PING
ok, msg := depoJobPing(ctx, db, 40*time.Second)
return seen + msg, ok
}
return "способ обработки не выбран", false
ok, msg := depoJobPing(ctx, db, 40*time.Second)
return msg, ok
}
// depoJobCreateAgent — создать джобу SQL Agent от имени логина bj (нужна роль SQLAgentOperatorRole).
@@ -880,6 +971,7 @@ func depoJobCreateAgent(ctx context.Context, rc *RuntimeConfig, adminUser, admin
return fmt.Errorf("нет доступа к msdb (%s). Нужны права: %s", quikErrorDetail(err), strings.ReplaceAll(depoJobAgentRightsScript(f), "\n", " "))
}
if n > 0 {
depoGrantJobReader(ctx, mdb, f) // джоба осталась (msdb не восстанавливали) — права на проверку всё равно выдаём
return fmt.Errorf("джоба %s уже существует", depoJobName)
}
for _, stmt := range strings.Split(depoJobAgentScript(f), "\n") {
@@ -892,9 +984,26 @@ func depoJobCreateAgent(ctx context.Context, rc *RuntimeConfig, adminUser, admin
}
}
log.Printf("depo-job: джоба SQL Agent %s создана", depoJobName)
depoGrantJobReader(ctx, mdb, f)
return nil
}
// depoGrantJobReader — пока есть логин администратора: логину bj — чтение списка джоб (для проверки).
func depoGrantJobReader(ctx context.Context, mdb *sql.DB, f FancyMSSQLSettings) {
if !depoLoginRe.MatchString(f.User) {
return
}
for _, q := range strings.Split(depoJobReaderGrant(f), "\n") {
if strings.HasPrefix(q, "USE ") {
continue
}
if _, err := mdb.ExecContext(ctx, q); err != nil {
log.Printf("depo-job: право SQLAgentReaderRole логину %s: %s", f.User, quikErrorDetail(err))
return
}
}
}
// DepoJobView — данные карточки «Где запускается обработка».
type DepoJobView struct {
Name, Proc, GrantScript, AgentRights, AgentScript string