441 lines
19 KiB
Go
441 lines
19 KiB
Go
package outboxarchiverestore
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "github.com/go-sql-driver/mysql"
|
|
)
|
|
|
|
const (
|
|
RehearsalTable = "wallet_outbox_restore_rehearsals_v1"
|
|
RowsTable = "wallet_outbox_restore_rows_v1"
|
|
restoreState = "RESTORING"
|
|
)
|
|
|
|
// RestoreResult is safe to print: it identifies the selected schema and fixed
|
|
// table contract but never includes the DSN or credentials used to reach it.
|
|
type RestoreResult struct {
|
|
State string `json:"state"`
|
|
RehearsalID string `json:"rehearsal_id"`
|
|
BatchID string `json:"batch_id"`
|
|
AppCode string `json:"app_code"`
|
|
RowCount int `json:"row_count"`
|
|
TargetDatabase string `json:"target_database"`
|
|
RehearsalTable string `json:"rehearsal_table"`
|
|
RowsTable string `json:"rows_table"`
|
|
DataObjectKey string `json:"data_object_key"`
|
|
FirstCursor Cursor `json:"first_cursor"`
|
|
LastCursor Cursor `json:"last_cursor"`
|
|
NDJSONSHA256 string `json:"ndjson_sha256"`
|
|
GzipSHA256 string `json:"gzip_sha256"`
|
|
DataCRC64 string `json:"data_crc64"`
|
|
ManifestSHA256 string `json:"manifest_sha256"`
|
|
ManifestCRC64 string `json:"manifest_crc64"`
|
|
SuccessSHA256 string `json:"success_sha256"`
|
|
SuccessCRC64 string `json:"success_crc64"`
|
|
AvailableTotal string `json:"available_delta_total"`
|
|
FrozenTotal string `json:"frozen_delta_total"`
|
|
VerifiedAtMS int64 `json:"verified_at_ms"`
|
|
IdempotentReadback bool `json:"idempotent_readback"`
|
|
}
|
|
|
|
// Restore persists a fully verified batch into versioned isolation tables. All
|
|
// DDL targets new/fixed table names; all DML is scoped by rehearsal PK. It never
|
|
// reads, locks, updates, or deletes wallet_outbox.
|
|
func Restore(ctx context.Context, dsn, expectedDatabase, rehearsalID string, verification Verification) (RestoreResult, error) {
|
|
dsn = strings.TrimSpace(dsn)
|
|
expectedDatabase = strings.TrimSpace(expectedDatabase)
|
|
rehearsalID = strings.TrimSpace(rehearsalID)
|
|
if dsn == "" || expectedDatabase == "" {
|
|
return RestoreResult{}, errors.New("restore DSN and exact expected database are required")
|
|
}
|
|
if !validRehearsalID(rehearsalID) {
|
|
return RestoreResult{}, errors.New("rehearsal_id must be 1..64 ASCII letters, digits, dot, underscore, or dash")
|
|
}
|
|
if verification.State != filesVerifiedState || len(verification.Records) != verification.Manifest.RowCount {
|
|
return RestoreResult{}, errors.New("local archive verification is incomplete")
|
|
}
|
|
|
|
db, err := sql.Open("mysql", dsn)
|
|
if err != nil {
|
|
return RestoreResult{}, err
|
|
}
|
|
defer db.Close()
|
|
// A rehearsal is intentionally single-connection and serial; this prevents a
|
|
// restore tool from consuming the production-sized connection pool by mistake.
|
|
db.SetMaxOpenConns(1)
|
|
db.SetMaxIdleConns(1)
|
|
db.SetConnMaxLifetime(10 * time.Minute)
|
|
conn, err := db.Conn(ctx)
|
|
if err != nil {
|
|
return RestoreResult{}, err
|
|
}
|
|
defer conn.Close()
|
|
|
|
var databaseName string
|
|
if err := conn.QueryRowContext(ctx, `SELECT DATABASE()`).Scan(&databaseName); err != nil {
|
|
return RestoreResult{}, err
|
|
}
|
|
if databaseName != expectedDatabase {
|
|
return RestoreResult{}, fmt.Errorf("restore database mismatch: connected=%q expected=%q", databaseName, expectedDatabase)
|
|
}
|
|
if err := ensureIsolationSchema(ctx, conn); err != nil {
|
|
return RestoreResult{}, err
|
|
}
|
|
release, acquired, err := acquireRestoreLock(ctx, conn, rehearsalID)
|
|
if err != nil {
|
|
return RestoreResult{}, err
|
|
}
|
|
if !acquired {
|
|
return RestoreResult{}, errors.New("another process holds the same restore rehearsal lock")
|
|
}
|
|
defer release()
|
|
|
|
if existing, found, err := readExistingRestore(ctx, conn, rehearsalID, verification); err != nil {
|
|
return RestoreResult{}, err
|
|
} else if found {
|
|
return existing, nil
|
|
}
|
|
|
|
tx, err := conn.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
|
|
if err != nil {
|
|
return RestoreResult{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
nowMS := time.Now().UTC().UnixMilli()
|
|
if err := insertRehearsal(ctx, tx, rehearsalID, databaseName, verification, nowMS); err != nil {
|
|
return RestoreResult{}, err
|
|
}
|
|
for offset := 0; offset < len(verification.Records); offset += 100 {
|
|
end := min(offset+100, len(verification.Records))
|
|
if err := insertRestoreRows(ctx, tx, rehearsalID, verification.Manifest.BatchID, nowMS, verification.Records[offset:end]); err != nil {
|
|
return RestoreResult{}, err
|
|
}
|
|
}
|
|
// These aggregates only scan one bounded rehearsal partition in the isolation
|
|
// table. They never aggregate the live outbox and are the database-level proof
|
|
// that JSON conversion and INSERT semantics preserved the archive controls.
|
|
if err := verifyRestoredControls(ctx, tx, rehearsalID, verification); err != nil {
|
|
return RestoreResult{}, err
|
|
}
|
|
if err := verifyRestoredRowsExact(ctx, tx, rehearsalID, verification.Records); err != nil {
|
|
return RestoreResult{}, err
|
|
}
|
|
result := newRestoreResult(rehearsalID, databaseName, verification, nowMS, false)
|
|
update, err := tx.ExecContext(ctx, `
|
|
UPDATE wallet_outbox_restore_rehearsals_v1
|
|
SET state = ?, verified_at_ms = ?, updated_at_ms = ?
|
|
WHERE rehearsal_id = ? AND state = ?`, restoreVerifiedState, nowMS, nowMS, rehearsalID, restoreState)
|
|
if err != nil {
|
|
return RestoreResult{}, err
|
|
}
|
|
if affected, err := update.RowsAffected(); err != nil || affected != 1 {
|
|
return RestoreResult{}, errors.New("restore rehearsal state transition did not affect exactly one row")
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return RestoreResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func ensureIsolationSchema(ctx context.Context, conn *sql.Conn) error {
|
|
// CREATE IF NOT EXISTS takes metadata locks only on the dedicated table names;
|
|
// unlike ALTER wallet_outbox it cannot rebuild or block the hot owner table.
|
|
statements := []string{`
|
|
CREATE TABLE IF NOT EXISTS wallet_outbox_restore_rehearsals_v1 (
|
|
rehearsal_id VARCHAR(64) NOT NULL,
|
|
source_app_code VARCHAR(32) NOT NULL,
|
|
source_batch_id CHAR(64) NOT NULL,
|
|
state VARCHAR(32) NOT NULL,
|
|
schema_version INT NOT NULL,
|
|
data_object_key VARCHAR(512) NOT NULL,
|
|
row_count INT NOT NULL,
|
|
first_updated_at_ms BIGINT NOT NULL,
|
|
first_event_id VARCHAR(128) NOT NULL,
|
|
last_updated_at_ms BIGINT NOT NULL,
|
|
last_event_id VARCHAR(128) NOT NULL,
|
|
available_delta_total VARCHAR(80) NOT NULL,
|
|
frozen_delta_total VARCHAR(80) NOT NULL,
|
|
ndjson_sha256 CHAR(64) NOT NULL,
|
|
gzip_sha256 CHAR(64) NOT NULL,
|
|
data_crc64 VARCHAR(32) NOT NULL,
|
|
manifest_sha256 CHAR(64) NOT NULL,
|
|
manifest_crc64 VARCHAR(32) NOT NULL,
|
|
success_sha256 CHAR(64) NOT NULL,
|
|
success_crc64 VARCHAR(32) NOT NULL,
|
|
target_database VARCHAR(128) NOT NULL,
|
|
verified_at_ms BIGINT NOT NULL,
|
|
created_at_ms BIGINT NOT NULL,
|
|
updated_at_ms BIGINT NOT NULL,
|
|
PRIMARY KEY (rehearsal_id),
|
|
KEY idx_wallet_outbox_restore_batch (source_app_code, source_batch_id, state)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='wallet outbox archive restore rehearsal evidence v1'`, `
|
|
CREATE TABLE IF NOT EXISTS wallet_outbox_restore_rows_v1 (
|
|
rehearsal_id VARCHAR(64) NOT NULL,
|
|
source_batch_id CHAR(64) NOT NULL,
|
|
app_code VARCHAR(32) NOT NULL,
|
|
event_id VARCHAR(128) NOT NULL,
|
|
event_type VARCHAR(64) NOT NULL,
|
|
transaction_id VARCHAR(96) NOT NULL,
|
|
command_id VARCHAR(128) NOT NULL,
|
|
user_id BIGINT NOT NULL,
|
|
asset_type VARCHAR(32) NOT NULL,
|
|
available_delta BIGINT NOT NULL,
|
|
frozen_delta BIGINT NOT NULL,
|
|
payload JSON NOT NULL,
|
|
status VARCHAR(32) NOT NULL,
|
|
worker_id VARCHAR(128) NOT NULL,
|
|
lock_until_ms BIGINT NULL,
|
|
retry_count INT NOT NULL,
|
|
next_retry_at_ms BIGINT NULL,
|
|
last_error TEXT NULL,
|
|
created_at_ms BIGINT NOT NULL,
|
|
updated_at_ms BIGINT NOT NULL,
|
|
restored_at_ms BIGINT NOT NULL,
|
|
PRIMARY KEY (rehearsal_id, app_code, event_id),
|
|
KEY idx_wallet_outbox_restore_cursor (rehearsal_id, app_code, updated_at_ms, event_id),
|
|
KEY idx_wallet_outbox_restore_batch (source_batch_id, app_code, event_id)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='isolated wallet_outbox-compatible restored rows v1'`}
|
|
for _, statement := range statements {
|
|
if _, err := conn.ExecContext(ctx, statement); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func insertRehearsal(ctx context.Context, tx *sql.Tx, rehearsalID, databaseName string, verification Verification, nowMS int64) error {
|
|
manifest := verification.Manifest
|
|
_, err := tx.ExecContext(ctx, `
|
|
INSERT INTO wallet_outbox_restore_rehearsals_v1 (
|
|
rehearsal_id, source_app_code, source_batch_id, state, schema_version, data_object_key, row_count,
|
|
first_updated_at_ms, first_event_id, last_updated_at_ms, last_event_id,
|
|
available_delta_total, frozen_delta_total, ndjson_sha256, gzip_sha256, data_crc64,
|
|
manifest_sha256, manifest_crc64, success_sha256, success_crc64, target_database,
|
|
verified_at_ms, created_at_ms, updated_at_ms
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
|
|
rehearsalID, manifest.AppCode, manifest.BatchID, restoreState, manifest.SchemaVersion, manifest.DataObjectKey, manifest.RowCount,
|
|
manifest.FirstCursor.UpdatedAtMS, manifest.FirstCursor.EventID, manifest.LastCursor.UpdatedAtMS, manifest.LastCursor.EventID,
|
|
manifest.AvailableDeltaTotal, manifest.FrozenDeltaTotal, manifest.NDJSONSHA256, manifest.GzipSHA256, manifest.DataCRC64,
|
|
verification.ManifestSHA256, verification.ManifestCRC64, verification.SuccessSHA256, verification.SuccessCRC64, databaseName,
|
|
nowMS, nowMS)
|
|
return err
|
|
}
|
|
|
|
func insertRestoreRows(ctx context.Context, tx *sql.Tx, rehearsalID, batchID string, restoredAtMS int64, records []Record) error {
|
|
if len(records) == 0 || len(records) > 100 {
|
|
return errors.New("restore insert batch must contain 1..100 rows")
|
|
}
|
|
const columns = `(rehearsal_id, source_batch_id, app_code, event_id, event_type, transaction_id, command_id,
|
|
user_id, asset_type, available_delta, frozen_delta, payload, status, worker_id, lock_until_ms,
|
|
retry_count, next_retry_at_ms, last_error, created_at_ms, updated_at_ms, restored_at_ms)`
|
|
placeholders := make([]string, 0, len(records))
|
|
args := make([]any, 0, len(records)*21)
|
|
for _, record := range records {
|
|
placeholders = append(placeholders, `(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
args = append(args,
|
|
rehearsalID, batchID, record.AppCode, record.EventID, record.EventType, record.TransactionID, record.CommandID,
|
|
record.UserID, record.AssetType, record.AvailableDelta, record.FrozenDelta, record.PayloadJSON, record.Status, record.WorkerID,
|
|
nullInt64(record.LockUntilMS), record.RetryCount, nullInt64(record.NextRetryAtMS), nullString(record.LastError),
|
|
record.CreatedAtMS, record.UpdatedAtMS, restoredAtMS,
|
|
)
|
|
}
|
|
_, err := tx.ExecContext(ctx, `INSERT INTO wallet_outbox_restore_rows_v1 `+columns+` VALUES `+strings.Join(placeholders, ","), args...)
|
|
return err
|
|
}
|
|
|
|
func verifyRestoredControls(ctx context.Context, query interface {
|
|
QueryRowContext(context.Context, string, ...any) *sql.Row
|
|
}, rehearsalID string, verification Verification) error {
|
|
var count int
|
|
var availableTotal, frozenTotal string
|
|
var minCreated, maxCreated, minUpdated, maxUpdated int64
|
|
if err := query.QueryRowContext(ctx, `
|
|
SELECT COUNT(*),
|
|
CAST(COALESCE(SUM(available_delta), 0) AS CHAR),
|
|
CAST(COALESCE(SUM(frozen_delta), 0) AS CHAR),
|
|
COALESCE(MIN(created_at_ms), 0), COALESCE(MAX(created_at_ms), 0),
|
|
COALESCE(MIN(updated_at_ms), 0), COALESCE(MAX(updated_at_ms), 0)
|
|
FROM wallet_outbox_restore_rows_v1 FORCE INDEX (PRIMARY)
|
|
WHERE rehearsal_id = ?`, rehearsalID).Scan(&count, &availableTotal, &frozenTotal, &minCreated, &maxCreated, &minUpdated, &maxUpdated); err != nil {
|
|
return err
|
|
}
|
|
manifest := verification.Manifest
|
|
if count != manifest.RowCount || availableTotal != manifest.AvailableDeltaTotal || frozenTotal != manifest.FrozenDeltaTotal || minCreated != manifest.MinCreatedAtMS || maxCreated != manifest.MaxCreatedAtMS || minUpdated != manifest.MinUpdatedAtMS || maxUpdated != manifest.MaxUpdatedAtMS {
|
|
return errors.New("isolation-table row count, time bounds, or control totals do not match manifest")
|
|
}
|
|
var firstUpdated, lastUpdated int64
|
|
var firstEvent, lastEvent string
|
|
if err := query.QueryRowContext(ctx, `
|
|
SELECT updated_at_ms, event_id FROM wallet_outbox_restore_rows_v1 FORCE INDEX (idx_wallet_outbox_restore_cursor)
|
|
WHERE rehearsal_id = ? AND app_code = ? ORDER BY updated_at_ms ASC, event_id ASC LIMIT 1`, rehearsalID, manifest.AppCode).Scan(&firstUpdated, &firstEvent); err != nil {
|
|
return err
|
|
}
|
|
if err := query.QueryRowContext(ctx, `
|
|
SELECT updated_at_ms, event_id FROM wallet_outbox_restore_rows_v1 FORCE INDEX (idx_wallet_outbox_restore_cursor)
|
|
WHERE rehearsal_id = ? AND app_code = ? ORDER BY updated_at_ms DESC, event_id DESC LIMIT 1`, rehearsalID, manifest.AppCode).Scan(&lastUpdated, &lastEvent); err != nil {
|
|
return err
|
|
}
|
|
if (Cursor{UpdatedAtMS: firstUpdated, EventID: firstEvent}) != manifest.FirstCursor || (Cursor{UpdatedAtMS: lastUpdated, EventID: lastEvent}) != manifest.LastCursor {
|
|
return errors.New("isolation-table cursor bounds do not match manifest")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func readExistingRestore(ctx context.Context, conn *sql.Conn, rehearsalID string, verification Verification) (RestoreResult, bool, error) {
|
|
var state, appCode, batchID, manifestSHA, manifestCRC, successCRC, availableTotal, frozenTotal string
|
|
var rowCount int
|
|
var verifiedAtMS int64
|
|
err := conn.QueryRowContext(ctx, `
|
|
SELECT state, source_app_code, source_batch_id, row_count, manifest_sha256, manifest_crc64, success_crc64,
|
|
available_delta_total, frozen_delta_total, verified_at_ms
|
|
FROM wallet_outbox_restore_rehearsals_v1 WHERE rehearsal_id = ?`, rehearsalID).
|
|
Scan(&state, &appCode, &batchID, &rowCount, &manifestSHA, &manifestCRC, &successCRC, &availableTotal, &frozenTotal, &verifiedAtMS)
|
|
if err == sql.ErrNoRows {
|
|
return RestoreResult{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return RestoreResult{}, false, err
|
|
}
|
|
manifest := verification.Manifest
|
|
if state != restoreVerifiedState || appCode != manifest.AppCode || batchID != manifest.BatchID || rowCount != manifest.RowCount || manifestSHA != verification.ManifestSHA256 || manifestCRC != verification.ManifestCRC64 || successCRC != verification.SuccessCRC64 || availableTotal != manifest.AvailableDeltaTotal || frozenTotal != manifest.FrozenDeltaTotal {
|
|
return RestoreResult{}, false, errors.New("rehearsal_id already exists with non-matching or incomplete evidence")
|
|
}
|
|
if err := verifyRestoredControls(ctx, conn, rehearsalID, verification); err != nil {
|
|
return RestoreResult{}, false, fmt.Errorf("existing restore readback failed: %w", err)
|
|
}
|
|
if err := verifyRestoredRowsExact(ctx, conn, rehearsalID, verification.Records); err != nil {
|
|
return RestoreResult{}, false, fmt.Errorf("existing restore exact-row readback failed: %w", err)
|
|
}
|
|
result := newRestoreResult(rehearsalID, "", verification, verifiedAtMS, true)
|
|
if err := conn.QueryRowContext(ctx, `SELECT DATABASE()`).Scan(&result.TargetDatabase); err != nil {
|
|
return RestoreResult{}, false, err
|
|
}
|
|
return result, true, nil
|
|
}
|
|
|
|
func newRestoreResult(rehearsalID, databaseName string, verification Verification, verifiedAtMS int64, idempotent bool) RestoreResult {
|
|
manifest := verification.Manifest
|
|
return RestoreResult{
|
|
State: restoreVerifiedState, RehearsalID: rehearsalID, BatchID: manifest.BatchID, AppCode: manifest.AppCode,
|
|
RowCount: manifest.RowCount, TargetDatabase: databaseName, RehearsalTable: RehearsalTable, RowsTable: RowsTable,
|
|
DataObjectKey: manifest.DataObjectKey, FirstCursor: manifest.FirstCursor, LastCursor: manifest.LastCursor,
|
|
NDJSONSHA256: manifest.NDJSONSHA256, GzipSHA256: manifest.GzipSHA256, DataCRC64: manifest.DataCRC64,
|
|
ManifestSHA256: verification.ManifestSHA256, ManifestCRC64: verification.ManifestCRC64,
|
|
SuccessSHA256: verification.SuccessSHA256, SuccessCRC64: verification.SuccessCRC64,
|
|
AvailableTotal: manifest.AvailableDeltaTotal, FrozenTotal: manifest.FrozenDeltaTotal,
|
|
VerifiedAtMS: verifiedAtMS, IdempotentReadback: idempotent,
|
|
}
|
|
}
|
|
|
|
func verifyRestoredRowsExact(ctx context.Context, query interface {
|
|
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
|
|
}, rehearsalID string, expected []Record) error {
|
|
rows, err := query.QueryContext(ctx, `
|
|
SELECT app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type,
|
|
available_delta, frozen_delta, CAST(payload AS CHAR), status, worker_id,
|
|
lock_until_ms, retry_count, next_retry_at_ms, last_error, created_at_ms, updated_at_ms
|
|
FROM wallet_outbox_restore_rows_v1 FORCE INDEX (idx_wallet_outbox_restore_cursor)
|
|
WHERE rehearsal_id = ?
|
|
ORDER BY app_code ASC, updated_at_ms ASC, event_id ASC`, rehearsalID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
index := 0
|
|
for rows.Next() {
|
|
if index >= len(expected) {
|
|
return errors.New("isolation table contains more rows than verified archive")
|
|
}
|
|
var actual Record
|
|
var lockUntilMS, nextRetryAtMS sql.NullInt64
|
|
var lastError sql.NullString
|
|
if err := rows.Scan(
|
|
&actual.AppCode, &actual.EventID, &actual.EventType, &actual.TransactionID, &actual.CommandID,
|
|
&actual.UserID, &actual.AssetType, &actual.AvailableDelta, &actual.FrozenDelta, &actual.PayloadJSON,
|
|
&actual.Status, &actual.WorkerID, &lockUntilMS, &actual.RetryCount, &nextRetryAtMS, &lastError,
|
|
&actual.CreatedAtMS, &actual.UpdatedAtMS,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
if lockUntilMS.Valid {
|
|
value := lockUntilMS.Int64
|
|
actual.LockUntilMS = &value
|
|
}
|
|
if nextRetryAtMS.Valid {
|
|
value := nextRetryAtMS.Int64
|
|
actual.NextRetryAtMS = &value
|
|
}
|
|
if lastError.Valid {
|
|
value := lastError.String
|
|
actual.LastError = &value
|
|
}
|
|
// Source payload was archived from CAST(payload AS CHAR); casting the restored
|
|
// JSON back through MySQL proves JSON parsing did not alter the owner snapshot.
|
|
if !reflect.DeepEqual(actual, expected[index]) {
|
|
return fmt.Errorf("isolation table row %d does not exactly match archive", index+1)
|
|
}
|
|
index++
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
if index != len(expected) {
|
|
return errors.New("isolation table contains fewer rows than verified archive")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func acquireRestoreLock(ctx context.Context, conn *sql.Conn, rehearsalID string) (func(), bool, error) {
|
|
hash := sha256.Sum256([]byte(rehearsalID))
|
|
lockName := "wallet_outbox_restore:" + hex.EncodeToString(hash[:16])
|
|
var acquired sql.NullInt64
|
|
if err := conn.QueryRowContext(ctx, `SELECT GET_LOCK(?, 0)`, lockName).Scan(&acquired); err != nil {
|
|
return nil, false, err
|
|
}
|
|
if !acquired.Valid || acquired.Int64 != 1 {
|
|
return func() {}, false, nil
|
|
}
|
|
return func() {
|
|
releaseCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
var released sql.NullInt64
|
|
_ = conn.QueryRowContext(releaseCtx, `SELECT RELEASE_LOCK(?)`, lockName).Scan(&released)
|
|
}, true, nil
|
|
}
|
|
|
|
func validRehearsalID(value string) bool {
|
|
if len(value) == 0 || len(value) > 64 {
|
|
return false
|
|
}
|
|
for _, char := range value {
|
|
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '.' || char == '_' || char == '-' {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func nullInt64(value *int64) any {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
return *value
|
|
}
|
|
|
|
func nullString(value *string) any {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
return *value
|
|
}
|