runledger-postgres 0.5.0

PostgreSQL persistence layer for the Runledger durable job and workflow system
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
use runledger_core::jobs::JobTypeName;

use crate::{DbTx, Error, QueryError, QueryErrorCategory, Result};

use super::row_decode::parse_job_type_name;
use super::types::JobScheduleJobTypeReference;

// All cross-table guards acquire job_schedules before job_definitions. Keep new
// callers in that order so active-schedule writes and definition disables cannot
// wait on the same tables in opposite directions.
const SCHEDULE_DEFINITION_GUARD_LOCK_TIMEOUT: &str = "5s";
const SCHEDULE_DEFINITION_GUARD_LOCK_TIMEOUT_MS: i64 = 5_000;
const DEFINITION_DISABLE_STATEMENT_TIMEOUT: &str = "30s";
const DEFINITION_DISABLE_STATEMENT_TIMEOUT_MS: i64 = 30_000;

#[derive(Clone, Copy)]
pub(in crate::jobs) enum GuardLockContext {
    ActiveScheduleWrite,
    DefinitionDisable,
}

pub(in crate::jobs) enum ScheduleDefinitionLockError {
    Schedule(Error),
    Definition(Error),
}

impl ScheduleDefinitionLockError {
    pub(in crate::jobs) fn into_error(self) -> Error {
        match self {
            Self::Schedule(error) | Self::Definition(error) => error,
        }
    }
}

impl GuardLockContext {
    fn set_schedule_lock_timeout_context(self) -> &'static str {
        match self {
            Self::ActiveScheduleWrite => "set active schedule guard schedule lock timeout",
            Self::DefinitionDisable => "set job definition disable schedule lock timeout",
        }
    }

    fn restore_schedule_lock_timeout_context(self) -> &'static str {
        match self {
            Self::ActiveScheduleWrite => "restore active schedule guard schedule lock timeout",
            Self::DefinitionDisable => "restore job definition disable schedule lock timeout",
        }
    }

    fn schedule_lock_context(self) -> &'static str {
        match self {
            Self::ActiveScheduleWrite => {
                "lock job schedules before active schedule definition check"
            }
            Self::DefinitionDisable => "lock job schedules before disabling job definitions",
        }
    }

    fn set_definition_lock_timeout_context(self) -> &'static str {
        match self {
            Self::ActiveScheduleWrite => "set active schedule guard definition lock timeout",
            Self::DefinitionDisable => "set job definition disable definition lock timeout",
        }
    }

    fn restore_definition_lock_timeout_context(self) -> &'static str {
        match self {
            Self::ActiveScheduleWrite => "restore active schedule guard definition lock timeout",
            Self::DefinitionDisable => "restore job definition disable definition lock timeout",
        }
    }

    fn definition_lock_context(self) -> &'static str {
        match self {
            Self::ActiveScheduleWrite => {
                "lock job definitions before active schedule definition check"
            }
            Self::DefinitionDisable => "lock job definitions before disabling job definitions",
        }
    }
}

pub(in crate::jobs) async fn cap_definition_disable_statement_timeout_tx(
    tx: &mut DbTx<'_>,
) -> Result<()> {
    cap_local_statement_timeout_tx(
        tx,
        DEFINITION_DISABLE_STATEMENT_TIMEOUT,
        DEFINITION_DISABLE_STATEMENT_TIMEOUT_MS,
        "set job definition disable statement timeout",
    )
    .await?;
    Ok(())
}

pub(in crate::jobs) async fn lock_schedules_then_definitions_tx(
    tx: &mut DbTx<'_>,
    context: GuardLockContext,
) -> std::result::Result<(), ScheduleDefinitionLockError> {
    lock_job_schedules_for_guard_tx(tx, context)
        .await
        .map_err(ScheduleDefinitionLockError::Schedule)?;
    lock_job_definitions_for_guard_tx(tx, context)
        .await
        .map_err(ScheduleDefinitionLockError::Definition)
}

pub(in crate::jobs) async fn lock_job_schedules_for_guard_tx(
    tx: &mut DbTx<'_>,
    context: GuardLockContext,
) -> Result<()> {
    let previous_lock_timeout = cap_local_lock_timeout_tx(
        tx,
        SCHEDULE_DEFINITION_GUARD_LOCK_TIMEOUT,
        SCHEDULE_DEFINITION_GUARD_LOCK_TIMEOUT_MS,
        context.set_schedule_lock_timeout_context(),
    )
    .await?;

    let lock_result = sqlx::query("LOCK TABLE job_schedules IN SHARE ROW EXCLUSIVE MODE")
        .execute(&mut **tx)
        .await;

    match lock_result {
        Ok(_) => {
            set_local_lock_timeout_tx(
                tx,
                &previous_lock_timeout,
                context.restore_schedule_lock_timeout_context(),
            )
            .await
        }
        Err(error) => Err(Error::from_query_sqlx_with_context(
            context.schedule_lock_context(),
            error,
        )),
    }
}

