1use std::collections::{BTreeMap, VecDeque};
99use std::fmt;
100use std::sync::{Arc, Mutex};
101use std::time::Duration;
102
103use mongreldb_types::errors::ErrorCategory;
104use mongreldb_types::hlc::{ClockSkewError, HlcClock, HlcTimestamp};
105use mongreldb_types::ids::{DatabaseId, MetadataVersion, SchemaVersion, TableId, TabletId};
106use serde::{Deserialize, Serialize};
107use sha2::{Digest, Sha256};
108
109use crate::meta::{MetaRejectionReason, SchemaJobState};
110use crate::split::{RecordStream, SnapshotPin, TabletDataError};
111use crate::tablet::Key;
112
113pub const DDL_STORE_FORMAT_VERSION: u32 = 1;
120pub const MIN_SUPPORTED_DDL_STORE_FORMAT_VERSION: u32 = 1;
122pub const DDL_REJECTION_LIMIT: usize = 256;
125
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, thiserror::Error)]
135pub enum DdlRejection {
136 #[error(transparent)]
139 Meta(#[from] MetaRejectionReason),
140 #[error("illegal DDL phase transition {from} -> {to} for job {job_id}")]
142 IllegalPhaseTransition {
143 job_id: u64,
145 from: DdlPhase,
147 to: DdlPhase,
149 },
150 #[error("DDL job {job_id} is {state:?}, not Running")]
152 JobNotRunning {
153 job_id: u64,
155 state: SchemaJobState,
157 },
158 #[error(
162 "schema version mismatch on table {table_id}: job pinned {expected}, \
163 table is now {found}; refresh schema metadata and resubmit"
164 )]
165 SchemaVersionMismatch {
166 table_id: TableId,
168 expected: SchemaVersion,
170 found: SchemaVersion,
172 },
173 #[error("tablet {tablet} progress regressed for job {job_id}: {reason}")]
175 TabletProgressRegression {
176 job_id: u64,
178 tablet: TabletId,
180 reason: String,
182 },
183 #[error("DDL job {job_id} cannot publish: tablets pending validation: {pending:?}")]
185 ValidationIncomplete {
186 job_id: u64,
188 pending: Vec<TabletId>,
190 },
191 #[error(
194 "index `{index_name}` on table {table_id} cannot be reclaimed: oldest reader \
195 pins metadata version {oldest_reader:?}, retirement requires {required}"
196 )]
197 ReclaimBlocked {
198 table_id: TableId,
200 index_name: String,
202 oldest_reader: Option<MetadataVersion>,
204 required: MetadataVersion,
206 },
207}
208
209#[derive(Debug, thiserror::Error)]
212pub enum DdlError {
213 #[error(transparent)]
215 Rejection(#[from] DdlRejection),
216 #[error(transparent)]
218 TabletData(#[from] TabletDataError),
219 #[error(transparent)]
221 Clock(#[from] ClockSkewError),
222 #[error("DDL job {job_id} failed validation: {reason}")]
225 ValidationFailed {
226 job_id: u64,
228 reason: String,
230 },
231 #[error("DDL job {job_id} cancelled")]
233 Cancelled {
234 job_id: u64,
236 },
237}
238
239impl DdlError {
240 pub fn category(&self) -> ErrorCategory {
244 match self {
245 Self::Rejection(DdlRejection::SchemaVersionMismatch { .. }) => {
246 ErrorCategory::SchemaVersionMismatch
247 }
248 Self::Rejection(DdlRejection::Meta(MetaRejectionReason::StaleWrite { .. })) => {
249 ErrorCategory::StaleMetadata
250 }
251 Self::Cancelled { .. } => ErrorCategory::Cancelled,
252 Self::Rejection(_)
253 | Self::TabletData(_)
254 | Self::Clock(_)
255 | Self::ValidationFailed { .. } => ErrorCategory::ResourceExhausted,
256 }
257 }
258}
259
260#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
268pub enum DdlPhase {
269 Pending,
271 WriteOnly,
273 Backfilling,
275 Validating,
277 Public,
279 Dropping,
282}
283
284impl DdlPhase {
285 pub const ALL: [DdlPhase; 6] = [
287 DdlPhase::Pending,
288 DdlPhase::WriteOnly,
289 DdlPhase::Backfilling,
290 DdlPhase::Validating,
291 DdlPhase::Public,
292 DdlPhase::Dropping,
293 ];
294
295 pub fn can_transition(self, next: Self) -> bool {
301 use DdlPhase::{Backfilling, Dropping, Pending, Public, Validating, WriteOnly};
302 matches!(
303 (self, next),
304 (Pending, WriteOnly)
305 | (WriteOnly, Backfilling)
306 | (Backfilling, Validating)
307 | (Validating, Public)
308 | (Public, Dropping)
309 )
310 }
311}
312
313impl fmt::Display for DdlPhase {
314 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
315 let name = match self {
316 Self::Pending => "Pending",
317 Self::WriteOnly => "WriteOnly",
318 Self::Backfilling => "Backfilling",
319 Self::Validating => "Validating",
320 Self::Public => "Public",
321 Self::Dropping => "Dropping",
322 };
323 f.write_str(name)
324 }
325}
326
327#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
333pub enum DdlJobKind {
334 AddIndex,
336 DropIndex,
338 AlterSchema,
341}
342
343#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
346pub enum DdlDefinition {
347 AddIndex {
349 index_name: String,
351 spec: serde_json::Value,
353 },
354 DropIndex {
356 index_name: String,
358 },
359 AlterSchema {
361 target: serde_json::Value,
363 },
364}
365
366impl DdlDefinition {
367 pub fn index_name(&self) -> Option<&str> {
369 match self {
370 Self::AddIndex { index_name, .. } | Self::DropIndex { index_name } => Some(index_name),
371 Self::AlterSchema { .. } => None,
372 }
373 }
374}
375
376#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
383pub enum TabletDdlStage {
384 Pending,
386 Backfilling,
388 CaughtUp,
391 Validated,
393}
394
395impl fmt::Display for TabletDdlStage {
396 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
397 let name = match self {
398 Self::Pending => "Pending",
399 Self::Backfilling => "Backfilling",
400 Self::CaughtUp => "CaughtUp",
401 Self::Validated => "Validated",
402 };
403 f.write_str(name)
404 }
405}
406
407#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
410pub struct TabletDdlProgress {
411 pub tablet_id: TabletId,
413 pub stage: TabletDdlStage,
415 pub rows_scanned: u64,
417 pub caught_up_through: Option<HlcTimestamp>,
420 pub validation: Option<TabletValidationReport>,
422}
423
424impl TabletDdlProgress {
425 pub fn pending(tablet_id: TabletId) -> Self {
427 Self {
428 tablet_id,
429 stage: TabletDdlStage::Pending,
430 rows_scanned: 0,
431 caught_up_through: None,
432 validation: None,
433 }
434 }
435}
436
437#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
444pub struct TabletValidationReport {
445 pub tablet_id: TabletId,
447 pub watermark: HlcTimestamp,
449 pub expected_rows: u64,
451 pub actual_rows: u64,
453 pub expected_checksum: [u8; 32],
455 pub actual_checksum: [u8; 32],
457}
458
459impl TabletValidationReport {
460 pub fn passed(&self) -> bool {
462 self.expected_rows == self.actual_rows && self.expected_checksum == self.actual_checksum
463 }
464}
465
466#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
469pub struct JobValidationReport {
470 pub job_id: u64,
472 pub tablets: Vec<TabletValidationReport>,
474 pub total_expected: u64,
476 pub total_actual: u64,
478 pub passed: bool,
480}
481
482pub fn generation_checksum(entries: &BTreeMap<Key, Vec<u8>>) -> [u8; 32] {
485 let mut hasher = Sha256::new();
486 for (key, entry) in entries {
487 hasher.update(key.as_bytes());
488 hasher.update(entry);
489 }
490 hasher.finalize().into()
491}
492
493#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
501pub struct DdlJobRecord {
502 pub job_id: u64,
504 pub database_id: DatabaseId,
506 pub table_id: TableId,
508 pub kind: DdlJobKind,
510 pub state: SchemaJobState,
512 pub phase: DdlPhase,
514 pub definition: DdlDefinition,
516 pub source_schema_version: SchemaVersion,
520 pub created_at: HlcTimestamp,
522 pub updated_at: HlcTimestamp,
524 pub pinned_snapshot: Option<HlcTimestamp>,
527 #[serde(with = "tablet_progress_map")]
529 pub tablet_progress: BTreeMap<TabletId, TabletDdlProgress>,
530 pub error: Option<String>,
532 #[serde(default = "zero_metadata_version")]
534 pub metadata_version: MetadataVersion,
535}
536
537fn zero_metadata_version() -> MetadataVersion {
538 MetadataVersion::ZERO
539}
540
541#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
545pub struct DdlIndexRecord {
546 pub table_id: TableId,
548 pub index_name: String,
550 pub definition: serde_json::Value,
552 pub phase: DdlPhase,
555 pub job_id: u64,
557 pub created_at: HlcTimestamp,
559 pub publication_version: Option<MetadataVersion>,
562 pub published_at: Option<HlcTimestamp>,
564 pub dropping_since: Option<MetadataVersion>,
568 #[serde(default = "zero_metadata_version")]
570 pub metadata_version: MetadataVersion,
571}
572
573#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
578pub struct TableAnchor {
579 pub table_id: TableId,
581 pub database_id: DatabaseId,
583 pub schema_version: SchemaVersion,
585 pub schema: serde_json::Value,
587 #[serde(default = "zero_metadata_version")]
589 pub metadata_version: MetadataVersion,
590}
591
592#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
602pub enum DdlCommand {
603 RegisterTable {
606 anchor: TableAnchor,
608 },
609 SubmitJob {
612 job: DdlJobRecord,
614 },
615 AdvancePhase {
620 job_id: u64,
622 to: DdlPhase,
624 pinned_snapshot: Option<HlcTimestamp>,
626 expected_version: Option<MetadataVersion>,
628 },
629 UpdateTabletProgress {
633 job_id: u64,
635 progress: TabletDdlProgress,
637 },
638 ForgetTabletProgress {
641 job_id: u64,
643 tablet_id: TabletId,
645 },
646 ReportTabletValidation {
649 job_id: u64,
651 report: TabletValidationReport,
653 },
654 PublishJob {
658 job_id: u64,
660 published_at: HlcTimestamp,
662 },
663 SetJobState {
666 job_id: u64,
668 state: SchemaJobState,
670 updated_at: HlcTimestamp,
672 error: Option<String>,
674 expected_version: Option<MetadataVersion>,
676 },
677 RemoveIndexRecord {
680 job_id: u64,
682 },
683 ReclaimIndex {
687 table_id: TableId,
689 index_name: String,
691 },
692 PinReader {
695 reader_id: u64,
697 version: MetadataVersion,
699 },
700 ReleaseReader {
702 reader_id: u64,
704 },
705}
706
707#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
713pub struct DdlStoreRejection {
714 pub command_id: Option<[u8; 16]>,
717 pub reason: DdlRejection,
719}
720
721#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
729#[serde(default)]
730pub struct DdlJobStore {
731 pub format_version: u32,
733 pub metadata_version: MetadataVersion,
735 pub next_job_id: u64,
737 pub tables: BTreeMap<TableId, TableAnchor>,
739 pub jobs: BTreeMap<u64, DdlJobRecord>,
741 #[serde(with = "index_record_map")]
743 pub indexes: BTreeMap<(TableId, String), DdlIndexRecord>,
744 pub reader_pins: BTreeMap<u64, MetadataVersion>,
746 pub rejections: VecDeque<DdlStoreRejection>,
749}
750
751impl Default for DdlJobStore {
752 fn default() -> Self {
753 Self {
754 format_version: DDL_STORE_FORMAT_VERSION,
755 metadata_version: MetadataVersion::ZERO,
756 next_job_id: 1,
757 tables: BTreeMap::new(),
758 jobs: BTreeMap::new(),
759 indexes: BTreeMap::new(),
760 reader_pins: BTreeMap::new(),
761 rejections: VecDeque::new(),
762 }
763 }
764}
765
766mod index_record_map {
771 use super::{DdlIndexRecord, TableId};
772 use serde::{Deserialize, Deserializer, Serialize, Serializer};
773 use std::collections::BTreeMap;
774
775 pub fn serialize<S>(
776 map: &BTreeMap<(TableId, String), DdlIndexRecord>,
777 serializer: S,
778 ) -> Result<S::Ok, S::Error>
779 where
780 S: Serializer,
781 {
782 let triples: Vec<(TableId, String, DdlIndexRecord)> = map
783 .iter()
784 .map(|((table, name), record)| (*table, name.clone(), record.clone()))
785 .collect();
786 triples.serialize(serializer)
787 }
788
789 pub fn deserialize<'de, D>(
790 deserializer: D,
791 ) -> Result<BTreeMap<(TableId, String), DdlIndexRecord>, D::Error>
792 where
793 D: Deserializer<'de>,
794 {
795 let triples: Vec<(TableId, String, DdlIndexRecord)> = Vec::deserialize(deserializer)?;
796 Ok(triples
797 .into_iter()
798 .map(|(table, name, record)| ((table, name), record))
799 .collect())
800 }
801}
802
803mod tablet_progress_map {
808 use super::{TabletDdlProgress, TabletId};
809 use serde::{Deserialize, Deserializer, Serialize, Serializer};
810 use std::collections::BTreeMap;
811
812 pub fn serialize<S>(
813 map: &BTreeMap<TabletId, TabletDdlProgress>,
814 serializer: S,
815 ) -> Result<S::Ok, S::Error>
816 where
817 S: Serializer,
818 {
819 let pairs: Vec<(TabletId, TabletDdlProgress)> = map
820 .iter()
821 .map(|(tablet, progress)| (*tablet, progress.clone()))
822 .collect();
823 pairs.serialize(serializer)
824 }
825
826 pub fn deserialize<'de, D>(
827 deserializer: D,
828 ) -> Result<BTreeMap<TabletId, TabletDdlProgress>, D::Error>
829 where
830 D: Deserializer<'de>,
831 {
832 let pairs: Vec<(TabletId, TabletDdlProgress)> = Vec::deserialize(deserializer)?;
833 Ok(pairs.into_iter().collect())
834 }
835}
836
837impl DdlJobStore {
838 pub fn apply(
843 &mut self,
844 command: &DdlCommand,
845 command_id: Option<[u8; 16]>,
846 commit_ts: HlcTimestamp,
847 ) -> Result<(), DdlRejection> {
848 self.metadata_version = MetadataVersion(self.metadata_version.get() + 1);
849 let version = self.metadata_version;
850 let result = self.dispatch(command, commit_ts, version);
851 if let Err(reason) = &result {
852 self.rejections.push_back(DdlStoreRejection {
853 command_id,
854 reason: reason.clone(),
855 });
856 while self.rejections.len() > DDL_REJECTION_LIMIT {
857 self.rejections.pop_front();
858 }
859 }
860 result
861 }
862
863 fn dispatch(
864 &mut self,
865 command: &DdlCommand,
866 commit_ts: HlcTimestamp,
867 version: MetadataVersion,
868 ) -> Result<(), DdlRejection> {
869 match command {
870 DdlCommand::RegisterTable { anchor } => self.apply_register_table(anchor, version),
871 DdlCommand::SubmitJob { job } => self.apply_submit_job(job, commit_ts, version),
872 DdlCommand::AdvancePhase {
873 job_id,
874 to,
875 pinned_snapshot,
876 expected_version,
877 } => self.apply_advance_phase(
878 *job_id,
879 *to,
880 *pinned_snapshot,
881 *expected_version,
882 commit_ts,
883 version,
884 ),
885 DdlCommand::UpdateTabletProgress { job_id, progress } => {
886 self.apply_update_tablet_progress(*job_id, progress, commit_ts, version)
887 }
888 DdlCommand::ForgetTabletProgress { job_id, tablet_id } => {
889 self.apply_forget_tablet_progress(*job_id, *tablet_id, commit_ts, version)
890 }
891 DdlCommand::ReportTabletValidation { job_id, report } => {
892 self.apply_report_tablet_validation(*job_id, report, commit_ts, version)
893 }
894 DdlCommand::PublishJob {
895 job_id,
896 published_at,
897 } => self.apply_publish_job(*job_id, *published_at, commit_ts, version),
898 DdlCommand::SetJobState {
899 job_id,
900 state,
901 updated_at,
902 error,
903 expected_version,
904 } => self.apply_set_job_state(
905 *job_id,
906 *state,
907 *updated_at,
908 error,
909 *expected_version,
910 version,
911 ),
912 DdlCommand::RemoveIndexRecord { job_id } => {
913 self.apply_remove_index_record(*job_id, version)
914 }
915 DdlCommand::ReclaimIndex {
916 table_id,
917 index_name,
918 } => self.apply_reclaim_index(*table_id, index_name, version),
919 DdlCommand::PinReader { reader_id, version } => {
920 self.apply_pin_reader(*reader_id, *version)
921 }
922 DdlCommand::ReleaseReader { reader_id } => {
923 self.reader_pins.remove(reader_id);
924 Ok(())
925 }
926 }
927 }
928
929 fn apply_register_table(
930 &mut self,
931 anchor: &TableAnchor,
932 version: MetadataVersion,
933 ) -> Result<(), DdlRejection> {
934 if anchor.table_id == TableId::ZERO {
935 return Err(MetaRejectionReason::Invalid {
936 reason: "reserved zero table id".to_owned(),
937 }
938 .into());
939 }
940 if anchor.schema_version == SchemaVersion::ZERO {
941 return Err(MetaRejectionReason::Invalid {
942 reason: "reserved zero schema version".to_owned(),
943 }
944 .into());
945 }
946 match self.tables.get(&anchor.table_id) {
947 Some(existing) => {
948 if anchor.schema_version > existing.schema_version {
949 let mut anchor = anchor.clone();
950 anchor.metadata_version = version;
951 self.tables.insert(anchor.table_id, anchor);
952 Ok(())
953 } else if anchor.schema_version == existing.schema_version {
954 if existing.database_id == anchor.database_id
955 && existing.schema == anchor.schema
956 {
957 Ok(())
958 } else {
959 Err(MetaRejectionReason::Conflict {
960 resource: format!("table {}", anchor.table_id),
961 reason: "schema version already used for different content".to_owned(),
962 }
963 .into())
964 }
965 } else {
966 Err(MetaRejectionReason::StaleWrite {
967 resource: format!("table {}", anchor.table_id),
968 current: MetadataVersion(existing.schema_version.get()),
969 attempted: MetadataVersion(anchor.schema_version.get()),
970 }
971 .into())
972 }
973 }
974 None => {
975 let mut anchor = anchor.clone();
976 anchor.metadata_version = version;
977 self.tables.insert(anchor.table_id, anchor);
978 Ok(())
979 }
980 }
981 }
982
983 fn apply_submit_job(
984 &mut self,
985 job: &DdlJobRecord,
986 commit_ts: HlcTimestamp,
987 version: MetadataVersion,
988 ) -> Result<(), DdlRejection> {
989 if job.job_id == 0 {
990 return Err(MetaRejectionReason::Invalid {
991 reason: "reserved zero job id".to_owned(),
992 }
993 .into());
994 }
995 if let Some(existing) = self.jobs.get(&job.job_id) {
998 let mut comparable = existing.clone();
999 comparable.metadata_version = job.metadata_version;
1000 return if comparable == *job {
1001 Ok(())
1002 } else {
1003 Err(MetaRejectionReason::Conflict {
1004 resource: format!("DDL job {}", job.job_id),
1005 reason: "job id already exists with different content".to_owned(),
1006 }
1007 .into())
1008 };
1009 }
1010 let anchor = self
1011 .tables
1012 .get(&job.table_id)
1013 .ok_or(MetaRejectionReason::NotFound {
1014 resource: format!("table {}", job.table_id),
1015 })?;
1016 if anchor.database_id != job.database_id {
1017 return Err(MetaRejectionReason::Conflict {
1018 resource: format!("table {}", job.table_id),
1019 reason: "table belongs to a different database".to_owned(),
1020 }
1021 .into());
1022 }
1023 if anchor.schema_version != job.source_schema_version {
1025 return Err(DdlRejection::SchemaVersionMismatch {
1026 table_id: job.table_id,
1027 expected: job.source_schema_version,
1028 found: anchor.schema_version,
1029 });
1030 }
1031 if job.state != SchemaJobState::Pending {
1032 return Err(MetaRejectionReason::Invalid {
1033 reason: "submitted jobs start Pending".to_owned(),
1034 }
1035 .into());
1036 }
1037 let expected_phase = match job.kind {
1038 DdlJobKind::AddIndex | DdlJobKind::AlterSchema => DdlPhase::Pending,
1039 DdlJobKind::DropIndex => DdlPhase::Public,
1040 };
1041 if job.phase != expected_phase {
1042 return Err(MetaRejectionReason::Invalid {
1043 reason: format!(
1044 "{:?} jobs start in phase {expected_phase}, not {}",
1045 job.kind, job.phase
1046 ),
1047 }
1048 .into());
1049 }
1050 if let Some(active) = self
1054 .jobs
1055 .values()
1056 .find(|existing| existing.table_id == job.table_id && !existing.state.is_terminal())
1057 {
1058 return Err(MetaRejectionReason::Conflict {
1059 resource: format!("table {}", job.table_id),
1060 reason: format!("table already has active DDL job {}", active.job_id),
1061 }
1062 .into());
1063 }
1064 match &job.definition {
1065 DdlDefinition::AddIndex { index_name, spec } => {
1066 if index_name.trim().is_empty() {
1067 return Err(MetaRejectionReason::Invalid {
1068 reason: "index name is empty".to_owned(),
1069 }
1070 .into());
1071 }
1072 let key = (job.table_id, index_name.clone());
1073 if self.indexes.contains_key(&key) {
1074 return Err(MetaRejectionReason::Conflict {
1075 resource: format!("index `{index_name}` on table {}", job.table_id),
1076 reason: "index name already exists".to_owned(),
1077 }
1078 .into());
1079 }
1080 self.indexes.insert(
1081 key,
1082 DdlIndexRecord {
1083 table_id: job.table_id,
1084 index_name: index_name.clone(),
1085 definition: spec.clone(),
1086 phase: DdlPhase::Pending,
1087 job_id: job.job_id,
1088 created_at: commit_ts,
1089 publication_version: None,
1090 published_at: None,
1091 dropping_since: None,
1092 metadata_version: version,
1093 },
1094 );
1095 }
1096 DdlDefinition::DropIndex { index_name } => {
1097 let key = (job.table_id, index_name.clone());
1098 match self.indexes.get(&key) {
1099 None => {
1100 return Err(MetaRejectionReason::NotFound {
1101 resource: format!("index `{index_name}` on table {}", job.table_id),
1102 }
1103 .into());
1104 }
1105 Some(record) if record.phase != DdlPhase::Public => {
1106 return Err(MetaRejectionReason::Conflict {
1107 resource: format!("index `{index_name}` on table {}", job.table_id),
1108 reason: format!("index is {}, not Public", record.phase),
1109 }
1110 .into());
1111 }
1112 Some(_) => {}
1113 }
1114 }
1115 DdlDefinition::AlterSchema { .. } => {}
1116 }
1117 let mut job = job.clone();
1118 job.metadata_version = version;
1119 job.updated_at = commit_ts;
1120 self.next_job_id = self.next_job_id.max(job.job_id + 1);
1121 self.jobs.insert(job.job_id, job);
1122 Ok(())
1123 }
1124
1125 fn apply_advance_phase(
1126 &mut self,
1127 job_id: u64,
1128 to: DdlPhase,
1129 pinned_snapshot: Option<HlcTimestamp>,
1130 expected_version: Option<MetadataVersion>,
1131 commit_ts: HlcTimestamp,
1132 version: MetadataVersion,
1133 ) -> Result<(), DdlRejection> {
1134 let job = self
1135 .jobs
1136 .get(&job_id)
1137 .ok_or(MetaRejectionReason::NotFound {
1138 resource: format!("DDL job {job_id}"),
1139 })?;
1140 if let Some(expected) = expected_version {
1141 if expected != job.metadata_version {
1142 return Err(MetaRejectionReason::StaleWrite {
1143 resource: format!("DDL job {job_id}"),
1144 current: job.metadata_version,
1145 attempted: expected,
1146 }
1147 .into());
1148 }
1149 }
1150 if job.state.is_terminal() {
1151 return Err(MetaRejectionReason::Conflict {
1152 resource: format!("DDL job {job_id}"),
1153 reason: format!("terminal state {:?}", job.state),
1154 }
1155 .into());
1156 }
1157 if job.state != SchemaJobState::Running {
1158 return Err(DdlRejection::JobNotRunning {
1159 job_id,
1160 state: job.state,
1161 });
1162 }
1163 if job.phase == to {
1165 if to == DdlPhase::Backfilling && job.pinned_snapshot != pinned_snapshot {
1166 return Err(MetaRejectionReason::Conflict {
1167 resource: format!("DDL job {job_id}"),
1168 reason: "phase already reached with a different pinned snapshot".to_owned(),
1169 }
1170 .into());
1171 }
1172 return Ok(());
1173 }
1174 if !job.phase.can_transition(to) {
1175 return Err(DdlRejection::IllegalPhaseTransition {
1176 job_id,
1177 from: job.phase,
1178 to,
1179 });
1180 }
1181 if to == DdlPhase::Public && job.kind != DdlJobKind::DropIndex {
1184 return Err(MetaRejectionReason::Invalid {
1185 reason: "Public is reached through PublishJob, never AdvancePhase".to_owned(),
1186 }
1187 .into());
1188 }
1189 if to == DdlPhase::Backfilling && pinned_snapshot.is_none() {
1190 return Err(MetaRejectionReason::Invalid {
1191 reason: "Backfilling requires the job-wide pinned snapshot".to_owned(),
1192 }
1193 .into());
1194 }
1195 let job = self.jobs.get_mut(&job_id).expect("job existence checked");
1196 job.phase = to;
1197 if to == DdlPhase::Backfilling {
1198 job.pinned_snapshot = pinned_snapshot;
1199 }
1200 job.updated_at = commit_ts;
1201 job.metadata_version = version;
1202 if let Some(index_name) = job.definition.index_name() {
1204 let key = (job.table_id, index_name.to_owned());
1205 if let Some(record) = self.indexes.get_mut(&key) {
1206 record.phase = to;
1207 if to == DdlPhase::Dropping {
1208 record.dropping_since = Some(version);
1209 }
1210 record.metadata_version = version;
1211 }
1212 }
1213 Ok(())
1214 }
1215
1216 fn apply_update_tablet_progress(
1217 &mut self,
1218 job_id: u64,
1219 progress: &TabletDdlProgress,
1220 commit_ts: HlcTimestamp,
1221 version: MetadataVersion,
1222 ) -> Result<(), DdlRejection> {
1223 let job = self
1224 .jobs
1225 .get_mut(&job_id)
1226 .ok_or(MetaRejectionReason::NotFound {
1227 resource: format!("DDL job {job_id}"),
1228 })?;
1229 if job.phase != DdlPhase::Backfilling {
1230 let dominated = job
1233 .tablet_progress
1234 .get(&progress.tablet_id)
1235 .is_some_and(|existing| {
1236 existing.stage >= progress.stage
1237 && existing.rows_scanned >= progress.rows_scanned
1238 });
1239 if dominated {
1240 return Ok(());
1241 }
1242 return Err(MetaRejectionReason::Conflict {
1243 resource: format!("DDL job {job_id}"),
1244 reason: format!("job is {}, not Backfilling", job.phase),
1245 }
1246 .into());
1247 }
1248 if let Some(existing) = job.tablet_progress.get(&progress.tablet_id) {
1249 if progress.stage < existing.stage {
1250 return Err(DdlRejection::TabletProgressRegression {
1251 job_id,
1252 tablet: progress.tablet_id,
1253 reason: format!("stage {} -> {}", existing.stage, progress.stage),
1254 });
1255 }
1256 if progress.stage == existing.stage && progress.rows_scanned < existing.rows_scanned {
1257 return Err(DdlRejection::TabletProgressRegression {
1258 job_id,
1259 tablet: progress.tablet_id,
1260 reason: format!(
1261 "rows_scanned {} -> {}",
1262 existing.rows_scanned, progress.rows_scanned
1263 ),
1264 });
1265 }
1266 }
1267 job.tablet_progress
1268 .insert(progress.tablet_id, progress.clone());
1269 job.updated_at = commit_ts;
1270 job.metadata_version = version;
1271 Ok(())
1272 }
1273
1274 fn apply_forget_tablet_progress(
1275 &mut self,
1276 job_id: u64,
1277 tablet_id: TabletId,
1278 commit_ts: HlcTimestamp,
1279 version: MetadataVersion,
1280 ) -> Result<(), DdlRejection> {
1281 let job = self
1282 .jobs
1283 .get_mut(&job_id)
1284 .ok_or(MetaRejectionReason::NotFound {
1285 resource: format!("DDL job {job_id}"),
1286 })?;
1287 if !matches!(job.phase, DdlPhase::Backfilling | DdlPhase::Validating) {
1288 return Err(MetaRejectionReason::Conflict {
1289 resource: format!("DDL job {job_id}"),
1290 reason: format!(
1291 "job is {}; tablet cursors are only mutable while driving",
1292 job.phase
1293 ),
1294 }
1295 .into());
1296 }
1297 if job.tablet_progress.remove(&tablet_id).is_some() {
1298 job.updated_at = commit_ts;
1299 job.metadata_version = version;
1300 }
1301 Ok(())
1302 }
1303
1304 fn apply_report_tablet_validation(
1305 &mut self,
1306 job_id: u64,
1307 report: &TabletValidationReport,
1308 commit_ts: HlcTimestamp,
1309 version: MetadataVersion,
1310 ) -> Result<(), DdlRejection> {
1311 let job = self
1312 .jobs
1313 .get_mut(&job_id)
1314 .ok_or(MetaRejectionReason::NotFound {
1315 resource: format!("DDL job {job_id}"),
1316 })?;
1317 if job.phase != DdlPhase::Validating {
1318 return Err(MetaRejectionReason::Conflict {
1319 resource: format!("DDL job {job_id}"),
1320 reason: format!("job is {}, not Validating", job.phase),
1321 }
1322 .into());
1323 }
1324 let progress = job.tablet_progress.get_mut(&report.tablet_id).ok_or(
1325 MetaRejectionReason::NotFound {
1326 resource: format!("tablet {} progress of job {job_id}", report.tablet_id),
1327 },
1328 )?;
1329 if progress.stage < TabletDdlStage::CaughtUp {
1330 return Err(DdlRejection::TabletProgressRegression {
1331 job_id,
1332 tablet: report.tablet_id,
1333 reason: "validation reported before catch-up completed".to_owned(),
1334 });
1335 }
1336 if progress.stage == TabletDdlStage::Validated
1337 && progress.validation.as_ref() == Some(report)
1338 {
1339 return Ok(());
1340 }
1341 progress.validation = Some(report.clone());
1342 progress.caught_up_through = Some(report.watermark);
1343 if report.passed() {
1344 progress.stage = TabletDdlStage::Validated;
1345 }
1346 job.updated_at = commit_ts;
1347 job.metadata_version = version;
1348 Ok(())
1349 }
1350
1351 fn apply_publish_job(
1352 &mut self,
1353 job_id: u64,
1354 published_at: HlcTimestamp,
1355 commit_ts: HlcTimestamp,
1356 version: MetadataVersion,
1357 ) -> Result<(), DdlRejection> {
1358 let job = self
1359 .jobs
1360 .get(&job_id)
1361 .ok_or(MetaRejectionReason::NotFound {
1362 resource: format!("DDL job {job_id}"),
1363 })?;
1364 if job.state == SchemaJobState::Succeeded
1365 && job.phase == DdlPhase::Public
1366 && job.kind != DdlJobKind::DropIndex
1367 {
1368 return Ok(()); }
1370 if job.kind == DdlJobKind::DropIndex {
1371 return Err(MetaRejectionReason::Invalid {
1372 reason: "drop jobs ride AdvancePhase(Public -> Dropping), never PublishJob"
1373 .to_owned(),
1374 }
1375 .into());
1376 }
1377 if job.state != SchemaJobState::Running {
1378 return Err(DdlRejection::JobNotRunning {
1379 job_id,
1380 state: job.state,
1381 });
1382 }
1383 if job.phase != DdlPhase::Validating {
1384 return Err(DdlRejection::IllegalPhaseTransition {
1385 job_id,
1386 from: job.phase,
1387 to: DdlPhase::Public,
1388 });
1389 }
1390 let anchor = self
1393 .tables
1394 .get(&job.table_id)
1395 .ok_or(MetaRejectionReason::NotFound {
1396 resource: format!("table {}", job.table_id),
1397 })?;
1398 if anchor.schema_version != job.source_schema_version {
1399 return Err(DdlRejection::SchemaVersionMismatch {
1400 table_id: job.table_id,
1401 expected: job.source_schema_version,
1402 found: anchor.schema_version,
1403 });
1404 }
1405 let pending: Vec<TabletId> = job
1406 .tablet_progress
1407 .values()
1408 .filter(|progress| {
1409 progress.stage != TabletDdlStage::Validated
1410 || progress
1411 .validation
1412 .as_ref()
1413 .is_none_or(|report| !report.passed())
1414 })
1415 .map(|progress| progress.tablet_id)
1416 .collect();
1417 if job.tablet_progress.is_empty() || !pending.is_empty() {
1418 return Err(DdlRejection::ValidationIncomplete { job_id, pending });
1419 }
1420 match &job.definition {
1421 DdlDefinition::AddIndex { index_name, .. } => {
1422 let key = (job.table_id, index_name.clone());
1423 let record = self
1424 .indexes
1425 .get_mut(&key)
1426 .ok_or(MetaRejectionReason::NotFound {
1427 resource: format!("index `{index_name}` on table {}", job.table_id),
1428 })?;
1429 record.phase = DdlPhase::Public;
1430 record.publication_version = Some(version);
1431 record.published_at = Some(published_at);
1432 record.metadata_version = version;
1433 }
1434 DdlDefinition::AlterSchema { target } => {
1435 let anchor = self
1436 .tables
1437 .get_mut(&job.table_id)
1438 .expect("anchor existence checked");
1439 anchor.schema_version = SchemaVersion(job.source_schema_version.get() + 1);
1440 anchor.schema = target.clone();
1441 anchor.metadata_version = version;
1442 }
1443 DdlDefinition::DropIndex { .. } => unreachable!("drop kind refused above"),
1444 }
1445 let job = self.jobs.get_mut(&job_id).expect("job existence checked");
1446 job.phase = DdlPhase::Public;
1447 job.state = SchemaJobState::Succeeded;
1448 job.updated_at = commit_ts;
1449 job.metadata_version = version;
1450 Ok(())
1451 }
1452
1453 fn apply_set_job_state(
1454 &mut self,
1455 job_id: u64,
1456 state: SchemaJobState,
1457 updated_at: HlcTimestamp,
1458 error: &Option<String>,
1459 expected_version: Option<MetadataVersion>,
1460 version: MetadataVersion,
1461 ) -> Result<(), DdlRejection> {
1462 let record = self
1463 .jobs
1464 .get_mut(&job_id)
1465 .ok_or(MetaRejectionReason::NotFound {
1466 resource: format!("DDL job {job_id}"),
1467 })?;
1468 if let Some(expected) = expected_version {
1469 if expected != record.metadata_version {
1470 return Err(MetaRejectionReason::StaleWrite {
1471 resource: format!("DDL job {job_id}"),
1472 current: record.metadata_version,
1473 attempted: expected,
1474 }
1475 .into());
1476 }
1477 }
1478 if record.state.is_terminal() {
1479 return Err(MetaRejectionReason::Conflict {
1480 resource: format!("DDL job {job_id}"),
1481 reason: format!("terminal state {:?}", record.state),
1482 }
1483 .into());
1484 }
1485 if record.state != state && !record.state.can_transition(state) {
1486 return Err(MetaRejectionReason::Conflict {
1487 resource: format!("DDL job {job_id}"),
1488 reason: format!("illegal transition {:?} -> {:?}", record.state, state),
1489 }
1490 .into());
1491 }
1492 if record.state == state && record.error == *error {
1493 return Ok(());
1494 }
1495 record.state = state;
1496 record.updated_at = updated_at;
1497 record.error = error.clone();
1498 record.metadata_version = version;
1499 Ok(())
1500 }
1501
1502 fn apply_remove_index_record(
1503 &mut self,
1504 job_id: u64,
1505 version: MetadataVersion,
1506 ) -> Result<(), DdlRejection> {
1507 let job = self
1508 .jobs
1509 .get(&job_id)
1510 .ok_or(MetaRejectionReason::NotFound {
1511 resource: format!("DDL job {job_id}"),
1512 })?;
1513 if !matches!(
1514 job.state,
1515 SchemaJobState::RollingBack | SchemaJobState::Failed
1516 ) {
1517 return Err(MetaRejectionReason::Conflict {
1518 resource: format!("DDL job {job_id}"),
1519 reason: "index records are removed only during rollback".to_owned(),
1520 }
1521 .into());
1522 }
1523 let Some(index_name) = job.definition.index_name() else {
1524 return Ok(());
1525 };
1526 let key = (job.table_id, index_name.to_owned());
1527 match self.indexes.get(&key) {
1528 None => Ok(()),
1529 Some(record) if record.phase == DdlPhase::Public => {
1530 Err(MetaRejectionReason::Conflict {
1531 resource: format!("index `{index_name}` on table {}", job.table_id),
1532 reason: "refusing to remove a published index".to_owned(),
1533 }
1534 .into())
1535 }
1536 Some(_) => {
1537 self.indexes.remove(&key);
1538 let job = self.jobs.get_mut(&job_id).expect("job existence checked");
1539 job.metadata_version = version;
1540 Ok(())
1541 }
1542 }
1543 }
1544
1545 fn apply_reclaim_index(
1546 &mut self,
1547 table_id: TableId,
1548 index_name: &str,
1549 _version: MetadataVersion,
1550 ) -> Result<(), DdlRejection> {
1551 let key = (table_id, index_name.to_owned());
1552 let Some(record) = self.indexes.get(&key) else {
1553 return Ok(()); };
1555 if record.phase != DdlPhase::Dropping {
1556 return Err(MetaRejectionReason::Conflict {
1557 resource: format!("index `{index_name}` on table {table_id}"),
1558 reason: format!("index is {}, not Dropping", record.phase),
1559 }
1560 .into());
1561 }
1562 let required = record
1563 .dropping_since
1564 .expect("Dropping records carry dropping_since");
1565 let oldest_reader = self.oldest_reader_version();
1566 if oldest_reader.is_some_and(|oldest| oldest < required) {
1567 return Err(DdlRejection::ReclaimBlocked {
1568 table_id,
1569 index_name: index_name.to_owned(),
1570 oldest_reader,
1571 required,
1572 });
1573 }
1574 self.indexes.remove(&key);
1575 Ok(())
1576 }
1577
1578 fn apply_pin_reader(
1579 &mut self,
1580 reader_id: u64,
1581 version: MetadataVersion,
1582 ) -> Result<(), DdlRejection> {
1583 if reader_id == 0 {
1584 return Err(MetaRejectionReason::Invalid {
1585 reason: "reserved zero reader id".to_owned(),
1586 }
1587 .into());
1588 }
1589 if let Some(existing) = self.reader_pins.get(&reader_id) {
1590 if version < *existing {
1591 return Err(MetaRejectionReason::Conflict {
1592 resource: format!("reader {reader_id}"),
1593 reason: format!("reader pins only move forward ({} -> {version})", *existing),
1594 }
1595 .into());
1596 }
1597 }
1598 self.reader_pins.insert(reader_id, version);
1599 Ok(())
1600 }
1601
1602 pub fn job(&self, job_id: u64) -> Option<&DdlJobRecord> {
1606 self.jobs.get(&job_id)
1607 }
1608
1609 pub fn index(&self, table_id: TableId, index_name: &str) -> Option<&DdlIndexRecord> {
1611 self.indexes.get(&(table_id, index_name.to_owned()))
1612 }
1613
1614 pub fn table_anchor(&self, table_id: TableId) -> Option<&TableAnchor> {
1616 self.tables.get(&table_id)
1617 }
1618
1619 pub fn oldest_reader_version(&self) -> Option<MetadataVersion> {
1621 self.reader_pins.values().copied().min()
1622 }
1623
1624 pub fn planner_visible_at(
1629 &self,
1630 table_id: TableId,
1631 version: MetadataVersion,
1632 ) -> Vec<DdlIndexRecord> {
1633 self.indexes
1634 .values()
1635 .filter(|record| {
1636 record.table_id == table_id
1637 && record
1638 .publication_version
1639 .is_some_and(|publication| publication <= version)
1640 && record
1641 .dropping_since
1642 .is_none_or(|dropping| version < dropping)
1643 })
1644 .cloned()
1645 .collect()
1646 }
1647
1648 pub fn planner_visible(&self, table_id: TableId) -> Vec<DdlIndexRecord> {
1650 self.planner_visible_at(table_id, self.metadata_version)
1651 }
1652
1653 pub fn write_maintained(&self, table_id: TableId) -> Vec<DdlIndexRecord> {
1658 self.indexes
1659 .values()
1660 .filter(|record| {
1661 record.table_id == table_id
1662 && matches!(
1663 record.phase,
1664 DdlPhase::WriteOnly
1665 | DdlPhase::Backfilling
1666 | DdlPhase::Validating
1667 | DdlPhase::Public
1668 )
1669 })
1670 .cloned()
1671 .collect()
1672 }
1673}
1674
1675pub type DeltaStream<'a> = Box<dyn Iterator<Item = (HlcTimestamp, Key, Option<Vec<u8>>)> + 'a>;
1684
1685pub trait BackfillKeyspace {
1692 fn pin_snapshot(&self, ts: HlcTimestamp) -> Result<Box<dyn SnapshotPin>, TabletDataError>;
1695
1696 fn snapshot_at(&self, ts: HlcTimestamp) -> Result<RecordStream<'_>, TabletDataError>;
1699
1700 fn deltas_after(&self, ts: HlcTimestamp) -> Result<DeltaStream<'_>, TabletDataError>;
1704}
1705
1706pub trait HiddenIndexSink {
1713 fn begin_build(&mut self, index: &str) -> Result<(), TabletDataError>;
1716
1717 fn stage_entry(&mut self, index: &str, key: &Key, entry: &[u8]) -> Result<(), TabletDataError>;
1719
1720 fn install_staged(&mut self, index: &str) -> Result<(), TabletDataError>;
1723
1724 fn apply_delta(
1729 &mut self,
1730 index: &str,
1731 key: &Key,
1732 entry: Option<&[u8]>,
1733 ) -> Result<(), TabletDataError>;
1734
1735 fn drop_generation(&mut self, index: &str) -> Result<(), TabletDataError>;
1738
1739 fn generation_entries(&self, index: &str) -> Result<BTreeMap<Key, Vec<u8>>, TabletDataError>;
1741
1742 fn entry_count(&self, index: &str) -> Result<u64, TabletDataError> {
1744 Ok(self.generation_entries(index)?.len() as u64)
1745 }
1746}
1747
1748pub trait ApplySideIndexMaintainer {
1754 fn sync_definitions(&mut self, maintained: Vec<String>) -> Result<(), TabletDataError>;
1758
1759 fn apply_committed_write(
1764 &mut self,
1765 key: &Key,
1766 row: Option<&[u8]>,
1767 ) -> Result<(), TabletDataError>;
1768}
1769
1770pub trait DdlTabletProvider {
1774 fn tablets_of(&self, table: TableId) -> Vec<TabletId>;
1776
1777 fn keyspace(&self, tablet: TabletId) -> Result<Box<dyn BackfillKeyspace>, TabletDataError>;
1779
1780 fn sink(&self, tablet: TabletId) -> Result<Box<dyn HiddenIndexSink>, TabletDataError>;
1782
1783 fn maintainer(
1785 &self,
1786 tablet: TabletId,
1787 ) -> Result<Box<dyn ApplySideIndexMaintainer>, TabletDataError>;
1788}
1789
1790pub type IndexProjection = Arc<dyn Fn(&DdlIndexRecord, &Key, &[u8]) -> Vec<u8> + Send + Sync>;
1793
1794pub fn identity_projection() -> IndexProjection {
1798 Arc::new(|_index, _key, value| value.to_vec())
1799}
1800
1801type VersionedRows = BTreeMap<Key, Vec<(HlcTimestamp, Option<Vec<u8>>)>>;
1808
1809#[derive(Clone, Default)]
1814pub struct InMemoryDdlKeyspace {
1815 state: Arc<Mutex<VersionedRows>>,
1816}
1817
1818impl InMemoryDdlKeyspace {
1819 pub fn new() -> Self {
1821 Self::default()
1822 }
1823
1824 pub fn insert(&self, key: Key, ts: HlcTimestamp, value: Vec<u8>) {
1826 let mut rows = self.state.lock().expect("keyspace lock poisoned");
1827 let chain = rows.entry(key).or_default();
1828 chain.push((ts, Some(value)));
1829 chain.sort_by_key(|(version, _)| *version);
1830 }
1831
1832 pub fn delete(&self, key: Key, ts: HlcTimestamp) {
1834 let mut rows = self.state.lock().expect("keyspace lock poisoned");
1835 let chain = rows.entry(key).or_default();
1836 chain.push((ts, None));
1837 chain.sort_by_key(|(version, _)| *version);
1838 }
1839
1840 pub fn rows_at(&self, ts: HlcTimestamp) -> BTreeMap<Key, Vec<u8>> {
1842 let rows = self.state.lock().expect("keyspace lock poisoned");
1843 rows.iter()
1844 .filter_map(|(key, chain)| {
1845 let (_, value) = chain.iter().rfind(|(version, _)| *version <= ts)?;
1846 value.clone().map(|value| (key.clone(), value))
1847 })
1848 .collect()
1849 }
1850}
1851
1852impl BackfillKeyspace for InMemoryDdlKeyspace {
1853 fn pin_snapshot(&self, ts: HlcTimestamp) -> Result<Box<dyn SnapshotPin>, TabletDataError> {
1854 Ok(Box::new(DdlSnapshotPin { ts }))
1855 }
1856
1857 fn snapshot_at(&self, ts: HlcTimestamp) -> Result<RecordStream<'_>, TabletDataError> {
1858 Ok(Box::new(self.rows_at(ts).into_iter()))
1859 }
1860
1861 fn deltas_after(&self, ts: HlcTimestamp) -> Result<DeltaStream<'_>, TabletDataError> {
1862 let rows = self.state.lock().expect("keyspace lock poisoned");
1863 let mut deltas: Vec<(HlcTimestamp, Key, Option<Vec<u8>>)> = Vec::new();
1864 for (key, chain) in rows.iter() {
1865 for (version, value) in chain {
1866 if *version > ts {
1867 deltas.push((*version, key.clone(), value.clone()));
1868 }
1869 }
1870 }
1871 deltas.sort_by(|(left_ts, left_key, _), (right_ts, right_key, _)| {
1872 (left_ts, left_key).cmp(&(right_ts, right_key))
1873 });
1874 Ok(Box::new(deltas.into_iter()))
1875 }
1876}
1877
1878struct DdlSnapshotPin {
1882 ts: HlcTimestamp,
1883}
1884
1885impl SnapshotPin for DdlSnapshotPin {
1886 fn pinned_at(&self) -> HlcTimestamp {
1887 self.ts
1888 }
1889}
1890
1891#[derive(Clone, Default)]
1896pub struct InMemoryTabletIndexes {
1897 state: Arc<Mutex<IndexesState>>,
1898}
1899
1900#[derive(Default)]
1901struct IndexesState {
1902 maintained: Vec<String>,
1904 staged: BTreeMap<String, BTreeMap<Key, Vec<u8>>>,
1906 generations: BTreeMap<String, BTreeMap<Key, Vec<u8>>>,
1908 begin_builds: usize,
1910}
1911
1912impl InMemoryTabletIndexes {
1913 pub fn new() -> Self {
1915 Self::default()
1916 }
1917
1918 pub fn entries(&self, index: &str) -> BTreeMap<Key, Vec<u8>> {
1921 self.state
1922 .lock()
1923 .expect("indexes lock poisoned")
1924 .generations
1925 .get(index)
1926 .cloned()
1927 .unwrap_or_default()
1928 }
1929
1930 pub fn begin_build_count(&self) -> usize {
1933 self.state
1934 .lock()
1935 .expect("indexes lock poisoned")
1936 .begin_builds
1937 }
1938
1939 pub fn is_installed(&self, index: &str) -> bool {
1941 self.state
1942 .lock()
1943 .expect("indexes lock poisoned")
1944 .generations
1945 .contains_key(index)
1946 }
1947
1948 pub fn maintained(&self) -> Vec<String> {
1950 self.state
1951 .lock()
1952 .expect("indexes lock poisoned")
1953 .maintained
1954 .clone()
1955 }
1956
1957 #[cfg(test)]
1960 pub fn seed_staged(&self, index: &str, key: Key, entry: Vec<u8>) {
1961 self.state
1962 .lock()
1963 .expect("indexes lock poisoned")
1964 .staged
1965 .entry(index.to_owned())
1966 .or_default()
1967 .insert(key, entry);
1968 }
1969}
1970
1971impl HiddenIndexSink for InMemoryTabletIndexes {
1972 fn begin_build(&mut self, index: &str) -> Result<(), TabletDataError> {
1973 let mut state = self.state.lock().expect("indexes lock poisoned");
1974 state.staged.insert(index.to_owned(), BTreeMap::new());
1975 state.begin_builds += 1;
1976 Ok(())
1977 }
1978
1979 fn stage_entry(&mut self, index: &str, key: &Key, entry: &[u8]) -> Result<(), TabletDataError> {
1980 let mut state = self.state.lock().expect("indexes lock poisoned");
1981 let Some(staged) = state.staged.get_mut(index) else {
1982 return Err(TabletDataError::NoStagedBuild);
1983 };
1984 staged.insert(key.clone(), entry.to_vec());
1985 Ok(())
1986 }
1987
1988 fn install_staged(&mut self, index: &str) -> Result<(), TabletDataError> {
1989 let mut state = self.state.lock().expect("indexes lock poisoned");
1990 let Some(staged) = state.staged.remove(index) else {
1991 return Err(TabletDataError::NoStagedBuild);
1992 };
1993 state.generations.insert(index.to_owned(), staged);
1994 Ok(())
1995 }
1996
1997 fn apply_delta(
1998 &mut self,
1999 index: &str,
2000 key: &Key,
2001 entry: Option<&[u8]>,
2002 ) -> Result<(), TabletDataError> {
2003 let mut state = self.state.lock().expect("indexes lock poisoned");
2004 let Some(generation) = state.generations.get_mut(index) else {
2005 return Err(TabletDataError::Sink(format!(
2006 "generation `{index}` is not installed"
2007 )));
2008 };
2009 match entry {
2010 Some(entry) => {
2011 generation.insert(key.clone(), entry.to_vec());
2012 }
2013 None => {
2014 generation.remove(key);
2015 }
2016 }
2017 Ok(())
2018 }
2019
2020 fn drop_generation(&mut self, index: &str) -> Result<(), TabletDataError> {
2021 let mut state = self.state.lock().expect("indexes lock poisoned");
2022 state.generations.remove(index);
2023 state.staged.remove(index);
2024 Ok(())
2025 }
2026
2027 fn generation_entries(&self, index: &str) -> Result<BTreeMap<Key, Vec<u8>>, TabletDataError> {
2028 Ok(self.entries(index))
2029 }
2030}
2031
2032impl ApplySideIndexMaintainer for InMemoryTabletIndexes {
2033 fn sync_definitions(&mut self, maintained: Vec<String>) -> Result<(), TabletDataError> {
2034 self.state.lock().expect("indexes lock poisoned").maintained = maintained;
2035 Ok(())
2036 }
2037
2038 fn apply_committed_write(
2039 &mut self,
2040 key: &Key,
2041 row: Option<&[u8]>,
2042 ) -> Result<(), TabletDataError> {
2043 let mut state = self.state.lock().expect("indexes lock poisoned");
2044 for index in state.maintained.clone() {
2045 if let Some(generation) = state.generations.get_mut(&index) {
2048 match row {
2049 Some(row) => {
2050 generation.insert(key.clone(), row.to_vec());
2051 }
2052 None => {
2053 generation.remove(key);
2054 }
2055 }
2056 }
2057 }
2058 Ok(())
2059 }
2060}
2061
2062struct InMemoryTablet {
2064 table_id: TableId,
2065 keyspace: InMemoryDdlKeyspace,
2066 indexes: InMemoryTabletIndexes,
2067}
2068
2069#[derive(Clone, Default)]
2074pub struct InMemoryDdlTablets {
2075 tablets: Arc<Mutex<BTreeMap<TabletId, InMemoryTablet>>>,
2076}
2077
2078impl InMemoryDdlTablets {
2079 pub fn new() -> Self {
2081 Self::default()
2082 }
2083
2084 pub fn add_tablet(&self, tablet: TabletId, table: TableId) {
2086 self.tablets
2087 .lock()
2088 .expect("tablets lock poisoned")
2089 .entry(tablet)
2090 .or_insert_with(|| InMemoryTablet {
2091 table_id: table,
2092 keyspace: InMemoryDdlKeyspace::new(),
2093 indexes: InMemoryTabletIndexes::new(),
2094 });
2095 }
2096
2097 #[cfg(test)]
2100 pub fn remove_tablet(&self, tablet: TabletId) {
2101 self.tablets
2102 .lock()
2103 .expect("tablets lock poisoned")
2104 .remove(&tablet);
2105 }
2106
2107 pub fn keyspace_handle(&self, tablet: TabletId) -> InMemoryDdlKeyspace {
2109 self.tablets
2110 .lock()
2111 .expect("tablets lock poisoned")
2112 .get(&tablet)
2113 .expect("unknown tablet")
2114 .keyspace
2115 .clone()
2116 }
2117
2118 pub fn indexes_handle(&self, tablet: TabletId) -> InMemoryTabletIndexes {
2120 self.tablets
2121 .lock()
2122 .expect("tablets lock poisoned")
2123 .get(&tablet)
2124 .expect("unknown tablet")
2125 .indexes
2126 .clone()
2127 }
2128
2129 pub fn commit_write(
2133 &self,
2134 tablet: TabletId,
2135 key: Key,
2136 ts: HlcTimestamp,
2137 row: Option<Vec<u8>>,
2138 ) -> Result<(), TabletDataError> {
2139 let (keyspace, mut indexes) = {
2140 let tablets = self.tablets.lock().expect("tablets lock poisoned");
2141 let tablet = tablets.get(&tablet).expect("unknown tablet");
2142 (tablet.keyspace.clone(), tablet.indexes.clone())
2143 };
2144 match row {
2145 Some(row) => {
2146 keyspace.insert(key.clone(), ts, row.clone());
2147 indexes.apply_committed_write(&key, Some(&row))
2148 }
2149 None => {
2150 keyspace.delete(key.clone(), ts);
2151 indexes.apply_committed_write(&key, None)
2152 }
2153 }
2154 }
2155}
2156
2157impl DdlTabletProvider for InMemoryDdlTablets {
2158 fn tablets_of(&self, table: TableId) -> Vec<TabletId> {
2159 self.tablets
2160 .lock()
2161 .expect("tablets lock poisoned")
2162 .iter()
2163 .filter(|(_, tablet)| tablet.table_id == table)
2164 .map(|(tablet_id, _)| *tablet_id)
2165 .collect()
2166 }
2167
2168 fn keyspace(&self, tablet: TabletId) -> Result<Box<dyn BackfillKeyspace>, TabletDataError> {
2169 let tablets = self.tablets.lock().expect("tablets lock poisoned");
2170 let Some(tablet) = tablets.get(&tablet) else {
2171 return Err(TabletDataError::Keyspace(format!(
2172 "unknown tablet {tablet}"
2173 )));
2174 };
2175 Ok(Box::new(tablet.keyspace.clone()))
2176 }
2177
2178 fn sink(&self, tablet: TabletId) -> Result<Box<dyn HiddenIndexSink>, TabletDataError> {
2179 let tablets = self.tablets.lock().expect("tablets lock poisoned");
2180 let Some(tablet) = tablets.get(&tablet) else {
2181 return Err(TabletDataError::Sink(format!("unknown tablet {tablet}")));
2182 };
2183 Ok(Box::new(tablet.indexes.clone()))
2184 }
2185
2186 fn maintainer(
2187 &self,
2188 tablet: TabletId,
2189 ) -> Result<Box<dyn ApplySideIndexMaintainer>, TabletDataError> {
2190 let tablets = self.tablets.lock().expect("tablets lock poisoned");
2191 let Some(tablet) = tablets.get(&tablet) else {
2192 return Err(TabletDataError::Sink(format!("unknown tablet {tablet}")));
2193 };
2194 Ok(Box::new(tablet.indexes.clone()))
2195 }
2196}
2197
2198#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2204pub enum DriveOutcome {
2205 Completed,
2208 Parked,
2210 RolledBack,
2213}
2214
2215#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2218enum DriveStep {
2219 Admitted,
2221 PhaseAdvanced {
2223 from: DdlPhase,
2225 to: DdlPhase,
2227 },
2228 TabletBackfilled {
2230 tablet: TabletId,
2232 },
2233 TabletValidated {
2235 tablet: TabletId,
2237 },
2238 Published {
2240 version: MetadataVersion,
2242 },
2243 Parked,
2245 RolledBack,
2247 Terminal,
2249}
2250
2251#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2253pub enum ReclaimOutcome {
2254 Reclaimed,
2256 Blocked {
2259 oldest_reader: Option<MetadataVersion>,
2261 required: MetadataVersion,
2263 },
2264}
2265
2266#[derive(Clone, Debug, PartialEq)]
2268pub struct DdlJobStatus {
2269 pub record: DdlJobRecord,
2271 pub tablets_registered: usize,
2273 pub tablets_caught_up: usize,
2275 pub tablets_validated: usize,
2277 pub rows_backfilled: u64,
2279}
2280
2281pub struct DdlDriver<P: DdlTabletProvider> {
2288 store: Arc<Mutex<DdlJobStore>>,
2289 provider: P,
2290 clock: HlcClock,
2291 projection: IndexProjection,
2292}
2293
2294impl<P: DdlTabletProvider> DdlDriver<P> {
2295 pub fn new(store: Arc<Mutex<DdlJobStore>>, provider: P) -> Self {
2297 Self::with_projection(store, provider, identity_projection())
2298 }
2299
2300 pub fn with_projection(
2302 store: Arc<Mutex<DdlJobStore>>,
2303 provider: P,
2304 projection: IndexProjection,
2305 ) -> Self {
2306 Self {
2307 store,
2308 provider,
2309 clock: HlcClock::new(0, Duration::from_secs(30)),
2310 projection,
2311 }
2312 }
2313
2314 pub fn store_handle(&self) -> Arc<Mutex<DdlJobStore>> {
2316 self.store.clone()
2317 }
2318
2319 pub fn submit_add_index(
2325 &self,
2326 database_id: DatabaseId,
2327 table_id: TableId,
2328 index_name: impl Into<String>,
2329 spec: serde_json::Value,
2330 source_schema_version: SchemaVersion,
2331 ) -> Result<u64, DdlError> {
2332 let created_at = self.clock.now()?;
2333 let job = DdlJobRecord {
2334 job_id: self.peek_next_job_id(),
2335 database_id,
2336 table_id,
2337 kind: DdlJobKind::AddIndex,
2338 state: SchemaJobState::Pending,
2339 phase: DdlPhase::Pending,
2340 definition: DdlDefinition::AddIndex {
2341 index_name: index_name.into(),
2342 spec,
2343 },
2344 source_schema_version,
2345 created_at,
2346 updated_at: created_at,
2347 pinned_snapshot: None,
2348 tablet_progress: BTreeMap::new(),
2349 error: None,
2350 metadata_version: MetadataVersion::ZERO,
2351 };
2352 self.apply(DdlCommand::SubmitJob { job: job.clone() })?;
2353 Ok(job.job_id)
2354 }
2355
2356 pub fn submit_drop_index(
2358 &self,
2359 database_id: DatabaseId,
2360 table_id: TableId,
2361 index_name: impl Into<String>,
2362 source_schema_version: SchemaVersion,
2363 ) -> Result<u64, DdlError> {
2364 let created_at = self.clock.now()?;
2365 let job = DdlJobRecord {
2366 job_id: self.peek_next_job_id(),
2367 database_id,
2368 table_id,
2369 kind: DdlJobKind::DropIndex,
2370 state: SchemaJobState::Pending,
2371 phase: DdlPhase::Public,
2372 definition: DdlDefinition::DropIndex {
2373 index_name: index_name.into(),
2374 },
2375 source_schema_version,
2376 created_at,
2377 updated_at: created_at,
2378 pinned_snapshot: None,
2379 tablet_progress: BTreeMap::new(),
2380 error: None,
2381 metadata_version: MetadataVersion::ZERO,
2382 };
2383 self.apply(DdlCommand::SubmitJob { job: job.clone() })?;
2384 Ok(job.job_id)
2385 }
2386
2387 pub fn submit_alter_schema(
2390 &self,
2391 database_id: DatabaseId,
2392 table_id: TableId,
2393 target: serde_json::Value,
2394 source_schema_version: SchemaVersion,
2395 ) -> Result<u64, DdlError> {
2396 let created_at = self.clock.now()?;
2397 let job = DdlJobRecord {
2398 job_id: self.peek_next_job_id(),
2399 database_id,
2400 table_id,
2401 kind: DdlJobKind::AlterSchema,
2402 state: SchemaJobState::Pending,
2403 phase: DdlPhase::Pending,
2404 definition: DdlDefinition::AlterSchema { target },
2405 source_schema_version,
2406 created_at,
2407 updated_at: created_at,
2408 pinned_snapshot: None,
2409 tablet_progress: BTreeMap::new(),
2410 error: None,
2411 metadata_version: MetadataVersion::ZERO,
2412 };
2413 self.apply(DdlCommand::SubmitJob { job: job.clone() })?;
2414 Ok(job.job_id)
2415 }
2416
2417 pub fn pause_job(&self, job_id: u64) -> Result<(), DdlError> {
2423 let record = self.job_record(job_id)?;
2424 if record.state != SchemaJobState::Running {
2425 return Err(DdlError::Rejection(DdlRejection::JobNotRunning {
2426 job_id,
2427 state: record.state,
2428 }));
2429 }
2430 self.apply(DdlCommand::SetJobState {
2431 job_id,
2432 state: SchemaJobState::Paused,
2433 updated_at: self.clock.now()?,
2434 error: None,
2435 expected_version: Some(record.metadata_version),
2436 })
2437 }
2438
2439 pub fn resume_job(&self, job_id: u64) -> Result<(), DdlError> {
2442 let record = self.job_record(job_id)?;
2443 if record.state != SchemaJobState::Paused {
2444 return Err(DdlError::Rejection(DdlRejection::Meta(
2445 MetaRejectionReason::Conflict {
2446 resource: format!("DDL job {job_id}"),
2447 reason: format!("cannot resume from {:?}", record.state),
2448 },
2449 )));
2450 }
2451 self.apply(DdlCommand::SetJobState {
2452 job_id,
2453 state: SchemaJobState::Pending,
2454 updated_at: self.clock.now()?,
2455 error: None,
2456 expected_version: Some(record.metadata_version),
2457 })
2458 }
2459
2460 pub fn cancel_job(&self, job_id: u64) -> Result<(), DdlError> {
2464 let record = self.job_record(job_id)?;
2465 if record.state.is_terminal() {
2466 return Err(DdlError::Rejection(DdlRejection::Meta(
2467 MetaRejectionReason::Conflict {
2468 resource: format!("DDL job {job_id}"),
2469 reason: format!("terminal state {:?}", record.state),
2470 },
2471 )));
2472 }
2473 self.apply(DdlCommand::SetJobState {
2474 job_id,
2475 state: SchemaJobState::Cancelling,
2476 updated_at: self.clock.now()?,
2477 error: None,
2478 expected_version: Some(record.metadata_version),
2479 })
2480 }
2481
2482 pub fn job_status(&self, job_id: u64) -> Option<DdlJobStatus> {
2484 let record = self
2485 .store
2486 .lock()
2487 .expect("store lock poisoned")
2488 .job(job_id)?
2489 .clone();
2490 let tablets_registered = record.tablet_progress.len();
2491 let tablets_caught_up = record
2492 .tablet_progress
2493 .values()
2494 .filter(|progress| progress.stage >= TabletDdlStage::CaughtUp)
2495 .count();
2496 let tablets_validated = record
2497 .tablet_progress
2498 .values()
2499 .filter(|progress| progress.stage == TabletDdlStage::Validated)
2500 .count();
2501 let rows_backfilled = record
2502 .tablet_progress
2503 .values()
2504 .map(|progress| progress.rows_scanned)
2505 .sum();
2506 Some(DdlJobStatus {
2507 record,
2508 tablets_registered,
2509 tablets_caught_up,
2510 tablets_validated,
2511 rows_backfilled,
2512 })
2513 }
2514
2515 pub fn validation_report(&self, job_id: u64) -> Option<JobValidationReport> {
2518 let record = self
2519 .store
2520 .lock()
2521 .expect("store lock poisoned")
2522 .job(job_id)?
2523 .clone();
2524 let tablets: Vec<TabletValidationReport> = record
2525 .tablet_progress
2526 .values()
2527 .filter_map(|progress| progress.validation.clone())
2528 .collect();
2529 if tablets.is_empty() {
2530 return None;
2531 }
2532 let total_expected = tablets.iter().map(|report| report.expected_rows).sum();
2533 let total_actual = tablets.iter().map(|report| report.actual_rows).sum();
2534 let passed = tablets.iter().all(TabletValidationReport::passed);
2535 Some(JobValidationReport {
2536 job_id,
2537 tablets,
2538 total_expected,
2539 total_actual,
2540 passed,
2541 })
2542 }
2543
2544 pub fn pin_reader(&self, reader_id: u64, version: MetadataVersion) -> Result<(), DdlError> {
2549 self.apply(DdlCommand::PinReader { reader_id, version })
2550 }
2551
2552 pub fn release_reader(&self, reader_id: u64) -> Result<(), DdlError> {
2554 self.apply(DdlCommand::ReleaseReader { reader_id })
2555 }
2556
2557 pub fn reclaim_index(
2561 &self,
2562 table_id: TableId,
2563 index_name: &str,
2564 ) -> Result<ReclaimOutcome, DdlError> {
2565 match self.apply(DdlCommand::ReclaimIndex {
2566 table_id,
2567 index_name: index_name.to_owned(),
2568 }) {
2569 Ok(()) => {
2570 for tablet in self.provider.tablets_of(table_id) {
2571 self.provider.sink(tablet)?.drop_generation(index_name)?;
2572 }
2573 Ok(ReclaimOutcome::Reclaimed)
2574 }
2575 Err(DdlError::Rejection(DdlRejection::ReclaimBlocked {
2576 oldest_reader,
2577 required,
2578 ..
2579 })) => Ok(ReclaimOutcome::Blocked {
2580 oldest_reader,
2581 required,
2582 }),
2583 Err(error) => Err(error),
2584 }
2585 }
2586
2587 pub fn drive_job(&self, job_id: u64) -> Result<DriveOutcome, DdlError> {
2593 loop {
2594 match self.step_job(job_id)? {
2595 DriveStep::Parked => return Ok(DriveOutcome::Parked),
2596 DriveStep::RolledBack => return Ok(DriveOutcome::RolledBack),
2597 DriveStep::Terminal => {
2598 let state = self.job_record(job_id)?.state;
2599 return Ok(match state {
2600 SchemaJobState::Succeeded => DriveOutcome::Completed,
2601 _ => DriveOutcome::RolledBack,
2602 });
2603 }
2604 DriveStep::Admitted
2605 | DriveStep::PhaseAdvanced { .. }
2606 | DriveStep::TabletBackfilled { .. }
2607 | DriveStep::TabletValidated { .. }
2608 | DriveStep::Published { .. } => {}
2609 }
2610 }
2611 }
2612
2613 fn step_job(&self, job_id: u64) -> Result<DriveStep, DdlError> {
2616 let job = self.job_record(job_id)?;
2617 match job.state {
2618 SchemaJobState::Pending => {
2619 self.set_state(job_id, SchemaJobState::Running, None)?;
2620 return Ok(DriveStep::Admitted);
2621 }
2622 SchemaJobState::Paused => return Ok(DriveStep::Parked),
2623 SchemaJobState::Cancelling | SchemaJobState::RollingBack => {
2624 self.rollback(&job, "cancelled by operator")?;
2625 return Ok(DriveStep::RolledBack);
2626 }
2627 SchemaJobState::Failed | SchemaJobState::Succeeded => {
2628 return Ok(DriveStep::Terminal);
2629 }
2630 SchemaJobState::Running => {}
2631 }
2632 match job.phase {
2633 DdlPhase::Pending => {
2634 self.advance_phase(job_id, DdlPhase::WriteOnly, None)?;
2637 self.sync_maintainers(job.table_id)?;
2638 Ok(DriveStep::PhaseAdvanced {
2639 from: DdlPhase::Pending,
2640 to: DdlPhase::WriteOnly,
2641 })
2642 }
2643 DdlPhase::WriteOnly => {
2644 let pin = self.clock.now()?;
2646 self.advance_phase(job_id, DdlPhase::Backfilling, Some(pin))?;
2647 Ok(DriveStep::PhaseAdvanced {
2648 from: DdlPhase::WriteOnly,
2649 to: DdlPhase::Backfilling,
2650 })
2651 }
2652 DdlPhase::Backfilling => {
2653 let tablets = self.provider.tablets_of(job.table_id);
2654 for tablet in &tablets {
2655 if !job.tablet_progress.contains_key(tablet) {
2656 self.update_progress(job_id, TabletDdlProgress::pending(*tablet))?;
2657 }
2658 }
2659 let mut job = self.job_record(job_id)?;
2660 let departed: Vec<TabletId> = job
2661 .tablet_progress
2662 .keys()
2663 .filter(|tablet| !tablets.contains(tablet))
2664 .copied()
2665 .collect();
2666 for tablet in departed {
2667 self.apply(DdlCommand::ForgetTabletProgress {
2668 job_id,
2669 tablet_id: tablet,
2670 })?;
2671 }
2672 job = self.job_record(job_id)?;
2673 let next = tablets.iter().copied().find(|tablet| {
2674 job.tablet_progress
2675 .get(tablet)
2676 .is_none_or(|progress| progress.stage < TabletDdlStage::CaughtUp)
2677 });
2678 match next {
2679 Some(tablet) => {
2680 let progress = job.tablet_progress.get(&tablet);
2681 if progress.is_none_or(|p| p.stage < TabletDdlStage::Backfilling) {
2682 let mut marker = TabletDdlProgress::pending(tablet);
2683 marker.stage = TabletDdlStage::Backfilling;
2684 self.update_progress(job_id, marker)?;
2685 job = self.job_record(job_id)?;
2686 }
2687 let progress = self.backfill_tablet(&job, tablet)?;
2688 self.update_progress(job_id, progress)?;
2689 Ok(DriveStep::TabletBackfilled { tablet })
2690 }
2691 None => {
2692 self.advance_phase(job_id, DdlPhase::Validating, None)?;
2693 Ok(DriveStep::PhaseAdvanced {
2694 from: DdlPhase::Backfilling,
2695 to: DdlPhase::Validating,
2696 })
2697 }
2698 }
2699 }
2700 DdlPhase::Validating => {
2701 let next = job
2705 .tablet_progress
2706 .values()
2707 .find(|progress| progress.stage < TabletDdlStage::Validated)
2708 .map(|progress| progress.tablet_id);
2709 match next {
2710 Some(tablet) => {
2711 let report = self.validate_tablet(&job, tablet)?;
2712 let passed = report.passed();
2713 self.apply(DdlCommand::ReportTabletValidation {
2714 job_id,
2715 report: report.clone(),
2716 })?;
2717 if !passed {
2718 let reason = format!(
2719 "tablet {tablet}: expected {} rows, built {} rows",
2720 report.expected_rows, report.actual_rows
2721 );
2722 self.rollback(&job, &reason)?;
2723 return Err(DdlError::ValidationFailed { job_id, reason });
2724 }
2725 Ok(DriveStep::TabletValidated { tablet })
2726 }
2727 None => {
2728 let published_at = self.clock.now()?;
2731 match self.apply(DdlCommand::PublishJob {
2732 job_id,
2733 published_at,
2734 }) {
2735 Ok(()) => Ok(DriveStep::Published {
2736 version: self
2737 .store
2738 .lock()
2739 .expect("store lock poisoned")
2740 .metadata_version,
2741 }),
2742 Err(DdlError::Rejection(
2743 reason @ DdlRejection::SchemaVersionMismatch { .. },
2744 )) => {
2745 self.rollback(&job, &reason.to_string())?;
2746 Err(DdlError::Rejection(reason))
2747 }
2748 Err(error) => Err(error),
2749 }
2750 }
2751 }
2752 }
2753 DdlPhase::Public => match job.kind {
2754 DdlJobKind::DropIndex => {
2757 self.advance_phase(job_id, DdlPhase::Dropping, None)?;
2758 self.sync_maintainers(job.table_id)?;
2759 Ok(DriveStep::PhaseAdvanced {
2760 from: DdlPhase::Public,
2761 to: DdlPhase::Dropping,
2762 })
2763 }
2764 DdlJobKind::AddIndex | DdlJobKind::AlterSchema => Ok(DriveStep::Terminal),
2765 },
2766 DdlPhase::Dropping => {
2767 self.set_state(job_id, SchemaJobState::Succeeded, None)?;
2768 Ok(DriveStep::Terminal)
2769 }
2770 }
2771 }
2772
2773 fn backfill_tablet(
2777 &self,
2778 job: &DdlJobRecord,
2779 tablet: TabletId,
2780 ) -> Result<TabletDdlProgress, DdlError> {
2781 let pin = job
2782 .pinned_snapshot
2783 .ok_or(DdlRejection::Meta(MetaRejectionReason::Invalid {
2784 reason: format!("job {} entered Backfilling without a pin", job.job_id),
2785 }))?;
2786 let keyspace = self.provider.keyspace(tablet)?;
2787 let _pin = keyspace.pin_snapshot(pin)?;
2788 let mut rows_scanned = 0_u64;
2789 match &job.definition {
2790 DdlDefinition::AddIndex { index_name, .. } => {
2791 let mut sink = self.provider.sink(tablet)?;
2792 let record = self.index_record(job, index_name)?;
2793 sink.begin_build(index_name)?;
2794 for (key, value) in keyspace.snapshot_at(pin)? {
2795 let entry = (self.projection)(&record, &key, &value);
2796 sink.stage_entry(index_name, &key, &entry)?;
2797 rows_scanned += 1;
2798 }
2799 sink.install_staged(index_name)?;
2800 }
2801 DdlDefinition::AlterSchema { .. } => {
2802 for (_key, _value) in keyspace.snapshot_at(pin)? {
2806 rows_scanned += 1;
2807 }
2808 }
2809 DdlDefinition::DropIndex { .. } => {
2810 return Err(DdlRejection::Meta(MetaRejectionReason::Invalid {
2811 reason: "drop jobs never backfill".to_owned(),
2812 })
2813 .into());
2814 }
2815 }
2816 let watermark = self.catch_up_tablet(job, tablet, pin)?;
2817 Ok(TabletDdlProgress {
2818 tablet_id: tablet,
2819 stage: TabletDdlStage::CaughtUp,
2820 rows_scanned,
2821 caught_up_through: Some(watermark),
2822 validation: None,
2823 })
2824 }
2825
2826 fn catch_up_tablet(
2831 &self,
2832 job: &DdlJobRecord,
2833 tablet: TabletId,
2834 from: HlcTimestamp,
2835 ) -> Result<HlcTimestamp, DdlError> {
2836 let keyspace = self.provider.keyspace(tablet)?;
2837 let mut sink = match &job.definition {
2838 DdlDefinition::AddIndex { index_name, .. } => {
2839 Some((self.provider.sink(tablet)?, index_name.clone()))
2840 }
2841 _ => None,
2842 };
2843 let mut watermark = from;
2844 loop {
2845 let mut saw_any = false;
2846 let mut max_ts = watermark;
2847 for (ts, key, value) in keyspace.deltas_after(watermark)? {
2848 saw_any = true;
2849 max_ts = max_ts.max(ts);
2850 if let Some((sink, index_name)) = &mut sink {
2851 let entry = match &value {
2852 Some(value) => {
2853 let record = self.index_record(job, index_name)?;
2854 Some((self.projection)(&record, &key, value))
2855 }
2856 None => None,
2857 };
2858 sink.apply_delta(index_name, &key, entry.as_deref())?;
2859 }
2860 }
2861 if !saw_any {
2862 return Ok(watermark);
2863 }
2864 watermark = max_ts;
2865 }
2866 }
2867
2868 fn validate_tablet(
2871 &self,
2872 job: &DdlJobRecord,
2873 tablet: TabletId,
2874 ) -> Result<TabletValidationReport, DdlError> {
2875 let pin = job.pinned_snapshot.expect("Validating implies a pin");
2876 let from = job
2877 .tablet_progress
2878 .get(&tablet)
2879 .and_then(|progress| progress.caught_up_through)
2880 .unwrap_or(pin);
2881 let watermark = self.catch_up_tablet(job, tablet, from)?;
2882 let keyspace = self.provider.keyspace(tablet)?;
2883 let expected: BTreeMap<Key, Vec<u8>> = match &job.definition {
2884 DdlDefinition::AddIndex { index_name, .. } => {
2885 let record = self.index_record(job, index_name)?;
2886 keyspace
2887 .snapshot_at(watermark)?
2888 .map(|(key, value)| {
2889 let entry = (self.projection)(&record, &key, &value);
2890 (key, entry)
2891 })
2892 .collect()
2893 }
2894 _ => keyspace.snapshot_at(watermark)?.collect(),
2897 };
2898 let actual = match &job.definition {
2899 DdlDefinition::AddIndex { index_name, .. } => {
2900 self.provider.sink(tablet)?.generation_entries(index_name)?
2901 }
2902 _ => expected.clone(),
2903 };
2904 Ok(TabletValidationReport {
2905 tablet_id: tablet,
2906 watermark,
2907 expected_rows: expected.len() as u64,
2908 actual_rows: actual.len() as u64,
2909 expected_checksum: generation_checksum(&expected),
2910 actual_checksum: generation_checksum(&actual),
2911 })
2912 }
2913
2914 fn rollback(&self, job: &DdlJobRecord, reason: &str) -> Result<(), DdlError> {
2919 let current = self.job_record(job.job_id)?;
2920 match current.state {
2921 SchemaJobState::Failed => return Ok(()),
2922 SchemaJobState::Succeeded => {
2923 return Err(DdlRejection::Meta(MetaRejectionReason::Conflict {
2924 resource: format!("DDL job {}", job.job_id),
2925 reason: "cannot roll back a succeeded job".to_owned(),
2926 })
2927 .into());
2928 }
2929 SchemaJobState::Running | SchemaJobState::Cancelling => {
2930 self.set_state(
2931 job.job_id,
2932 SchemaJobState::RollingBack,
2933 Some(reason.to_owned()),
2934 )?;
2935 }
2936 SchemaJobState::Pending | SchemaJobState::Paused | SchemaJobState::RollingBack => {}
2937 }
2938 if let DdlDefinition::AddIndex { index_name, .. } = &job.definition {
2939 for tablet in self.provider.tablets_of(job.table_id) {
2940 self.provider.sink(tablet)?.drop_generation(index_name)?;
2941 }
2942 self.apply(DdlCommand::RemoveIndexRecord { job_id: job.job_id })?;
2943 self.sync_maintainers(job.table_id)?;
2944 }
2945 self.set_state(job.job_id, SchemaJobState::Failed, Some(reason.to_owned()))
2946 }
2947
2948 fn apply(&self, command: DdlCommand) -> Result<(), DdlError> {
2951 let commit_ts = self.clock.now()?;
2952 self.store
2953 .lock()
2954 .expect("store lock poisoned")
2955 .apply(&command, None, commit_ts)
2956 .map_err(DdlError::from)
2957 }
2958
2959 fn job_record(&self, job_id: u64) -> Result<DdlJobRecord, DdlError> {
2960 self.store
2961 .lock()
2962 .expect("store lock poisoned")
2963 .job(job_id)
2964 .cloned()
2965 .ok_or_else(|| {
2966 DdlRejection::Meta(MetaRejectionReason::NotFound {
2967 resource: format!("DDL job {job_id}"),
2968 })
2969 .into()
2970 })
2971 }
2972
2973 fn index_record(
2974 &self,
2975 job: &DdlJobRecord,
2976 index_name: &str,
2977 ) -> Result<DdlIndexRecord, DdlError> {
2978 self.store
2979 .lock()
2980 .expect("store lock poisoned")
2981 .index(job.table_id, index_name)
2982 .cloned()
2983 .ok_or_else(|| {
2984 DdlRejection::Meta(MetaRejectionReason::NotFound {
2985 resource: format!("index `{index_name}` on table {}", job.table_id),
2986 })
2987 .into()
2988 })
2989 }
2990
2991 fn peek_next_job_id(&self) -> u64 {
2992 self.store.lock().expect("store lock poisoned").next_job_id
2993 }
2994
2995 fn set_state(
2996 &self,
2997 job_id: u64,
2998 state: SchemaJobState,
2999 error: Option<String>,
3000 ) -> Result<(), DdlError> {
3001 let updated_at = self.clock.now()?;
3002 self.apply(DdlCommand::SetJobState {
3003 job_id,
3004 state,
3005 updated_at,
3006 error,
3007 expected_version: None,
3008 })
3009 }
3010
3011 fn advance_phase(
3012 &self,
3013 job_id: u64,
3014 to: DdlPhase,
3015 pinned_snapshot: Option<HlcTimestamp>,
3016 ) -> Result<(), DdlError> {
3017 self.apply(DdlCommand::AdvancePhase {
3018 job_id,
3019 to,
3020 pinned_snapshot,
3021 expected_version: None,
3022 })
3023 }
3024
3025 fn update_progress(&self, job_id: u64, progress: TabletDdlProgress) -> Result<(), DdlError> {
3026 self.apply(DdlCommand::UpdateTabletProgress { job_id, progress })
3027 }
3028
3029 fn sync_maintainers(&self, table_id: TableId) -> Result<(), DdlError> {
3033 let names: Vec<String> = self
3034 .store
3035 .lock()
3036 .expect("store lock poisoned")
3037 .write_maintained(table_id)
3038 .iter()
3039 .map(|record| record.index_name.clone())
3040 .collect();
3041 for tablet in self.provider.tablets_of(table_id) {
3042 self.provider
3043 .maintainer(tablet)?
3044 .sync_definitions(names.clone())?;
3045 }
3046 Ok(())
3047 }
3048}
3049
3050#[cfg(test)]
3055mod tests {
3056 use super::*;
3057
3058 const TABLE: u64 = 1;
3059
3060 fn ts(micros: u64) -> HlcTimestamp {
3061 HlcTimestamp {
3062 physical_micros: micros,
3063 logical: 0,
3064 node_tiebreaker: 0,
3065 }
3066 }
3067
3068 fn after(pin: HlcTimestamp, n: u64) -> HlcTimestamp {
3070 HlcTimestamp {
3071 physical_micros: pin.physical_micros + n,
3072 logical: 0,
3073 node_tiebreaker: 0,
3074 }
3075 }
3076
3077 fn tablet(n: u8) -> TabletId {
3078 TabletId::from_bytes([n; 16])
3079 }
3080
3081 fn database() -> DatabaseId {
3082 DatabaseId::from_bytes([7; 16])
3083 }
3084
3085 fn table() -> TableId {
3086 TableId(TABLE)
3087 }
3088
3089 fn key(n: u64) -> Key {
3090 Key::from_bytes(n.to_be_bytes().to_vec())
3091 }
3092
3093 fn value(n: u64) -> Vec<u8> {
3094 format!("value-{n}").into_bytes()
3095 }
3096
3097 fn spec() -> serde_json::Value {
3098 serde_json::json!({"kind": "Bitmap", "columns": [1]})
3099 }
3100
3101 struct Fixture {
3102 store: Arc<Mutex<DdlJobStore>>,
3103 tablets: InMemoryDdlTablets,
3104 driver: DdlDriver<InMemoryDdlTablets>,
3105 }
3106
3107 impl Fixture {
3108 fn new(tablet_count: u8, rows_per_tablet: u64) -> Self {
3111 let store = Arc::new(Mutex::new(DdlJobStore::default()));
3112 let tablets = InMemoryDdlTablets::new();
3113 for n in 1..=tablet_count {
3114 tablets.add_tablet(tablet(n), table());
3115 let keyspace = tablets.keyspace_handle(tablet(n));
3116 for row in 1..=rows_per_tablet {
3117 keyspace.insert(key(row), ts(row), value(row));
3118 }
3119 }
3120 let driver = DdlDriver::new(store.clone(), tablets.clone());
3121 driver
3122 .apply(DdlCommand::RegisterTable {
3123 anchor: TableAnchor {
3124 table_id: table(),
3125 database_id: database(),
3126 schema_version: SchemaVersion(1),
3127 schema: serde_json::json!({"columns": ["id", "v"]}),
3128 metadata_version: MetadataVersion::ZERO,
3129 },
3130 })
3131 .expect("register table");
3132 Self {
3133 store,
3134 tablets,
3135 driver,
3136 }
3137 }
3138
3139 fn store(&self) -> std::sync::MutexGuard<'_, DdlJobStore> {
3140 self.store.lock().expect("store lock poisoned")
3141 }
3142
3143 fn submit_add(&self, index_name: &str) -> Result<u64, DdlError> {
3144 self.driver
3145 .submit_add_index(database(), table(), index_name, spec(), SchemaVersion(1))
3146 }
3147
3148 fn pin_of(&self, job_id: u64) -> HlcTimestamp {
3149 self.store()
3150 .job(job_id)
3151 .and_then(|job| job.pinned_snapshot)
3152 .expect("job is pinned")
3153 }
3154
3155 fn drive_until(
3158 &self,
3159 job_id: u64,
3160 mut stop: impl FnMut(DriveStep) -> bool,
3161 ) -> Vec<DriveStep> {
3162 let mut steps = Vec::new();
3163 for _ in 0..64 {
3164 let step = self.driver.step_job(job_id).expect("step");
3165 steps.push(step);
3166 if stop(step) || matches!(step, DriveStep::Terminal | DriveStep::Parked) {
3167 return steps;
3168 }
3169 }
3170 panic!("job {job_id} did not reach the expected step");
3171 }
3172
3173 fn drive_to_backfilled(&self, job_id: u64, count: usize) {
3174 let mut backfilled = 0_usize;
3175 self.drive_until(job_id, |step| {
3176 if matches!(step, DriveStep::TabletBackfilled { .. }) {
3177 backfilled += 1;
3178 }
3179 backfilled == count
3180 });
3181 }
3182 }
3183
3184 #[test]
3185 fn add_index_full_lifecycle_over_three_tablets() {
3186 let fixture = Fixture::new(3, 5);
3187 let job_id = fixture.submit_add("idx_a").expect("submit");
3188
3189 let steps = fixture.drive_until(job_id, |step| {
3192 matches!(
3193 step,
3194 DriveStep::PhaseAdvanced {
3195 from: DdlPhase::Pending,
3196 to: DdlPhase::WriteOnly,
3197 }
3198 )
3199 });
3200 assert_eq!(steps.first(), Some(&DriveStep::Admitted));
3201 assert_eq!(fixture.store().write_maintained(table()).len(), 1);
3202 assert!(fixture.store().planner_visible(table()).is_empty());
3203 for n in 1..=3 {
3204 assert_eq!(
3205 fixture.tablets.indexes_handle(tablet(n)).maintained(),
3206 ["idx_a"]
3207 );
3208 }
3209
3210 fixture.drive_until(job_id, |step| {
3212 matches!(
3213 step,
3214 DriveStep::PhaseAdvanced {
3215 from: DdlPhase::WriteOnly,
3216 to: DdlPhase::Backfilling,
3217 }
3218 )
3219 });
3220 let pin = fixture.pin_of(job_id);
3221
3222 fixture
3227 .tablets
3228 .commit_write(tablet(1), key(100), after(pin, 1), Some(value(100)))
3229 .expect("write 100");
3230 fixture.drive_to_backfilled(job_id, 1);
3231 assert!(fixture
3232 .tablets
3233 .indexes_handle(tablet(1))
3234 .is_installed("idx_a"));
3235 fixture
3236 .tablets
3237 .commit_write(tablet(1), key(102), after(pin, 3), Some(value(102)))
3238 .expect("write 102");
3239 assert_eq!(
3241 fixture
3242 .tablets
3243 .indexes_handle(tablet(1))
3244 .entries("idx_a")
3245 .len(),
3246 7,
3247 );
3248 fixture
3249 .tablets
3250 .commit_write(tablet(2), key(101), after(pin, 2), Some(value(101)))
3251 .expect("write 101");
3252 fixture
3253 .tablets
3254 .commit_write(tablet(1), key(1), after(pin, 4), None)
3255 .expect("delete 1");
3256 fixture.drive_to_backfilled(job_id, 2);
3257
3258 let steps = fixture.drive_until(job_id, |step| matches!(step, DriveStep::Published { .. }));
3260 let publication_version = steps.iter().find_map(|step| match step {
3261 DriveStep::Published { version } => Some(*version),
3262 _ => None,
3263 });
3264 let publication_version = publication_version.expect("published");
3265 let store = fixture.store();
3266 let job = store.job(job_id).expect("job");
3267 assert_eq!(job.state, SchemaJobState::Succeeded);
3268 assert_eq!(job.phase, DdlPhase::Public);
3269 let record = store.index(table(), "idx_a").expect("index record");
3270 assert_eq!(record.phase, DdlPhase::Public);
3271 assert_eq!(record.publication_version, Some(publication_version));
3272 assert!(store
3274 .planner_visible_at(table(), MetadataVersion(publication_version.get() - 1))
3275 .is_empty());
3276 assert_eq!(
3277 store.planner_visible_at(table(), publication_version).len(),
3278 1
3279 );
3280 drop(store);
3281
3282 let report = fixture
3285 .driver
3286 .validation_report(job_id)
3287 .expect("validation report");
3288 assert!(report.passed);
3289 assert_eq!(report.tablets.len(), 3);
3290 assert_eq!(report.total_expected, report.total_actual);
3291 let per_tablet: BTreeMap<TabletId, &TabletValidationReport> = report
3292 .tablets
3293 .iter()
3294 .map(|tablet_report| (tablet_report.tablet_id, tablet_report))
3295 .collect();
3296 assert_eq!(per_tablet[&tablet(1)].expected_rows, 6); assert_eq!(per_tablet[&tablet(2)].expected_rows, 6);
3298 assert_eq!(per_tablet[&tablet(3)].expected_rows, 5);
3299
3300 let watermark = per_tablet[&tablet(1)].watermark;
3303 let expected = fixture
3304 .tablets
3305 .keyspace_handle(tablet(1))
3306 .rows_at(watermark);
3307 let actual = fixture.tablets.indexes_handle(tablet(1)).entries("idx_a");
3308 assert_eq!(actual, expected);
3309
3310 let status = fixture.driver.job_status(job_id).expect("status");
3312 assert_eq!(status.tablets_registered, 3);
3313 assert_eq!(status.tablets_caught_up, 3);
3314 assert_eq!(status.tablets_validated, 3);
3315 assert_eq!(status.rows_backfilled, 15);
3316 }
3317
3318 #[test]
3319 fn catch_up_covers_pre_install_writes_and_tombstones() {
3320 let fixture = Fixture::new(1, 3);
3321 let job_id = fixture.submit_add("idx_t").expect("submit");
3322 fixture.drive_until(job_id, |step| {
3323 matches!(
3324 step,
3325 DriveStep::PhaseAdvanced {
3326 from: DdlPhase::WriteOnly,
3327 to: DdlPhase::Backfilling,
3328 }
3329 )
3330 });
3331 let pin = fixture.pin_of(job_id);
3332 fixture
3336 .tablets
3337 .commit_write(tablet(1), key(50), after(pin, 1), Some(value(50)))
3338 .expect("write 50");
3339 fixture
3340 .tablets
3341 .commit_write(tablet(1), key(50), after(pin, 2), None)
3342 .expect("delete 50");
3343 fixture
3344 .tablets
3345 .commit_write(tablet(1), key(2), after(pin, 3), None)
3346 .expect("delete 2");
3347 assert_eq!(
3348 fixture.driver.drive_job(job_id).expect("drive"),
3349 DriveOutcome::Completed
3350 );
3351
3352 let report = fixture.driver.validation_report(job_id).expect("report");
3353 assert!(report.passed);
3354 assert_eq!(report.total_expected, 2); let actual = fixture.tablets.indexes_handle(tablet(1)).entries("idx_t");
3356 assert_eq!(actual.len(), 2);
3357 assert!(!actual.contains_key(&key(2)));
3358 assert!(!actual.contains_key(&key(50)));
3359 }
3360
3361 #[test]
3362 fn atomic_publish_flips_planner_visibility_at_one_metadata_version() {
3363 let fixture = Fixture::new(1, 2);
3364 let job_id = fixture.submit_add("idx_v").expect("submit");
3365 let before = fixture.store().metadata_version;
3366 assert_eq!(
3367 fixture.driver.drive_job(job_id).expect("drive"),
3368 DriveOutcome::Completed
3369 );
3370 let store = fixture.store();
3371 let publication = store
3372 .index(table(), "idx_v")
3373 .and_then(|record| record.publication_version)
3374 .expect("publication version");
3375 assert!(publication > before);
3376 assert!(
3377 store
3378 .planner_visible_at(table(), MetadataVersion(publication.get() - 1))
3379 .is_empty(),
3380 "hidden before the publication version"
3381 );
3382 let visible = store.planner_visible_at(table(), publication);
3383 assert_eq!(visible.len(), 1);
3384 assert_eq!(visible[0].index_name, "idx_v");
3385 }
3386
3387 #[test]
3388 fn stale_schema_returns_structured_retry() {
3389 let fixture = Fixture::new(1, 2);
3390 let job_id = fixture.submit_add("idx_s").expect("submit");
3391 fixture.drive_until(job_id, |step| {
3394 matches!(
3395 step,
3396 DriveStep::PhaseAdvanced {
3397 from: DdlPhase::Backfilling,
3398 to: DdlPhase::Validating,
3399 }
3400 )
3401 });
3402 fixture
3403 .driver
3404 .apply(DdlCommand::RegisterTable {
3405 anchor: TableAnchor {
3406 table_id: table(),
3407 database_id: database(),
3408 schema_version: SchemaVersion(2),
3409 schema: serde_json::json!({"columns": ["id", "v", "w"]}),
3410 metadata_version: MetadataVersion::ZERO,
3411 },
3412 })
3413 .expect("schema bump");
3414 let error = fixture
3415 .driver
3416 .drive_job(job_id)
3417 .expect_err("publish must refuse the stale schema");
3418 assert_eq!(error.category(), ErrorCategory::SchemaVersionMismatch);
3419 match &error {
3420 DdlError::Rejection(DdlRejection::SchemaVersionMismatch {
3421 table_id,
3422 expected,
3423 found,
3424 }) => {
3425 assert_eq!(*table_id, table());
3426 assert_eq!(*expected, SchemaVersion(1));
3427 assert_eq!(*found, SchemaVersion(2));
3428 }
3429 other => panic!("expected SchemaVersionMismatch, got {other:?}"),
3430 }
3431 let store = fixture.store();
3433 let job = store.job(job_id).expect("job");
3434 assert_eq!(job.state, SchemaJobState::Failed);
3435 assert!(store.index(table(), "idx_s").is_none());
3436 drop(store);
3437 assert!(!fixture
3438 .tablets
3439 .indexes_handle(tablet(1))
3440 .is_installed("idx_s"));
3441
3442 let stale = fixture.driver.submit_add_index(
3445 database(),
3446 table(),
3447 "idx_s2",
3448 spec(),
3449 SchemaVersion(1),
3450 );
3451 assert!(matches!(
3452 stale,
3453 Err(DdlError::Rejection(
3454 DdlRejection::SchemaVersionMismatch { .. }
3455 ))
3456 ));
3457 let job_id = fixture
3458 .driver
3459 .submit_add_index(database(), table(), "idx_s2", spec(), SchemaVersion(2))
3460 .expect("resubmit at the current schema version");
3461 assert_eq!(
3462 fixture.driver.drive_job(job_id).expect("drive"),
3463 DriveOutcome::Completed
3464 );
3465 }
3466
3467 #[test]
3468 fn pause_resume_mid_backfill_resumes_exactly() {
3469 let fixture = Fixture::new(3, 4);
3470 let job_id = fixture.submit_add("idx_p").expect("submit");
3471 fixture.drive_to_backfilled(job_id, 1);
3472 fixture.driver.pause_job(job_id).expect("pause");
3473 assert!(
3474 fixture.driver.pause_job(job_id).is_err(),
3475 "double pause is refused"
3476 );
3477 assert_eq!(
3478 fixture.driver.drive_job(job_id).expect("drive"),
3479 DriveOutcome::Parked
3480 );
3481 assert_eq!(
3482 fixture
3483 .tablets
3484 .indexes_handle(tablet(1))
3485 .begin_build_count(),
3486 1
3487 );
3488 assert_eq!(
3489 fixture
3490 .tablets
3491 .indexes_handle(tablet(2))
3492 .begin_build_count(),
3493 0
3494 );
3495
3496 fixture.driver.resume_job(job_id).expect("resume");
3497 assert_eq!(
3498 fixture.driver.drive_job(job_id).expect("drive"),
3499 DriveOutcome::Completed
3500 );
3501 assert_eq!(
3503 fixture
3504 .tablets
3505 .indexes_handle(tablet(1))
3506 .begin_build_count(),
3507 1
3508 );
3509 assert_eq!(
3510 fixture
3511 .tablets
3512 .indexes_handle(tablet(2))
3513 .begin_build_count(),
3514 1
3515 );
3516 assert_eq!(
3517 fixture
3518 .tablets
3519 .indexes_handle(tablet(3))
3520 .begin_build_count(),
3521 1
3522 );
3523 assert!(
3524 fixture
3525 .driver
3526 .validation_report(job_id)
3527 .expect("report")
3528 .passed
3529 );
3530 assert!(
3531 fixture.driver.resume_job(job_id).is_err(),
3532 "resume of a terminal job is refused"
3533 );
3534 }
3535
3536 #[test]
3537 fn cancel_unwinds_the_hidden_generation_and_leaves_the_table_unaffected() {
3538 let fixture = Fixture::new(3, 4);
3539 let job_id = fixture.submit_add("idx_c").expect("submit");
3540 fixture.drive_to_backfilled(job_id, 1);
3541 assert!(fixture
3542 .tablets
3543 .indexes_handle(tablet(1))
3544 .is_installed("idx_c"));
3545
3546 fixture.driver.cancel_job(job_id).expect("cancel");
3547 assert_eq!(
3548 fixture.driver.drive_job(job_id).expect("drive"),
3549 DriveOutcome::RolledBack
3550 );
3551 let store = fixture.store();
3552 let job = store.job(job_id).expect("job");
3553 assert_eq!(job.state, SchemaJobState::Failed);
3554 assert_eq!(job.error.as_deref(), Some("cancelled by operator"));
3555 assert!(store.index(table(), "idx_c").is_none());
3556 assert!(store.planner_visible(table()).is_empty());
3557 assert!(store.write_maintained(table()).is_empty());
3558 assert_eq!(
3560 store.table_anchor(table()).expect("anchor").schema_version,
3561 SchemaVersion(1)
3562 );
3563 drop(store);
3564 for n in 1..=3 {
3565 let indexes = fixture.tablets.indexes_handle(tablet(n));
3566 assert!(!indexes.is_installed("idx_c"));
3567 assert_eq!(indexes.entries("idx_c").len(), 0);
3568 assert_eq!(indexes.maintained(), Vec::<String>::new());
3569 }
3570 assert_eq!(
3571 fixture
3572 .tablets
3573 .keyspace_handle(tablet(1))
3574 .rows_at(ts(u64::MAX))
3575 .len(),
3576 4
3577 );
3578
3579 let second = fixture.submit_add("idx_c2").expect("submit after unwind");
3582 assert_eq!(
3583 fixture.driver.drive_job(second).expect("drive"),
3584 DriveOutcome::Completed
3585 );
3586 assert!(fixture.driver.cancel_job(second).is_err());
3587 }
3588
3589 #[test]
3590 fn drop_index_lifecycle_reclaims_after_readers_drain() {
3591 let fixture = Fixture::new(3, 3);
3592 let add = fixture.submit_add("idx_d").expect("submit add");
3593 assert_eq!(
3594 fixture.driver.drive_job(add).expect("drive"),
3595 DriveOutcome::Completed
3596 );
3597 let pin = fixture.pin_of(add);
3598 fixture
3600 .tablets
3601 .commit_write(tablet(1), key(40), after(pin, 1), Some(value(40)))
3602 .expect("maintained write");
3603 assert_eq!(
3604 fixture
3605 .tablets
3606 .indexes_handle(tablet(1))
3607 .entries("idx_d")
3608 .len(),
3609 4
3610 );
3611 let pre_drop_version = fixture.store().metadata_version;
3612
3613 let drop = fixture
3614 .driver
3615 .submit_drop_index(database(), table(), "idx_d", SchemaVersion(1))
3616 .expect("submit drop");
3617 assert_eq!(
3618 fixture.driver.drive_job(drop).expect("drive"),
3619 DriveOutcome::Completed
3620 );
3621 let dropping_since = {
3622 let store = fixture.store();
3623 let record = store.index(table(), "idx_d").expect("record");
3624 assert_eq!(record.phase, DdlPhase::Dropping);
3625 assert_eq!(
3626 store.job(drop).expect("job").state,
3627 SchemaJobState::Succeeded
3628 );
3629 assert!(store.planner_visible(table()).is_empty());
3631 assert_eq!(store.planner_visible_at(table(), pre_drop_version).len(), 1);
3632 record.dropping_since.expect("dropping version")
3633 };
3634 assert!(dropping_since > pre_drop_version);
3635 assert_eq!(
3637 fixture.tablets.indexes_handle(tablet(1)).maintained(),
3638 Vec::<String>::new()
3639 );
3640 fixture
3641 .tablets
3642 .commit_write(tablet(1), key(41), after(pin, 2), Some(value(41)))
3643 .expect("write after drop");
3644 assert_eq!(
3645 fixture
3646 .tablets
3647 .indexes_handle(tablet(1))
3648 .entries("idx_d")
3649 .len(),
3650 4
3651 );
3652
3653 fixture
3655 .driver
3656 .pin_reader(1, pre_drop_version)
3657 .expect("pin reader");
3658 let blocked = fixture
3659 .driver
3660 .reclaim_index(table(), "idx_d")
3661 .expect("reclaim attempt");
3662 assert_eq!(
3663 blocked,
3664 ReclaimOutcome::Blocked {
3665 oldest_reader: Some(pre_drop_version),
3666 required: dropping_since,
3667 }
3668 );
3669 let forward_version = fixture.store().metadata_version;
3670 fixture
3671 .driver
3672 .pin_reader(1, forward_version)
3673 .expect("reader moves forward");
3674 assert_eq!(
3675 fixture
3676 .driver
3677 .reclaim_index(table(), "idx_d")
3678 .expect("reclaim"),
3679 ReclaimOutcome::Reclaimed
3680 );
3681 assert!(fixture.store().index(table(), "idx_d").is_none());
3682 for n in 1..=3 {
3683 assert!(!fixture
3684 .tablets
3685 .indexes_handle(tablet(n))
3686 .is_installed("idx_d"));
3687 }
3688 fixture
3690 .driver
3691 .pin_reader(2, dropping_since)
3692 .expect("pin reader 2");
3693 assert!(fixture.driver.pin_reader(2, pre_drop_version).is_err());
3694 }
3695
3696 #[test]
3697 fn per_tablet_progress_survives_a_driver_crash() {
3698 let fixture = Fixture::new(3, 4);
3699 let job_id = fixture.submit_add("idx_x").expect("submit");
3700 fixture.drive_to_backfilled(job_id, 1);
3701 let pin = fixture.pin_of(job_id);
3702 let crashed = fixture.driver;
3703 fixture.tablets.indexes_handle(tablet(2)).seed_staged(
3706 "idx_x",
3707 key(999),
3708 b"garbage".to_vec(),
3709 );
3710 fixture
3712 .tablets
3713 .commit_write(tablet(2), key(60), after(pin, 1), Some(value(60)))
3714 .expect("write during outage");
3715 drop(crashed);
3716
3717 let resumed = DdlDriver::new(fixture.store.clone(), fixture.tablets.clone());
3718 assert_eq!(
3719 resumed.drive_job(job_id).expect("drive"),
3720 DriveOutcome::Completed
3721 );
3722 assert_eq!(
3725 fixture
3726 .tablets
3727 .indexes_handle(tablet(1))
3728 .begin_build_count(),
3729 1
3730 );
3731 assert_eq!(
3732 fixture
3733 .tablets
3734 .indexes_handle(tablet(2))
3735 .begin_build_count(),
3736 1
3737 );
3738 assert!(!fixture
3739 .tablets
3740 .indexes_handle(tablet(2))
3741 .entries("idx_x")
3742 .contains_key(&key(999)));
3743 assert_eq!(
3744 fixture
3745 .tablets
3746 .indexes_handle(tablet(2))
3747 .entries("idx_x")
3748 .len(),
3749 5
3750 );
3751 assert!(resumed.validation_report(job_id).expect("report").passed);
3752 }
3753
3754 #[test]
3755 fn phase_graph_is_enforced() {
3756 let edges: [(DdlPhase, DdlPhase); 5] = [
3757 (DdlPhase::Pending, DdlPhase::WriteOnly),
3758 (DdlPhase::WriteOnly, DdlPhase::Backfilling),
3759 (DdlPhase::Backfilling, DdlPhase::Validating),
3760 (DdlPhase::Validating, DdlPhase::Public),
3761 (DdlPhase::Public, DdlPhase::Dropping),
3762 ];
3763 for from in DdlPhase::ALL {
3764 for to in DdlPhase::ALL {
3765 assert_eq!(
3766 from.can_transition(to),
3767 edges.contains(&(from, to)),
3768 "{from} -> {to}",
3769 );
3770 }
3771 }
3772
3773 let fixture = Fixture::new(1, 1);
3775 let job_id = fixture.submit_add("idx_g").expect("submit");
3776 fixture.drive_until(job_id, |step| matches!(step, DriveStep::Admitted));
3777 let skipped = fixture
3778 .driver
3779 .advance_phase(job_id, DdlPhase::Validating, None);
3780 assert!(matches!(
3781 skipped,
3782 Err(DdlError::Rejection(DdlRejection::IllegalPhaseTransition {
3783 from: DdlPhase::Pending,
3784 to: DdlPhase::Validating,
3785 ..
3786 }))
3787 ));
3788 fixture.drive_to_backfilled(job_id, 1);
3789 fixture.drive_until(job_id, |step| {
3790 matches!(
3791 step,
3792 DriveStep::PhaseAdvanced {
3793 from: DdlPhase::Backfilling,
3794 to: DdlPhase::Validating,
3795 }
3796 )
3797 });
3798 let bypass = fixture.driver.advance_phase(job_id, DdlPhase::Public, None);
3799 assert!(
3800 matches!(
3801 bypass,
3802 Err(DdlError::Rejection(DdlRejection::Meta(
3803 MetaRejectionReason::Invalid { .. }
3804 )))
3805 ),
3806 "Public rides PublishJob: {bypass:?}",
3807 );
3808 }
3809
3810 #[test]
3811 fn admin_state_graph_is_enforced() {
3812 let fixture = Fixture::new(1, 1);
3813 let job_id = fixture.submit_add("idx_a").expect("submit");
3814 assert!(
3815 fixture.driver.pause_job(job_id).is_err(),
3816 "Pending cannot pause"
3817 );
3818 assert!(
3819 fixture.driver.resume_job(job_id).is_err(),
3820 "Pending cannot resume"
3821 );
3822 fixture
3823 .driver
3824 .cancel_job(job_id)
3825 .expect("cancel from Pending");
3826 assert_eq!(
3827 fixture.driver.drive_job(job_id).expect("drive"),
3828 DriveOutcome::RolledBack
3829 );
3830 let job = fixture.store().job(job_id).expect("job").clone();
3831 assert_eq!(job.state, SchemaJobState::Failed);
3832 assert!(
3833 fixture.driver.cancel_job(job_id).is_err(),
3834 "terminal jobs refuse cancel"
3835 );
3836 }
3837
3838 #[test]
3839 fn alter_schema_publishes_the_next_schema_version_atomically() {
3840 let fixture = Fixture::new(2, 3);
3841 let target = serde_json::json!({"columns": ["id", "v", "w"]});
3842 let job_id = fixture
3843 .driver
3844 .submit_alter_schema(database(), table(), target.clone(), SchemaVersion(1))
3845 .expect("submit alter");
3846 assert_eq!(
3847 fixture.driver.drive_job(job_id).expect("drive"),
3848 DriveOutcome::Completed
3849 );
3850 let store = fixture.store();
3851 let anchor = store.table_anchor(table()).expect("anchor");
3852 assert_eq!(anchor.schema_version, SchemaVersion(2));
3853 assert_eq!(anchor.schema, target);
3854 let job = store.job(job_id).expect("job");
3855 assert_eq!(job.state, SchemaJobState::Succeeded);
3856 assert_eq!(job.phase, DdlPhase::Public);
3857 assert_eq!(job.tablet_progress.len(), 2);
3858 drop(store);
3859
3860 let stale = fixture.submit_add("idx_old");
3862 assert!(matches!(
3863 stale,
3864 Err(DdlError::Rejection(
3865 DdlRejection::SchemaVersionMismatch { .. }
3866 ))
3867 ));
3868 let fresh = fixture
3869 .driver
3870 .submit_add_index(database(), table(), "idx_new", spec(), SchemaVersion(2))
3871 .expect("submit at v2");
3872 assert_eq!(
3873 fixture.driver.drive_job(fresh).expect("drive"),
3874 DriveOutcome::Completed
3875 );
3876 }
3877
3878 #[test]
3879 fn topology_change_mid_backfill_is_reconciled() {
3880 let fixture = Fixture::new(3, 3);
3881 let job_id = fixture.submit_add("idx_t").expect("submit");
3882 fixture.drive_to_backfilled(job_id, 1);
3883 fixture.tablets.add_tablet(tablet(4), table());
3885 let keyspace = fixture.tablets.keyspace_handle(tablet(4));
3886 for row in 1..=3 {
3887 keyspace.insert(key(row + 100), ts(row), value(row + 100));
3888 }
3889 fixture.tablets.remove_tablet(tablet(3));
3890 assert_eq!(
3891 fixture.driver.drive_job(job_id).expect("drive"),
3892 DriveOutcome::Completed
3893 );
3894 let status = fixture.driver.job_status(job_id).expect("status");
3895 assert_eq!(status.tablets_registered, 3);
3896 assert!(status.record.tablet_progress.contains_key(&tablet(4)));
3897 assert!(!status.record.tablet_progress.contains_key(&tablet(3)));
3898 assert!(
3899 fixture
3900 .driver
3901 .validation_report(job_id)
3902 .expect("report")
3903 .passed
3904 );
3905 }
3906
3907 #[test]
3908 fn conflicting_and_duplicate_submissions_are_refused() {
3909 let fixture = Fixture::new(1, 2);
3910 let job_id = fixture.submit_add("idx_1").expect("submit");
3911 let concurrent = fixture.submit_add("idx_2");
3913 assert!(matches!(
3914 concurrent,
3915 Err(DdlError::Rejection(DdlRejection::Meta(
3916 MetaRejectionReason::Conflict { .. }
3917 )))
3918 ));
3919 assert_eq!(
3920 fixture.driver.drive_job(job_id).expect("drive"),
3921 DriveOutcome::Completed
3922 );
3923 let duplicate = fixture.submit_add("idx_1");
3925 assert!(matches!(
3926 duplicate,
3927 Err(DdlError::Rejection(DdlRejection::Meta(
3928 MetaRejectionReason::Conflict { .. }
3929 )))
3930 ));
3931 let missing =
3933 fixture
3934 .driver
3935 .submit_drop_index(database(), table(), "idx_missing", SchemaVersion(1));
3936 assert!(matches!(
3937 missing,
3938 Err(DdlError::Rejection(DdlRejection::Meta(
3939 MetaRejectionReason::NotFound { .. }
3940 )))
3941 ));
3942 let job = fixture.store().job(job_id).expect("job").clone();
3944 fixture
3945 .driver
3946 .apply(DdlCommand::SubmitJob { job })
3947 .expect("identical replay is a no-op");
3948 assert_eq!(fixture.store().jobs.len(), 1);
3949 }
3950
3951 #[test]
3952 fn store_roundtrips_through_json() {
3953 let fixture = Fixture::new(2, 3);
3954 let job_id = fixture.submit_add("idx_j").expect("submit");
3955 fixture.drive_to_backfilled(job_id, 1);
3956 let snapshot = {
3957 let store = fixture.store();
3958 serde_json::to_string(&*store).expect("encode")
3959 };
3960 let decoded: DdlJobStore = serde_json::from_str(&snapshot).expect("decode");
3961 assert_eq!(decoded, *fixture.store());
3962 }
3963
3964 #[test]
3965 fn command_roundtrips_through_json() {
3966 let job = DdlJobRecord {
3967 job_id: 9,
3968 database_id: database(),
3969 table_id: table(),
3970 kind: DdlJobKind::AddIndex,
3971 state: SchemaJobState::Pending,
3972 phase: DdlPhase::Pending,
3973 definition: DdlDefinition::AddIndex {
3974 index_name: "idx_serde".to_owned(),
3975 spec: spec(),
3976 },
3977 source_schema_version: SchemaVersion(1),
3978 created_at: ts(10),
3979 updated_at: ts(10),
3980 pinned_snapshot: None,
3981 tablet_progress: BTreeMap::new(),
3982 error: None,
3983 metadata_version: MetadataVersion::ZERO,
3984 };
3985 let command = DdlCommand::SubmitJob { job };
3986 let encoded = serde_json::to_string(&command).expect("encode");
3987 let decoded: DdlCommand = serde_json::from_str(&encoded).expect("decode");
3988 assert_eq!(decoded, command);
3989 }
3990}