use crate::state::{
DurableTaskMisfirePolicyV1, SchedulerStateClaimRequestV1, SchedulerStateClaimV1,
SchedulerStateCompletionV1, SchedulerStateError, SchedulerStateProvider,
SchedulerStateRecordV1, SchedulerStateRegistrationV1, SchedulerStateStatsV1,
MAX_SCHEDULER_STATE_RECORDS,
};
use crate::state_file_stream::{
checksum as stream_checksum, write_bounded_json, RecordsRef, StateFileRef,
};
use crate::state_memory::InMemorySchedulerStateProvider;
use fs2::FileExt;
use serde::{Deserialize, Serialize};
use std::ffi::OsString;
use std::fmt;
use std::fs::{self, File, OpenOptions};
use std::io::{self, BufReader, Read};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex as ProcessMutex, OnceLock, Weak};
pub const SCHEDULER_STATE_FORMAT_V1: &str = "appcore-scheduler-state-v1";
const MAX_STATE_FILE_BYTES: u64 = 4 * 1024 * 1024;
const MAX_ACTIVE_FILE_PROVIDERS: usize = 1_024;
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
static PROCESS_LOCKS: OnceLock<
ProcessMutex<std::collections::HashMap<PathBuf, Weak<ProcessMutex<()>>>>,
> = OnceLock::new();
#[derive(Clone)]
pub struct FileSchedulerStateProvider {
path: PathBuf,
lock_path: PathBuf,
process_lock: Arc<ProcessMutex<()>>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct StateFileV1 {
format: String,
records: Vec<FileRecordV1>,
checksum: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct FileRecordV1 {
task_id: String,
definition_hash: String,
next_run_ms: u64,
attempts: u32,
misfire_policy: String,
completed: bool,
last_receipt_epoch: Option<u64>,
claim: Option<FileClaimV1>,
fencing_epoch: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct FileClaimV1 {
task_id: String,
owner_id: String,
fencing_epoch: u64,
lease_until_ms: u64,
attempt: u32,
}
impl FileSchedulerStateProvider {
pub fn new(path: impl Into<PathBuf>) -> Result<Self, SchedulerStateError> {
let path = path.into();
let parent = state_parent(&path);
reject_symlink_if_present(parent)?;
fs::create_dir_all(parent).map_err(|_| SchedulerStateError::Unavailable)?;
reject_symlink_if_present(parent)?;
reject_symlink_if_present(&path)?;
let lock_path = sidecar_path(&path, ".lock");
reject_symlink_if_present(&lock_path)?;
let process_lock = process_lock(&path)?;
let provider = Self {
path,
lock_path,
process_lock,
};
provider.with_locked(false, |_| Ok(()))?;
Ok(provider)
}
fn with_locked<T>(
&self,
write: bool,
operation: impl FnOnce(&InMemorySchedulerStateProvider) -> Result<T, SchedulerStateError>,
) -> Result<T, SchedulerStateError> {
let _process_guard = self
.process_lock
.lock()
.map_err(|_| SchedulerStateError::Unavailable)?;
reject_symlink_if_present(state_parent(&self.path))?;
reject_symlink_if_present(&self.path)?;
reject_symlink_if_present(&self.lock_path)?;
let lock = open_lock(&self.lock_path)?;
lock.lock_exclusive()
.map_err(|_| SchedulerStateError::Unavailable)?;
let records = load_records(&self.path)?;
let memory = InMemorySchedulerStateProvider::from_records(records);
let result = operation(&memory)?;
if write {
memory.with_records(|records| write_records(&self.path, records))?;
}
Ok(result)
}
}
fn process_lock(path: &Path) -> Result<Arc<ProcessMutex<()>>, SchedulerStateError> {
let parent =
fs::canonicalize(state_parent(path)).map_err(|_| SchedulerStateError::Unavailable)?;
let file_name = path.file_name().ok_or(SchedulerStateError::Unavailable)?;
let key = parent.join(file_name);
let registry =
PROCESS_LOCKS.get_or_init(|| ProcessMutex::new(std::collections::HashMap::new()));
let mut locks = registry
.lock()
.map_err(|_| SchedulerStateError::Unavailable)?;
locks.retain(|_, lock| lock.strong_count() > 0);
if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) {
return Ok(lock);
}
if locks.len() >= MAX_ACTIVE_FILE_PROVIDERS {
return Err(SchedulerStateError::Unavailable);
}
let lock = Arc::new(ProcessMutex::new(()));
locks.insert(key, Arc::downgrade(&lock));
Ok(lock)
}
impl fmt::Debug for FileSchedulerStateProvider {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("FileSchedulerStateProvider")
.finish_non_exhaustive()
}
}
impl SchedulerStateProvider for FileSchedulerStateProvider {
fn register(
&self,
registration: &SchedulerStateRegistrationV1,
max_records: usize,
) -> Result<SchedulerStateRecordV1, SchedulerStateError> {
self.with_locked(true, |memory| memory.register(registration, max_records))
}
fn try_claim(
&self,
request: &SchedulerStateClaimRequestV1,
) -> Result<Option<SchedulerStateClaimV1>, SchedulerStateError> {
self.with_locked(true, |memory| memory.try_claim(request))
}
fn record(&self, task_id: &str) -> Result<Option<SchedulerStateRecordV1>, SchedulerStateError> {
self.with_locked(false, |memory| memory.record(task_id))
}
fn renew_claim(
&self,
claim: &SchedulerStateClaimV1,
now_ms: u64,
lease_until_ms: u64,
) -> Result<(), SchedulerStateError> {
self.with_locked(true, |memory| {
memory.renew_claim(claim, now_ms, lease_until_ms)
})
}
fn complete(
&self,
completion: &SchedulerStateCompletionV1,
) -> Result<SchedulerStateRecordV1, SchedulerStateError> {
self.with_locked(true, |memory| memory.complete(completion))
}
fn stats(&self) -> Result<SchedulerStateStatsV1, SchedulerStateError> {
self.with_locked(false, |memory| memory.stats())
}
}
fn load_records(path: &Path) -> Result<Vec<SchedulerStateRecordV1>, SchedulerStateError> {
if !path.exists() {
return Ok(Vec::new());
}
let metadata = fs::symlink_metadata(path).map_err(|_| SchedulerStateError::Unavailable)?;
if !metadata.file_type().is_file() || metadata.len() > MAX_STATE_FILE_BYTES {
return Err(SchedulerStateError::InvalidState("invalid state file"));
}
let file = open_regular_file(path)?;
let mut reader = BufReader::new(file).take(MAX_STATE_FILE_BYTES.saturating_add(1));
let result = {
let mut deserializer = serde_json::Deserializer::from_reader(&mut reader);
let state = StateFileV1::deserialize(&mut deserializer);
state.and_then(|state| {
deserializer.end()?;
Ok(state)
})
};
let consumed = MAX_STATE_FILE_BYTES
.saturating_add(1)
.saturating_sub(reader.limit());
if consumed > MAX_STATE_FILE_BYTES {
return Err(SchedulerStateError::InvalidState("invalid state file"));
}
let file = result.map_err(|_| SchedulerStateError::InvalidState("invalid state file"))?;
if file.format != SCHEDULER_STATE_FORMAT_V1 {
return Err(SchedulerStateError::UpdateRequired);
}
if file.records.len() > MAX_SCHEDULER_STATE_RECORDS
|| checksum_file_records(&file.records)? != file.checksum
{
return Err(SchedulerStateError::InvalidState("invalid state checksum"));
}
let mut records: Vec<SchedulerStateRecordV1> = Vec::with_capacity(file.records.len());
for record in file.records {
if records
.last()
.is_some_and(|previous| previous.task_id.as_str() >= record.task_id.as_str())
{
return Err(SchedulerStateError::InvalidState("invalid state ordering"));
}
records.push(record.try_into()?);
}
Ok(records)
}
fn write_records(
path: &Path,
records: &std::collections::BTreeMap<String, SchedulerStateRecordV1>,
) -> Result<(), SchedulerStateError> {
let checksum = stream_checksum(&RecordsRef::new(records))?;
let file = StateFileRef::new(SCHEDULER_STATE_FORMAT_V1, records, &checksum);
atomic_write(path, &file)
}
impl TryFrom<FileRecordV1> for SchedulerStateRecordV1 {
type Error = SchedulerStateError;
fn try_from(record: FileRecordV1) -> Result<Self, Self::Error> {
let misfire_policy = match record.misfire_policy.as_str() {
"fire_once" => DurableTaskMisfirePolicyV1::FireOnce,
"skip" => DurableTaskMisfirePolicyV1::Skip,
_ => return Err(SchedulerStateError::UpdateRequired),
};
let claim = record
.claim
.map(SchedulerStateClaimV1::try_from)
.transpose()?;
let record = Self {
task_id: record.task_id,
definition_hash: record.definition_hash,
next_run_ms: record.next_run_ms,
attempts: record.attempts,
misfire_policy,
completed: record.completed,
last_receipt_epoch: record.last_receipt_epoch,
claim,
fencing_epoch: record.fencing_epoch,
};
record.validate()?;
Ok(record)
}
}
impl TryFrom<FileClaimV1> for SchedulerStateClaimV1 {
type Error = SchedulerStateError;
fn try_from(claim: FileClaimV1) -> Result<Self, Self::Error> {
Self::new(
claim.task_id,
claim.owner_id,
claim.fencing_epoch,
claim.lease_until_ms,
claim.attempt,
)
}
}
fn checksum_file_records(records: &[FileRecordV1]) -> Result<String, SchedulerStateError> {
stream_checksum(&records)
}
fn atomic_write(path: &Path, value: &impl Serialize) -> Result<(), SchedulerStateError> {
let temporary = sidecar_path(
path,
&format!(
".tmp-{}-{}",
std::process::id(),
TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed)
),
);
let mut options = OpenOptions::new();
options.write(true).create_new(true);
let mut file =
open_no_follow(&mut options, &temporary).map_err(|_| SchedulerStateError::Unavailable)?;
let result = (|| {
write_bounded_json(&mut file, value, MAX_STATE_FILE_BYTES)?;
file.sync_all()
.map_err(|_| SchedulerStateError::Unavailable)?;
fs::rename(&temporary, path).map_err(|_| SchedulerStateError::Unavailable)?;
sync_parent(path)
})();
if result.is_err() {
let _ = fs::remove_file(&temporary);
}
result
}
fn open_lock(path: &Path) -> Result<File, SchedulerStateError> {
let mut options = OpenOptions::new();
options.read(true).write(true).create(true).truncate(false);
let file = open_no_follow(&mut options, path).map_err(|_| SchedulerStateError::Unavailable)?;
validate_open_file(&file, path)?;
Ok(file)
}
fn sync_parent(path: &Path) -> Result<(), SchedulerStateError> {
File::open(state_parent(path))
.and_then(|directory| directory.sync_all())
.map_err(|_| SchedulerStateError::Unavailable)
}
fn state_parent(path: &Path) -> &Path {
path.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
}
fn sidecar_path(path: &Path, suffix: &str) -> PathBuf {
let mut value: OsString = path.as_os_str().to_owned();
value.push(suffix);
PathBuf::from(value)
}
fn reject_symlink_if_present(path: &Path) -> Result<(), SchedulerStateError> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata_is_link(&metadata) => Err(SchedulerStateError::Unavailable),
Ok(_) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(_) => Err(SchedulerStateError::Unavailable),
}
}
fn open_regular_file(path: &Path) -> Result<File, SchedulerStateError> {
let mut options = OpenOptions::new();
options.read(true);
let file = open_no_follow(&mut options, path).map_err(|_| SchedulerStateError::Unavailable)?;
validate_open_file(&file, path)?;
Ok(file)
}
fn validate_open_file(file: &File, path: &Path) -> Result<(), SchedulerStateError> {
let path_metadata = fs::symlink_metadata(path).map_err(|_| SchedulerStateError::Unavailable)?;
let file_metadata = file
.metadata()
.map_err(|_| SchedulerStateError::Unavailable)?;
if metadata_is_link(&path_metadata)
|| metadata_is_link(&file_metadata)
|| !file_metadata.is_file()
{
return Err(SchedulerStateError::Unavailable);
}
Ok(())
}
#[cfg(unix)]
fn open_no_follow(options: &mut OpenOptions, path: &Path) -> io::Result<File> {
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW).open(path)
}
#[cfg(windows)]
fn open_no_follow(options: &mut OpenOptions, path: &Path) -> io::Result<File> {
use std::os::windows::fs::OpenOptionsExt;
use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT;
options
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
.open(path)
}
#[cfg(all(not(unix), not(windows)))]
fn open_no_follow(_options: &mut OpenOptions, _path: &Path) -> io::Result<File> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"no-follow file opening is unavailable on this platform",
))
}
fn metadata_is_link(metadata: &fs::Metadata) -> bool {
metadata.file_type().is_symlink() || metadata_is_reparse_point(metadata)
}
#[cfg(windows)]
fn metadata_is_reparse_point(metadata: &fs::Metadata) -> bool {
use std::os::windows::fs::MetadataExt;
use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT;
metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(not(windows))]
fn metadata_is_reparse_point(_metadata: &fs::Metadata) -> bool {
false
}