pub(in crate::jobs) async fn lock_job_definitions_for_guard_tx(
    tx: &mut DbTx<'_>,
    context: GuardLockContext,
) -> Result<()> {
    let previous_lock_timeout = cap_local_lock_timeout_tx(
        tx,
        SCHEDULE_DEFINITION_GUARD_LOCK_TIMEOUT,
        SCHEDULE_DEFINITION_GUARD_LOCK_TIMEOUT_MS,
        context.set_definition_lock_timeout_context(),
    )
    .await?;

    let lock_result = sqlx::query("LOCK TABLE job_definitions IN SHARE ROW EXCLUSIVE MODE")
        .execute(&mut **tx)
        .await;

    match lock_result {
        Ok(_) => {
            set_local_lock_timeout_tx(
                tx,
                &previous_lock_timeout,
                context.restore_definition_lock_timeout_context(),
            )
            .await
        }
        Err(error) => Err(Error::from_query_sqlx_with_context(
            context.definition_lock_context(),
            error,
        )),
    }
}

pub(in crate::jobs) async fn reject_unavailable_definition_for_active_schedule_tx(
    tx: &mut DbTx<'_>,
    job_type: &str,
) -> Result<()> {
    if enabled_job_definition_exists_tx(tx, job_type).await? {
        return Ok(());
    }

    Err(active_schedule_definition_unavailable_error(job_type))
}

async fn enabled_job_definition_exists_tx(tx: &mut DbTx<'_>, job_type: &str) -> Result<bool> {
    let is_enabled = sqlx::query_scalar::<_, bool>(
        "SELECT is_enabled
         FROM job_definitions
         WHERE job_type = $1",
    )
    .bind(job_type)
    .fetch_optional(&mut **tx)
    .await
    .map_err(|error| {
        Error::from_query_sqlx_with_context(
            "check job definition before active schedule write",
            error,
        )
    })?;

    Ok(is_enabled == Some(true))
}

pub(in crate::jobs) async fn find_active_schedule_for_job_type_tx(
    tx: &mut DbTx<'_>,
    job_type: &str,
) -> Result<Option<JobScheduleJobTypeReference>> {
    let row = sqlx::query_as::<_, (String, String)>(
        "SELECT name, job_type
         FROM job_schedules
         WHERE is_active = true
           AND job_type = $1
         ORDER BY name ASC
         LIMIT 1",
    )
    .bind(job_type)
    .fetch_optional(&mut **tx)
    .await
    .map_err(|error| {
        Error::from_query_sqlx_with_context("find active schedule for job definition", error)
    })?;

    row.map(|(name, job_type)| parse_schedule_job_type_reference(name, job_type))
        .transpose()
}

pub(in crate::jobs) async fn find_active_schedule_for_job_types_tx(
    tx: &mut DbTx<'_>,
    job_types: &[JobTypeName],
) -> Result<Option<JobScheduleJobTypeReference>> {
    if job_types.is_empty() {
        return Ok(None);
    }

    let job_types = job_type_strings(job_types);
    let row = sqlx::query_as::<_, (String, String)>(
        "SELECT name, job_type
         FROM job_schedules
         WHERE is_active = true
           AND job_type = ANY($1::text[])
         ORDER BY name ASC
         LIMIT 1",
    )
    .bind(job_types.as_slice())
    .fetch_optional(&mut **tx)
    .await
    .map_err(|error| {
        Error::from_query_sqlx_with_context("find active schedule for job definitions", error)
    })?;

    row.map(|(name, job_type)| parse_schedule_job_type_reference(name, job_type))
        .transpose()
}

