PyReverseETL 3.1.4__tar.gz → 3.1.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/Cargo.lock +2 -2
  2. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/Cargo.toml +1 -1
  3. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/PKG-INFO +2 -2
  4. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/README.md +1 -1
  5. pyreverseetl-3.1.6/core/src/dlq.rs +161 -0
  6. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/executor.rs +172 -19
  7. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/lib.rs +2 -0
  8. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/pyproject.toml +1 -1
  9. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/python/pyreverseetl/cli.py +29 -1
  10. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/python/src/lib.rs +40 -4
  11. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/LICENSE +0 -0
  12. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/Cargo.toml +0 -0
  13. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/benches/load_test.rs +0 -0
  14. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/bin/pyreverseetl_dashboard.rs +0 -0
  15. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/activation.rs +0 -0
  16. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/adapters/alert_compat.rs +0 -0
  17. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/adapters/error.rs +0 -0
  18. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/adapters/http_client.rs +0 -0
  19. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/adapters/hubspot.rs +0 -0
  20. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/adapters/mapping.rs +0 -0
  21. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/adapters/marketo.rs +0 -0
  22. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/adapters/mod.rs +0 -0
  23. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/adapters/oauth_manager.rs +0 -0
  24. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/adapters/retry_policy.rs +0 -0
  25. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/adapters/salesforce.rs +0 -0
  26. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/adapters/schema_detect.rs +0 -0
  27. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/adapters/webhook.rs +0 -0
  28. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/cdc/change_detector.rs +0 -0
  29. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/cdc/changelog.rs +0 -0
  30. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/cdc/checkpoint.rs +0 -0
  31. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/cdc/mod.rs +0 -0
  32. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/config.rs +0 -0
  33. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/connectors_db.rs +0 -0
  34. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/database_advanced.rs +0 -0
  35. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/destination.rs +0 -0
  36. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/error.rs +0 -0
  37. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/hdfs.rs +0 -0
  38. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/mod.rs +0 -0
  39. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/mysql.rs +0 -0
  40. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/object_storage.rs +0 -0
  41. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/postgres.rs +0 -0
  42. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/rate_limiting.rs +0 -0
  43. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/registry.rs +0 -0
  44. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/saas.rs +0 -0
  45. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/source.rs +0 -0
  46. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/web.rs +0 -0
  47. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/destination.rs +0 -0
  48. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/entity.rs +0 -0
  49. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/error.rs +0 -0
  50. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/governance/cached_gate.rs +0 -0
  51. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/governance/compliance_rules.rs +0 -0
  52. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/governance/credentials.rs +0 -0
  53. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/governance/mod.rs +0 -0
  54. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/governance/quality_gate.rs +0 -0
  55. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/governance/retry_policy.rs +0 -0
  56. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/governance/schema_evolution.rs +0 -0
  57. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/governance/statguardian_client.rs +0 -0
  58. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/idempotency.rs +0 -0
  59. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/lineage.rs +0 -0
  60. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/observability/dashboard_launcher.rs +0 -0
  61. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/observability/logs.rs +0 -0
  62. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/observability/metrics.rs +0 -0
  63. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/observability/metrics_server.rs +0 -0
  64. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/observability/mod.rs +0 -0
  65. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/observability/traces.rs +0 -0
  66. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/pipeline/activation_pipeline.rs +0 -0
  67. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/pipeline/backpressure.rs +0 -0
  68. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/pipeline/latency_tracker.rs +0 -0
  69. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/pipeline/mod.rs +0 -0
  70. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/sources/kafka.rs +0 -0
  71. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/sources/mod.rs +0 -0
  72. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/sources/polling.rs +0 -0
  73. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/sources/sync_config.rs +0 -0
  74. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/statguardian.rs +0 -0
  75. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/storage/mod.rs +0 -0
  76. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/storage/repository.rs +0 -0
  77. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/storage/schema.rs +0 -0
  78. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/streaming/event_processor.rs +0 -0
  79. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/streaming/event_schema.rs +0 -0
  80. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/streaming/mod.rs +0 -0
  81. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/streampdf.rs +0 -0
  82. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/streamxl.rs +0 -0
  83. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/sync.rs +0 -0
  84. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/testing/connector_test.rs +0 -0
  85. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/testing/harness.rs +0 -0
  86. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/testing/metrics.rs +0 -0
  87. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/testing/mock_http.rs +0 -0
  88. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/testing/mod.rs +0 -0
  89. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/testing/test_data.rs +0 -0
  90. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/transformers/mod.rs +0 -0
  91. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/transformers/spark.rs +0 -0
  92. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/workflow.rs +0 -0
  93. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/python/Cargo.toml +0 -0
  94. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/python/pyreverseetl/__init__.py +0 -0
  95. {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/python/pyreverseetl/server.py +0 -0
@@ -3199,7 +3199,7 @@ dependencies = [
3199
3199
 
3200
3200
  [[package]]
3201
3201
  name = "pyreverseetl"
3202
- version = "3.1.4"
3202
+ version = "3.1.6"
3203
3203
  dependencies = [
3204
3204
  "chrono",
3205
3205
  "pyo3",
@@ -3210,7 +3210,7 @@ dependencies = [
3210
3210
 
3211
3211
  [[package]]
3212
3212
  name = "pyreverseetl-core"
3213
- version = "3.1.4"
3213
+ version = "3.1.6"
3214
3214
  dependencies = [
3215
3215
  "async-trait",
3216
3216
  "aws-config",
@@ -3,7 +3,7 @@ members = ["core", "python"]
3
3
  resolver = "2"
4
4
 
5
5
  [workspace.package]
6
- version = "3.1.4"
6
+ version = "3.1.6"
7
7
  authors = ["Georgi Mammen Mullassery <mullassery@gmail.com>"]
8
8
  license = "Apache-2.0"
9
9
  repository = "https://github.com/Mullassery/PyReverseETL"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyReverseETL
3
- Version: 3.1.4
3
+ Version: 3.1.6
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Programming Language :: Python :: 3
@@ -103,7 +103,7 @@ repeated here to avoid implying vendor endorsement.
103
103
 
104
104
  ## Project status
105
105
 
106
- **Beta.** Version 3.1.4. The Postgres/MySQL/S3/webhook paths are real and verified against
106
+ **Beta.** Version 3.1.6. The Postgres/MySQL/S3/webhook paths are real and verified against
107
107
  real services; the `web` source connector (MudFish-backed site crawling) is implemented
108
108
  and unit-tested but not yet exercised end-to-end against a live site; the CRM/marketing
109
109
  adapters are real but mock-verified only; several
@@ -80,7 +80,7 @@ repeated here to avoid implying vendor endorsement.
80
80
 
81
81
  ## Project status
82
82
 
83
- **Beta.** Version 3.1.4. The Postgres/MySQL/S3/webhook paths are real and verified against
83
+ **Beta.** Version 3.1.6. The Postgres/MySQL/S3/webhook paths are real and verified against
84
84
  real services; the `web` source connector (MudFish-backed site crawling) is implemented
85
85
  and unit-tested but not yet exercised end-to-end against a live site; the CRM/marketing
86
86
  adapters are real but mock-verified only; several
@@ -0,0 +1,161 @@
1
+ //! Local dead-letter queue (quarantine) for records that failed to write.
2
+ //!
3
+ //! Per the verified audit gap (RECHECK-018): a sync that partially fails left
4
+ //! the operator with only an aggregate `rows_failed` count -- there was no
5
+ //! record of *which* records failed, why, or their payload, so re-driving a
6
+ //! failed batch after a transient destination error (a rate limit, a 5xx, a
7
+ //! single malformed record) meant re-reading the whole source and guessing.
8
+ //!
9
+ //! `DeadLetterQueue` is a real, persisted (SQLite) quarantine: every record
10
+ //! that a destination rejected is stored with its run id, destination key,
11
+ //! the reason for rejection, and its exact payload, so a failed record can be
12
+ //! inspected and replayed deliberately instead of re-running the entire sync.
13
+
14
+ use rusqlite::{params, Connection};
15
+ use serde::Serialize;
16
+ use std::path::Path;
17
+ use std::sync::Mutex;
18
+
19
+ /// One quarantined record: what failed, where it was headed, and why.
20
+ #[derive(Debug, Clone, Serialize)]
21
+ pub struct DeadLetter {
22
+ pub run_id: String,
23
+ pub destination_key: String,
24
+ pub record_id: String,
25
+ pub reason: String,
26
+ /// The exact record payload (compact JSON) that was rejected.
27
+ pub payload: String,
28
+ pub failed_at: String,
29
+ }
30
+
31
+ pub struct DeadLetterQueue {
32
+ conn: Mutex<Connection>,
33
+ }
34
+
35
+ impl DeadLetterQueue {
36
+ /// Open (creating if absent) a real SQLite file as the quarantine store.
37
+ pub fn open(path: &Path) -> crate::Result<Self> {
38
+ let conn = Connection::open(path)?;
39
+ Self::from_connection(conn)
40
+ }
41
+
42
+ /// SQLite-backed but in-memory -- exercises the real SQL code path without
43
+ /// touching disk (tests).
44
+ #[cfg(test)]
45
+ pub fn in_memory() -> crate::Result<Self> {
46
+ let conn = Connection::open_in_memory()?;
47
+ Self::from_connection(conn)
48
+ }
49
+
50
+ fn from_connection(conn: Connection) -> crate::Result<Self> {
51
+ conn.execute(
52
+ "CREATE TABLE IF NOT EXISTS dead_letters (
53
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
54
+ run_id TEXT NOT NULL,
55
+ destination_key TEXT NOT NULL,
56
+ record_id TEXT NOT NULL,
57
+ reason TEXT NOT NULL,
58
+ payload TEXT NOT NULL,
59
+ failed_at TEXT NOT NULL
60
+ )",
61
+ [],
62
+ )?;
63
+ conn.execute(
64
+ "CREATE INDEX IF NOT EXISTS idx_dead_letters_run ON dead_letters(run_id)",
65
+ [],
66
+ )?;
67
+ Ok(Self {
68
+ conn: Mutex::new(conn),
69
+ })
70
+ }
71
+
72
+ /// Quarantine one failed record. Appends a new row every time (a record
73
+ /// that fails across several runs leaves a row per run), so the history of
74
+ /// repeated failures is preserved rather than overwritten.
75
+ #[allow(clippy::too_many_arguments)]
76
+ pub fn record(
77
+ &self,
78
+ run_id: &str,
79
+ destination_key: &str,
80
+ record_id: &str,
81
+ reason: &str,
82
+ payload: &str,
83
+ ) -> crate::Result<()> {
84
+ let failed_at = chrono::Utc::now().to_rfc3339();
85
+ let conn = self.conn.lock().unwrap();
86
+ conn.execute(
87
+ "INSERT INTO dead_letters
88
+ (run_id, destination_key, record_id, reason, payload, failed_at)
89
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
90
+ params![run_id, destination_key, record_id, reason, payload, failed_at],
91
+ )?;
92
+ Ok(())
93
+ }
94
+
95
+ /// Every record quarantined by a single run, in insertion order.
96
+ pub fn list_for_run(&self, run_id: &str) -> crate::Result<Vec<DeadLetter>> {
97
+ let conn = self.conn.lock().unwrap();
98
+ let mut stmt = conn.prepare(
99
+ "SELECT run_id, destination_key, record_id, reason, payload, failed_at
100
+ FROM dead_letters WHERE run_id = ?1 ORDER BY id ASC",
101
+ )?;
102
+ let rows = stmt.query_map(params![run_id], |row| {
103
+ Ok(DeadLetter {
104
+ run_id: row.get(0)?,
105
+ destination_key: row.get(1)?,
106
+ record_id: row.get(2)?,
107
+ reason: row.get(3)?,
108
+ payload: row.get(4)?,
109
+ failed_at: row.get(5)?,
110
+ })
111
+ })?;
112
+ let mut out = Vec::new();
113
+ for row in rows {
114
+ out.push(row?);
115
+ }
116
+ Ok(out)
117
+ }
118
+
119
+ /// Total number of quarantined records across every run.
120
+ pub fn count(&self) -> crate::Result<u64> {
121
+ let conn = self.conn.lock().unwrap();
122
+ let count: i64 = conn.query_row("SELECT COUNT(*) FROM dead_letters", [], |row| row.get(0))?;
123
+ Ok(count as u64)
124
+ }
125
+ }
126
+
127
+ #[cfg(test)]
128
+ mod tests {
129
+ use super::*;
130
+
131
+ #[test]
132
+ fn records_and_lists_failures_per_run() {
133
+ let dlq = DeadLetterQueue::in_memory().unwrap();
134
+ dlq.record("run-1", "webhook:x", "rec-1", "HTTP 429", "{\"a\":1}")
135
+ .unwrap();
136
+ dlq.record("run-1", "webhook:x", "rec-2", "HTTP 500", "{\"b\":2}")
137
+ .unwrap();
138
+ dlq.record("run-2", "webhook:x", "rec-3", "boom", "{\"c\":3}")
139
+ .unwrap();
140
+
141
+ let run1 = dlq.list_for_run("run-1").unwrap();
142
+ assert_eq!(run1.len(), 2);
143
+ assert_eq!(run1[0].record_id, "rec-1");
144
+ assert_eq!(run1[0].reason, "HTTP 429");
145
+ assert_eq!(run1[0].payload, "{\"a\":1}");
146
+ assert_eq!(run1[1].record_id, "rec-2");
147
+
148
+ assert_eq!(dlq.list_for_run("run-2").unwrap().len(), 1);
149
+ assert_eq!(dlq.count().unwrap(), 3);
150
+ }
151
+
152
+ #[test]
153
+ fn repeated_failures_are_appended_not_overwritten() {
154
+ let dlq = DeadLetterQueue::in_memory().unwrap();
155
+ dlq.record("run-1", "webhook:x", "rec-1", "HTTP 429", "{}")
156
+ .unwrap();
157
+ dlq.record("run-2", "webhook:x", "rec-1", "HTTP 429", "{}")
158
+ .unwrap();
159
+ assert_eq!(dlq.count().unwrap(), 2);
160
+ }
161
+ }
@@ -17,6 +17,7 @@ use crate::connectors::{
17
17
  ObjectStorageDestination, ObjectStorageSource, PostgreSQLConfig, PostgreSQLConnector,
18
18
  Record as ConnRecord, SourceConnector, WebConfig, WebSource,
19
19
  };
20
+ use crate::dlq::DeadLetterQueue;
20
21
  use crate::entity::{Entity, EntityType};
21
22
  use crate::governance::{
22
23
  ComplianceEngine, ComplianceRule, DefaultComplianceEngine, DefaultSchemaEvolution,
@@ -91,6 +92,14 @@ pub struct ExecuteOptions {
91
92
  /// adapter-based destinations (webhook/Salesforce/HubSpot/Marketo) --
92
93
  /// `None` disables idempotency checking (matches prior behavior).
93
94
  pub idempotency_store_path: Option<PathBuf>,
95
+ /// When set, enables a real dead-letter queue (quarantine): every record
96
+ /// the destination rejected is persisted to a SQLite file at this path
97
+ /// with its run id, reason, and exact payload, so a partial batch failure
98
+ /// can be inspected and re-driven deliberately instead of re-running the
99
+ /// whole sync. Only applies to the adapter-based destinations
100
+ /// (webhook/Salesforce/HubSpot/Marketo), where per-record success/failure
101
+ /// is observable. `None` disables quarantine (matches prior behavior).
102
+ pub dlq_store_path: Option<PathBuf>,
94
103
  }
95
104
 
96
105
  /// The real result of a sync: actual counts, actual timestamps -- never
@@ -119,6 +128,13 @@ pub struct ExecutionResult {
119
128
  /// content marked as synced to this destination. Always 0 when
120
129
  /// idempotency checking is disabled.
121
130
  pub rows_skipped_idempotent: u64,
131
+ /// Records quarantined into the dead-letter queue (see
132
+ /// `ExecuteOptions::dlq_store_path`) because the destination rejected
133
+ /// them. Always 0 when no DLQ path is configured. Equals `rows_failed`
134
+ /// for the adapter-based destinations when a DLQ path is set (every
135
+ /// observed failure is persisted); stays 0 for batch connectors, which
136
+ /// report only an aggregate count and cannot identify individual rows.
137
+ pub rows_dead_lettered: u64,
122
138
  /// Only populated when `ExecuteOptions::schema_store_path` is set: a
123
139
  /// human-readable description of every field-name/type change detected
124
140
  /// against the last-known shape for this source->destination pair.
@@ -206,21 +222,45 @@ async fn execute_with_records(
206
222
  Vec::new()
207
223
  };
208
224
 
209
- let (rows_written, rows_failed, rows_skipped_idempotent, dry_run_preview, destination_node) =
225
+ let (rows_written, rows_failed, rows_skipped_idempotent, rows_dead_lettered, dry_run_preview, destination_node) =
210
226
  if options.dry_run {
211
227
  let preview = compliant_records
212
228
  .iter()
213
229
  .map(|r| r.data.to_string())
214
230
  .collect();
215
- (0, 0, 0, preview, None)
231
+ (0, 0, 0, 0, preview, None)
216
232
  } else {
217
- let (written, failed, skipped, node) = write_to_destination(
233
+ let (written, failed_count, skipped, failed, node) = write_to_destination(
218
234
  &destination,
219
235
  &compliant_records,
220
236
  options.idempotency_store_path.as_deref(),
221
237
  )
222
238
  .await?;
223
- (written, failed, skipped, Vec::new(), Some(node))
239
+
240
+ // Quarantine real per-record failures for deliberate re-drive.
241
+ // Only records whose individual rejection was observed are
242
+ // persisted (the adapter path); batch connectors report an
243
+ // aggregate failure count but not which rows, so they don't
244
+ // contribute here.
245
+ let mut dead_lettered = 0u64;
246
+ if let Some(dlq_path) = &options.dlq_store_path {
247
+ if !failed.is_empty() {
248
+ let dlq = DeadLetterQueue::open(dlq_path)?;
249
+ let destination_key = destination_schema_key(&destination);
250
+ for (record, reason) in &failed {
251
+ dlq.record(
252
+ &run_id,
253
+ &destination_key,
254
+ &record.id,
255
+ reason,
256
+ &record.data.to_string(),
257
+ )?;
258
+ dead_lettered += 1;
259
+ }
260
+ }
261
+ }
262
+
263
+ (written, failed_count, skipped, dead_lettered, Vec::new(), Some(node))
224
264
  };
225
265
 
226
266
  let completed_at = Utc::now();
@@ -243,6 +283,7 @@ async fn execute_with_records(
243
283
  dry_run: options.dry_run,
244
284
  dry_run_preview,
245
285
  rows_skipped_idempotent,
286
+ rows_dead_lettered,
246
287
  schema_changes,
247
288
  compliance_violations,
248
289
  started_at,
@@ -424,7 +465,7 @@ async fn write_to_destination(
424
465
  destination: &DestinationSpec,
425
466
  records: &[ConnRecord],
426
467
  idempotency_store_path: Option<&Path>,
427
- ) -> crate::Result<(u64, u64, u64, LineageNode)> {
468
+ ) -> crate::Result<(u64, u64, u64, Vec<(ConnRecord, String)>, LineageNode)> {
428
469
  match destination {
429
470
  DestinationSpec::Postgres(cfg) => {
430
471
  let connector = PostgreSQLConnector::new(cfg.clone());
@@ -439,6 +480,7 @@ async fn write_to_destination(
439
480
  written,
440
481
  (records.len() as u64).saturating_sub(written),
441
482
  0,
483
+ Vec::new(),
442
484
  node,
443
485
  ))
444
486
  }
@@ -455,6 +497,7 @@ async fn write_to_destination(
455
497
  written,
456
498
  (records.len() as u64).saturating_sub(written),
457
499
  0,
500
+ Vec::new(),
458
501
  node,
459
502
  ))
460
503
  }
@@ -473,11 +516,12 @@ async fn write_to_destination(
473
516
  written,
474
517
  (records.len() as u64).saturating_sub(written),
475
518
  0,
519
+ Vec::new(),
476
520
  node,
477
521
  ))
478
522
  }
479
523
  DestinationSpec::Webhook { config, auth } => {
480
- let (written, failed, skipped) = write_via_adapter(
524
+ let (written, skipped, failed) = write_via_adapter(
481
525
  "webhook",
482
526
  config.clone(),
483
527
  auth.clone(),
@@ -502,10 +546,10 @@ async fn write_to_destination(
502
546
  .unwrap_or("webhook")
503
547
  .to_string(),
504
548
  );
505
- Ok((written, failed, skipped, node))
549
+ Ok((written, failed.len() as u64, skipped, failed, node))
506
550
  }
507
551
  DestinationSpec::Salesforce { config, auth } => {
508
- let (written, failed, skipped) = write_via_adapter(
552
+ let (written, skipped, failed) = write_via_adapter(
509
553
  "salesforce",
510
554
  config.clone(),
511
555
  auth.clone(),
@@ -531,10 +575,10 @@ async fn write_to_destination(
531
575
  "salesforce",
532
576
  object.to_string(),
533
577
  );
534
- Ok((written, failed, skipped, node))
578
+ Ok((written, failed.len() as u64, skipped, failed, node))
535
579
  }
536
580
  DestinationSpec::HubSpot { config, auth } => {
537
- let (written, failed, skipped) = write_via_adapter(
581
+ let (written, skipped, failed) = write_via_adapter(
538
582
  "hubspot",
539
583
  config.clone(),
540
584
  auth.clone(),
@@ -553,10 +597,10 @@ async fn write_to_destination(
553
597
  "hubspot",
554
598
  object.to_string(),
555
599
  );
556
- Ok((written, failed, skipped, node))
600
+ Ok((written, failed.len() as u64, skipped, failed, node))
557
601
  }
558
602
  DestinationSpec::Marketo { config, auth } => {
559
- let (written, failed, skipped) = write_via_adapter(
603
+ let (written, skipped, failed) = write_via_adapter(
560
604
  "marketo",
561
605
  config.clone(),
562
606
  auth.clone(),
@@ -577,7 +621,7 @@ async fn write_to_destination(
577
621
  "marketo",
578
622
  "leads".to_string(),
579
623
  );
580
- Ok((written, failed, skipped, node))
624
+ Ok((written, failed.len() as u64, skipped, failed, node))
581
625
  }
582
626
  }
583
627
  }
@@ -599,8 +643,8 @@ async fn write_via_adapter(
599
643
  records: Vec<ConnRecord>,
600
644
  idempotency_store_path: Option<PathBuf>,
601
645
  destination_key: String,
602
- ) -> crate::Result<(u64, u64, u64)> {
603
- tokio::task::spawn_blocking(move || -> crate::Result<(u64, u64, u64)> {
646
+ ) -> crate::Result<(u64, u64, Vec<(ConnRecord, String)>)> {
647
+ tokio::task::spawn_blocking(move || -> crate::Result<(u64, u64, Vec<(ConnRecord, String)>)> {
604
648
  let adapter = AdapterFactory::create_adapter(adapter_type, &config, &auth)?;
605
649
  let ledger = idempotency_store_path
606
650
  .as_deref()
@@ -608,8 +652,10 @@ async fn write_via_adapter(
608
652
  .transpose()?;
609
653
 
610
654
  let mut written = 0u64;
611
- let mut failed = 0u64;
612
655
  let mut skipped = 0u64;
656
+ // Records the destination rejected, with the reason, captured so a
657
+ // caller can quarantine them (DLQ) rather than only seeing a count.
658
+ let mut failed: Vec<(ConnRecord, String)> = Vec::new();
613
659
  for record in &records {
614
660
  if let Some(ledger) = &ledger {
615
661
  let hash = idempotency::content_hash(&record.data);
@@ -623,17 +669,29 @@ async fn write_via_adapter(
623
669
  ledger.mark_synced(&destination_key, &record.id, &hash)?;
624
670
  written += 1;
625
671
  }
626
- _ => failed += 1,
672
+ Ok(result) => {
673
+ let reason = result
674
+ .error_message
675
+ .unwrap_or_else(|| "destination rejected record".to_string());
676
+ failed.push((record.clone(), reason));
677
+ }
678
+ Err(e) => failed.push((record.clone(), e.to_string())),
627
679
  }
628
680
  } else {
629
681
  let entity = record_to_entity(record);
630
682
  match adapter.upsert(&entity, &[]) {
631
683
  Ok(result) if result.success => written += 1,
632
- _ => failed += 1,
684
+ Ok(result) => {
685
+ let reason = result
686
+ .error_message
687
+ .unwrap_or_else(|| "destination rejected record".to_string());
688
+ failed.push((record.clone(), reason));
689
+ }
690
+ Err(e) => failed.push((record.clone(), e.to_string())),
633
691
  }
634
692
  }
635
693
  }
636
- Ok((written, failed, skipped))
694
+ Ok((written, skipped, failed))
637
695
  })
638
696
  .await
639
697
  .map_err(|e| crate::Error::Internal(format!("destination adapter task panicked: {e}")))?
@@ -747,6 +805,7 @@ mod tests {
747
805
  dry_run: false,
748
806
  schema_store_path: None,
749
807
  idempotency_store_path: None,
808
+ dlq_store_path: None,
750
809
  },
751
810
  &lineage,
752
811
  )
@@ -793,6 +852,7 @@ mod tests {
793
852
  dry_run: false,
794
853
  schema_store_path: None,
795
854
  idempotency_store_path: None,
855
+ dlq_store_path: None,
796
856
  },
797
857
  &lineage,
798
858
  )
@@ -829,6 +889,7 @@ mod tests {
829
889
  dry_run: true,
830
890
  schema_store_path: None,
831
891
  idempotency_store_path: None,
892
+ dlq_store_path: None,
832
893
  },
833
894
  &lineage,
834
895
  )
@@ -886,6 +947,7 @@ mod tests {
886
947
  dry_run: true,
887
948
  schema_store_path: None,
888
949
  idempotency_store_path: None,
950
+ dlq_store_path: None,
889
951
  },
890
952
  &lineage,
891
953
  )
@@ -932,6 +994,7 @@ mod tests {
932
994
  dry_run: false,
933
995
  schema_store_path: Some(store_path.clone()),
934
996
  idempotency_store_path: None,
997
+ dlq_store_path: None,
935
998
  },
936
999
  &lineage,
937
1000
  )
@@ -950,6 +1013,7 @@ mod tests {
950
1013
  dry_run: false,
951
1014
  schema_store_path: Some(store_path.clone()),
952
1015
  idempotency_store_path: None,
1016
+ dlq_store_path: None,
953
1017
  },
954
1018
  &lineage,
955
1019
  )
@@ -974,6 +1038,7 @@ mod tests {
974
1038
  dry_run: false,
975
1039
  schema_store_path: Some(store_path.clone()),
976
1040
  idempotency_store_path: None,
1041
+ dlq_store_path: None,
977
1042
  },
978
1043
  &lineage,
979
1044
  )
@@ -1022,6 +1087,7 @@ mod tests {
1022
1087
  dry_run: false,
1023
1088
  schema_store_path: Some(store_path.clone()),
1024
1089
  idempotency_store_path: None,
1090
+ dlq_store_path: None,
1025
1091
  },
1026
1092
  &lineage,
1027
1093
  )
@@ -1042,6 +1108,7 @@ mod tests {
1042
1108
  dry_run: false,
1043
1109
  schema_store_path: Some(store_path.clone()),
1044
1110
  idempotency_store_path: None,
1111
+ dlq_store_path: None,
1045
1112
  },
1046
1113
  &lineage,
1047
1114
  )
@@ -1072,6 +1139,7 @@ mod tests {
1072
1139
  dry_run: false,
1073
1140
  schema_store_path: Some(store_path.clone()),
1074
1141
  idempotency_store_path: None,
1142
+ dlq_store_path: None,
1075
1143
  },
1076
1144
  &lineage,
1077
1145
  )
@@ -1117,6 +1185,7 @@ mod tests {
1117
1185
  dry_run: false,
1118
1186
  schema_store_path: None,
1119
1187
  idempotency_store_path: Some(ledger_path.clone()),
1188
+ dlq_store_path: None,
1120
1189
  },
1121
1190
  &lineage,
1122
1191
  )
@@ -1143,6 +1212,7 @@ mod tests {
1143
1212
  dry_run: false,
1144
1213
  schema_store_path: None,
1145
1214
  idempotency_store_path: Some(ledger_path.clone()),
1215
+ dlq_store_path: None,
1146
1216
  },
1147
1217
  &lineage,
1148
1218
  )
@@ -1190,6 +1260,7 @@ mod tests {
1190
1260
  dry_run: false,
1191
1261
  schema_store_path: None,
1192
1262
  idempotency_store_path: Some(ledger_path.clone()),
1263
+ dlq_store_path: None,
1193
1264
  },
1194
1265
  &lineage,
1195
1266
  )
@@ -1212,6 +1283,7 @@ mod tests {
1212
1283
  dry_run: false,
1213
1284
  schema_store_path: None,
1214
1285
  idempotency_store_path: Some(ledger_path.clone()),
1286
+ dlq_store_path: None,
1215
1287
  },
1216
1288
  &lineage,
1217
1289
  )
@@ -1268,4 +1340,85 @@ mod tests {
1268
1340
  "with no idempotency store configured, every run resends every record (prior behavior)"
1269
1341
  );
1270
1342
  }
1343
+
1344
+ #[tokio::test]
1345
+ async fn failed_records_are_quarantined_in_dead_letter_queue() {
1346
+ let dir = std::env::temp_dir().join(format!("pyreverseetl_dlq_{}", uuid::Uuid::new_v4()));
1347
+ std::fs::create_dir_all(&dir).unwrap();
1348
+ let dlq_path = dir.join("dlq.db");
1349
+
1350
+ // Destination rejects every record with a 500.
1351
+ let server = MockHttpServer::start(500, "{}");
1352
+ let mut config = HashMap::new();
1353
+ config.insert("url".to_string(), json!(server.base_url.clone()));
1354
+ let destination = DestinationSpec::Webhook {
1355
+ config,
1356
+ auth: AdapterAuth::Bearer {
1357
+ token: "tok".to_string(),
1358
+ },
1359
+ };
1360
+ let lineage = LineageStore::new();
1361
+
1362
+ let result = execute_with_records(
1363
+ sample_records(),
1364
+ source_node(),
1365
+ destination,
1366
+ ExecuteOptions {
1367
+ dlq_store_path: Some(dlq_path.clone()),
1368
+ ..Default::default()
1369
+ },
1370
+ &lineage,
1371
+ )
1372
+ .await
1373
+ .unwrap();
1374
+
1375
+ assert_eq!(result.rows_written, 0);
1376
+ assert_eq!(result.rows_failed, 2);
1377
+ assert_eq!(
1378
+ result.rows_dead_lettered, 2,
1379
+ "every observed per-record failure is quarantined"
1380
+ );
1381
+
1382
+ let dlq = DeadLetterQueue::open(&dlq_path).unwrap();
1383
+ let letters = dlq.list_for_run(&result.run_id).unwrap();
1384
+ assert_eq!(letters.len(), 2);
1385
+ assert!(
1386
+ letters.iter().all(|l| !l.reason.is_empty()),
1387
+ "each quarantined record carries a reason"
1388
+ );
1389
+ assert!(
1390
+ letters.iter().all(|l| !l.payload.is_empty()),
1391
+ "each quarantined record carries its payload for replay"
1392
+ );
1393
+ }
1394
+
1395
+ #[tokio::test]
1396
+ async fn no_dlq_path_means_no_quarantine() {
1397
+ let server = MockHttpServer::start(500, "{}");
1398
+ let mut config = HashMap::new();
1399
+ config.insert("url".to_string(), json!(server.base_url.clone()));
1400
+ let destination = DestinationSpec::Webhook {
1401
+ config,
1402
+ auth: AdapterAuth::Bearer {
1403
+ token: "tok".to_string(),
1404
+ },
1405
+ };
1406
+ let lineage = LineageStore::new();
1407
+
1408
+ let result = execute_with_records(
1409
+ sample_records(),
1410
+ source_node(),
1411
+ destination,
1412
+ ExecuteOptions::default(),
1413
+ &lineage,
1414
+ )
1415
+ .await
1416
+ .unwrap();
1417
+
1418
+ assert_eq!(result.rows_failed, 2);
1419
+ assert_eq!(
1420
+ result.rows_dead_lettered, 0,
1421
+ "no quarantine store configured -> nothing persisted (prior behavior)"
1422
+ );
1423
+ }
1271
1424
  }