pub(in crate::jobs) async fn find_active_schedule_for_enabled_absent_job_types_tx(
    tx: &mut DbTx<'_>,
    catalog_job_types: &[JobTypeName],
    scope_job_types: &[JobTypeName],
) -> Result<Option<JobScheduleJobTypeReference>> {
    if scope_job_types.is_empty() {
        return Ok(None);
    }

    let catalog_job_types = job_type_strings(catalog_job_types);
    let scope_job_types = job_type_strings(scope_job_types);
    let row = sqlx::query_as::<_, (String, String)>(
        "SELECT job_schedules.name, job_schedules.job_type
         FROM job_schedules
         INNER JOIN job_definitions
            ON job_definitions.job_type = job_schedules.job_type
         WHERE job_schedules.is_active = true
           AND job_schedules.job_type <> ALL($1::text[])
           AND job_schedules.job_type = ANY($2::text[])
           AND job_definitions.is_enabled = true
         ORDER BY job_schedules.name ASC
         LIMIT 1",
    )
    .bind(catalog_job_types.as_slice())
    .bind(scope_job_types.as_slice())
    .fetch_optional(&mut **tx)
    .await
    .map_err(|error| {
        Error::from_query_sqlx_with_context(
            "find active schedule for enabled absent job definitions",
            error,
        )
    })?;

    row.map(|(name, job_type)| parse_schedule_job_type_reference(name, job_type))
        .transpose()
}

fn active_schedule_definition_unavailable_error(job_type: &str) -> Error {
    Error::QueryError(QueryError::from_classified(
        QueryErrorCategory::Validation,
        "job_schedule.definition_not_found_or_disabled",
        "Active job schedules require an enabled job definition.",
        format!("active job schedule references missing or disabled job definition: {job_type}"),
    ))
}

pub(in crate::jobs) fn active_schedule_for_disabled_definition_error(
    reference: &JobScheduleJobTypeReference,
) -> Error {
    Error::QueryError(QueryError::from_classified(
        QueryErrorCategory::Validation,
        "job_definition.active_schedule_exists",
        "Job definition cannot be disabled while active schedules reference it.",
        format!(
            "active schedule {} still references job type {}",
            reference.schedule_name, reference.job_type
        ),
    ))
}

async fn cap_local_lock_timeout_tx(
    tx: &mut DbTx<'_>,
    lock_timeout: &str,
    lock_timeout_ms: i64,
    context: &'static str,
) -> Result<String> {
    sqlx::query_scalar::<_, String>(
        "WITH previous AS MATERIALIZED (
             SELECT
                current_setting('lock_timeout') AS lock_timeout,
                setting::bigint AS lock_timeout_ms
             FROM pg_settings
             WHERE name = 'lock_timeout'
         )
         SELECT previous.lock_timeout
         FROM previous,
              LATERAL (
                SELECT set_config(
                    'lock_timeout',
                    CASE
                        WHEN previous.lock_timeout_ms = 0 THEN $1
                        WHEN previous.lock_timeout_ms <= $2 THEN previous.lock_timeout
                        ELSE $1
                    END,
                    true
                )
              ) AS applied",
    )
    .bind(lock_timeout)
    .bind(lock_timeout_ms)
    .fetch_one(&mut **tx)
    .await
    .map_err(|error| Error::from_query_sqlx_with_context(context, error))
}

async fn cap_local_statement_timeout_tx(
    tx: &mut DbTx<'_>,
    statement_timeout: &str,
    statement_timeout_ms: i64,
    context: &'static str,
) -> Result<String> {
    sqlx::query_scalar::<_, String>(
        "WITH previous AS MATERIALIZED (
             SELECT
                current_setting('statement_timeout') AS statement_timeout,
                setting::bigint AS statement_timeout_ms
             FROM pg_settings
             WHERE name = 'statement_timeout'
         )
         SELECT previous.statement_timeout
         FROM previous,
              LATERAL (
                SELECT set_config(
                    'statement_timeout',
                    CASE
                        WHEN previous.statement_timeout_ms = 0 THEN $1
                        WHEN previous.statement_timeout_ms <= $2 THEN previous.statement_timeout
                        ELSE $1
                    END,
                    true
                )
              ) AS applied",
    )
    .bind(statement_timeout)
    .bind(statement_timeout_ms)
    .fetch_one(&mut **tx)
    .await
    .map_err(|error| Error::from_query_sqlx_with_context(context, error))
}

async fn set_local_lock_timeout_tx(
    tx: &mut DbTx<'_>,
    lock_timeout: &str,
    context: &'static str,
) -> Result<()> {
    sqlx::query_scalar::<_, String>("SELECT set_config('lock_timeout', $1, true)")
        .bind(lock_timeout)
        .fetch_one(&mut **tx)
        .await
        .map_err(|error| Error::from_query_sqlx_with_context(context, error))?;

    Ok(())
}

fn job_type_strings(job_types: &[JobTypeName]) -> Vec<String> {
    job_types
        .iter()
        .map(|job_type| job_type.as_str().to_owned())
        .collect()
}

fn parse_schedule_job_type_reference(
    schedule_name: String,
    job_type: String,
) -> Result<JobScheduleJobTypeReference> {
    Ok(JobScheduleJobTypeReference {
        schedule_name,
        job_type: parse_job_type_name(job_type)?,
    })
}