@@ -3,6 +3,7 @@ pub mod adapters;
3
3
  pub mod cdc;
4
4
  pub mod connectors;
5
5
  pub mod destination;
6
+ pub mod dlq;
6
7
  pub mod entity;
7
8
  pub mod error;
8
9
  pub mod executor;
@@ -28,6 +29,7 @@ pub use cdc::{
28
29
  Change, ChangeDetector, ChangeLog, ChangeLogEntry, ChangeType, Checkpoint, CheckpointManager,
29
30
  };
30
31
  pub use destination::Destination;
32
+ pub use dlq::{DeadLetter, DeadLetterQueue};
31
33
  pub use entity::Entity;
32
34
  pub use error::{Error, Result};
33
35
  pub use executor::{execute_sync, DestinationSpec, ExecuteOptions, ExecutionResult, SourceSpec};
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "PyReverseETL"
7
- version = "3.1.4"
7
+ version = "3.1.6"
8
8
  description = "The Open Operational Data Activation Runtime"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -161,6 +161,7 @@ class CLIInterface:
161
161
  dry_run: bool = False,
162
162
  schema_store_path: str | None = None,
163
163
  idempotency_store_path: str | None = None,
164
+ dlq_store_path: str | None = None,
164
165
  ) -> dict:
165
166
  """Execute an activation: run a real sync through the Rust engine.
166
167
 
@@ -199,6 +200,14 @@ class CLIInterface:
199
200
  changed is still sent. Only applies to the adapter-based
200
201
  destinations (webhook/salesforce/hubspot/marketo). Omit to
201
202
  disable idempotency checking entirely (default).
203
+ dlq_store_path: Optional path to a SQLite file used as a
204
+ dead-letter queue (quarantine) for records the destination
205
+ rejects. Each failed record is stored with its run id, the
206
+ reason for rejection, and its exact payload, so a partial
207
+ batch failure can be inspected and re-driven deliberately
208
+ instead of re-running the whole sync. Only applies to the
209
+ adapter-based destinations. Omit to disable quarantine
210
+ (default).
202
211
 
203
212
  Returns:
204
213
  JSON response with real execution details, or a real error if
@@ -241,6 +250,7 @@ class CLIInterface:
241
250
  dry_run=dry_run,
242
251
  schema_store_path=schema_store_path,
243
252
  idempotency_store_path=idempotency_store_path,
253
+ dlq_store_path=dlq_store_path,
244
254
  )
245
255
  except Exception as e: # noqa: BLE001 - surface real engine errors verbatim
246
256
  return {
@@ -258,6 +268,7 @@ class CLIInterface:
258
268
  "dry_run": True,
259
269
  "rows_read": result.rows_read,
260
270
  "rows_would_write": len(result.dry_run_preview),
271
+ "rows_dead_lettered": result.rows_dead_lettered,
261
272
  "dry_run_preview": [json.loads(p) for p in result.dry_run_preview],
262
273
  "compliance_violations": result.compliance_violations,
263
274
  "schema_changes": result.schema_changes,
@@ -277,6 +288,7 @@ class CLIInterface:
277
288
  "rows_read": result.rows_read,
278
289
  "rows_failed": result.rows_failed,
279
290
  "rows_skipped_idempotent": result.rows_skipped_idempotent,
291
+ "rows_dead_lettered": result.rows_dead_lettered,
280
292
  "compliance_violations": result.compliance_violations,
281
293
  "schema_changes": result.schema_changes,
282
294
  "destination": activation["destination"],
@@ -294,6 +306,7 @@ class CLIInterface:
294
306
  "rows_read": result.rows_read,
295
307
  "rows_failed": result.rows_failed,
296
308
  "rows_skipped_idempotent": result.rows_skipped_idempotent,
309
+ "rows_dead_lettered": result.rows_dead_lettered,
297
310
  "compliance_violations": result.compliance_violations,
298
311
  "schema_changes": result.schema_changes,
299
312
  "duration_ms": result.duration_ms,
@@ -480,6 +493,13 @@ def main():
480
493
  print(json.dumps({"error": "--idempotency-store requires a file path"}))
481
494
  sys.exit(1)
482
495
  idempotency_store_path = sys.argv[idx + 1]
496
+ dlq_store_path = None
497
+ if "--dlq-store" in sys.argv:
498
+ idx = sys.argv.index("--dlq-store")
499
+ if idx + 1 >= len(sys.argv):
500
+ print(json.dumps({"error": "--dlq-store requires a file path"}))
501
+ sys.exit(1)
502
+ dlq_store_path = sys.argv[idx + 1]
483
503
 
484
504
  result = cli.execute_activation(
485
505
  activation_id,
@@ -488,6 +508,7 @@ def main():
488
508
  dry_run,
489
509
  schema_store_path,
490
510
  idempotency_store_path,
511
+ dlq_store_path,
491
512
  )
492
513
  print(json.dumps(result))
493
514
 
@@ -584,7 +605,7 @@ COMMANDS:
584
605
  pyreverseetl create-activation ltv_to_hook ltv_sync webhook \\
585
606
  --dest-config '{"url":"https://example.com/hook","auth":{"type":"bearer","token":"secret"}}'
586
607
 
587
- execute <activation_id> [limit] [--compliance-rules <json|@file>] [--dry-run] [--schema-store <path>] [--idempotency-store <path>]
608
+ execute <activation_id> [limit] [--compliance-rules <json|@file>] [--dry-run] [--schema-store <path>] [--idempotency-store <path>] [--dlq-store <path>]
588
609
  Execute a REAL data synchronization through the Rust engine
589
610
  - activation_id: Activation to execute (required)
590
611
  - limit: Max rows to read from the source (optional)
@@ -606,12 +627,19 @@ COMMANDS:
606
627
  re-sending them, while a record whose content genuinely changed
607
628
  is still sent. Only applies to webhook/salesforce/hubspot/marketo
608
629
  destinations.
630
+ - --dlq-store <path>: Persist every record the destination rejects to
631
+ a real SQLite dead-letter queue at <path>, with the run id, reason,
632
+ and exact payload (rows_dead_lettered), so a partial batch failure
633
+ can be inspected and re-driven deliberately instead of re-running
634
+ the whole sync. Only applies to webhook/salesforce/hubspot/marketo
635
+ destinations.
609
636
 
610
637
  Example:
611
638
  pyreverseetl execute ltv_to_hook 5000
612
639
  pyreverseetl execute ltv_to_hook --dry-run
613
640
  pyreverseetl execute ltv_to_hook --schema-store .pyreverseetl/schema.db
614
641
  pyreverseetl execute ltv_to_hook --idempotency-store .pyreverseetl/idempotency.db
642
+ pyreverseetl execute ltv_to_hook --dlq-store .pyreverseetl/dlq.db
615
643
 
616
644
  status <run_id>
617
645
  Get status of a sync run
@@ -2,7 +2,7 @@ use pyo3::exceptions::PyValueError;
2
2
  use pyo3::prelude::*;
3
3
  use pyreverseetl_core::adapters::AuthMethod as AdapterAuth;
4
4
  use pyreverseetl_core::connectors::{
5
- MySQLConfig, ObjectStorageConfig, PostgreSQLConfig, TableFormat,
5
+ MySQLConfig, ObjectStorageConfig, PostgreSQLConfig, TableFormat, WebConfig,
6
6
  };
7
7
  use pyreverseetl_core::destination::DestinationType;
8
8
  use pyreverseetl_core::entity::EntityType;
@@ -301,8 +301,27 @@ fn build_source_spec(source_type: &str, config: &Value) -> PyResult<SourceSpec>
301
301
  Ok(SourceSpec::MySQL(cfg))
302
302
  }
303
303
  "s3" => Ok(SourceSpec::S3(build_object_storage_config(config)?)),
304
+ "web" => {
305
+ let mut cfg = WebConfig {
306
+ seed_url: get_str(config, "seed_url")?.to_string(),
307
+ ..Default::default()
308
+ };
309
+ if let Some(v) = config.get("max_depth").and_then(|v| v.as_u64()) {
310
+ cfg.max_depth = v as usize;
311
+ }
312
+ if let Some(v) = config.get("max_urls").and_then(|v| v.as_u64()) {
313
+ cfg.max_urls = v as usize;
314
+ }
315
+ if let Some(v) = config.get("same_domain").and_then(|v| v.as_bool()) {
316
+ cfg.same_domain = v;
317
+ }
318
+ if let Some(v) = config.get("render_js").and_then(|v| v.as_bool()) {
319
+ cfg.render_js = v;
320
+ }
321
+ Ok(SourceSpec::Web(cfg))
322
+ }
304
323
  other => Err(py_err(format!(
305
- "unsupported source_type '{other}' (supported: postgres, mysql, s3)"
324
+ "unsupported source_type '{other}' (supported: postgres, mysql, s3, web)"
306
325
  ))),
307
326
  }
308
327
  }
@@ -484,12 +503,20 @@ pub struct PySyncResult {
484
503
  /// disabled.
485
504
  #[pyo3(get)]
486
505
  pub rows_skipped_idempotent: u64,
506
+ /// Records quarantined into the dead-letter queue (see `dlq_store_path`)
507
+ /// because the destination rejected them. Always 0 when no DLQ path is
508
+ /// configured; equals `rows_failed` for the adapter-based destinations
509
+ /// (webhook/salesforce/hubspot/marketo) when a DLQ path is set.
510
+ #[pyo3(get)]
511
+ pub rows_dead_lettered: u64,
487
512
  }
488
513
 
489
514
  /// Run a real sync: read from `source_type`/`source_config` (JSON string),
490
515
  /// apply `compliance_rules` (JSON string, optional), write to
491
516
  /// `destination_type`/`destination_config` (JSON string), and record a real
492
- /// lineage edge. Supported `source_type`: `postgres`, `mysql`, `s3`.
517
+ /// lineage edge. Supported `source_type`: `postgres`, `mysql`, `s3`, `web`
518
+ /// (MudFish-backed site crawl; `source_config` takes `seed_url` plus optional
519
+ /// `max_depth`, `max_urls`, `same_domain`, `render_js`).
493
520
  /// Supported `destination_type`: all of those plus `webhook`, `salesforce`,
494
521
  /// `hubspot`, `marketo`. With `dry_run=True`, records are still read and run
495
522
  /// through the real compliance engine, but the destination is never
@@ -503,8 +530,14 @@ pub struct PySyncResult {
503
530
  /// records already synced instead of re-sending them -- see
504
531
  /// `PySyncResult.rows_skipped_idempotent`. Only applies to the
505
532
  /// adapter-based destinations (webhook/salesforce/hubspot/marketo).
533
+ /// With `dlq_store_path` set, every record the destination rejects is
534
+ /// persisted to a real SQLite dead-letter queue at that path (run id, reason,
535
+ /// and exact payload), so a partial batch failure can be inspected and
536
+ /// re-driven deliberately instead of re-running the whole sync -- see
537
+ /// `PySyncResult.rows_dead_lettered`. Only applies to the adapter-based
538
+ /// destinations.
506
539
  #[pyfunction]
507
- #[pyo3(signature = (source_type, source_config, destination_type, destination_config, limit=None, compliance_rules=None, dry_run=false, schema_store_path=None, idempotency_store_path=None))]
540
+ #[pyo3(signature = (source_type, source_config, destination_type, destination_config, limit=None, compliance_rules=None, dry_run=false, schema_store_path=None, idempotency_store_path=None, dlq_store_path=None))]
508
541
  pub fn run_sync(
509
542
  source_type: &str,
510
543
  source_config: &str,
@@ -515,6 +548,7 @@ pub fn run_sync(
515
548
  dry_run: bool,
516
549
  schema_store_path: Option<&str>,
517
550
  idempotency_store_path: Option<&str>,
551
+ dlq_store_path: Option<&str>,
518
552
  ) -> PyResult<PySyncResult> {
519
553
  let source_cfg_value = parse_json(source_config)?;
520
554
  let dest_cfg_value = parse_json(destination_config)?;
@@ -529,6 +563,7 @@ pub fn run_sync(
529
563
  dry_run,
530
564
  schema_store_path: schema_store_path.map(std::path::PathBuf::from),
531
565
  idempotency_store_path: idempotency_store_path.map(std::path::PathBuf::from),
566
+ dlq_store_path: dlq_store_path.map(std::path::PathBuf::from),
532
567
  };
533
568
 
534
569
  let runtime = tokio::runtime::Runtime::new().map_err(py_err)?;
@@ -547,6 +582,7 @@ pub fn run_sync(
547
582
  duration_ms: result.duration_ms,
548
583
  schema_changes: result.schema_changes,
549
584
  rows_skipped_idempotent: result.rows_skipped_idempotent,
585
+ rows_dead_lettered: result.rows_dead_lettered,
550
586
  dry_run: result.dry_run,
551
587
  dry_run_preview: result.dry_run_preview,
552
588
  })
File without changes