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.
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/Cargo.lock +2 -2
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/Cargo.toml +1 -1
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/PKG-INFO +2 -2
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/README.md +1 -1
- pyreverseetl-3.1.6/core/src/dlq.rs +161 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/executor.rs +172 -19
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/lib.rs +2 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/pyproject.toml +1 -1
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/python/pyreverseetl/cli.py +29 -1
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/python/src/lib.rs +40 -4
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/LICENSE +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/Cargo.toml +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/benches/load_test.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/bin/pyreverseetl_dashboard.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/activation.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/adapters/alert_compat.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/adapters/error.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/adapters/http_client.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/adapters/hubspot.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/adapters/mapping.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/adapters/marketo.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/adapters/mod.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/adapters/oauth_manager.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/adapters/retry_policy.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/adapters/salesforce.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/adapters/schema_detect.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/adapters/webhook.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/cdc/change_detector.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/cdc/changelog.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/cdc/checkpoint.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/cdc/mod.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/config.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/connectors_db.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/database_advanced.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/destination.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/error.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/hdfs.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/mod.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/mysql.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/object_storage.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/postgres.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/rate_limiting.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/registry.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/saas.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/source.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/connectors/web.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/destination.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/entity.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/error.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/governance/cached_gate.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/governance/compliance_rules.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/governance/credentials.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/governance/mod.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/governance/quality_gate.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/governance/retry_policy.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/governance/schema_evolution.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/governance/statguardian_client.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/idempotency.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/lineage.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/observability/dashboard_launcher.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/observability/logs.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/observability/metrics.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/observability/metrics_server.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/observability/mod.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/observability/traces.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/pipeline/activation_pipeline.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/pipeline/backpressure.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/pipeline/latency_tracker.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/pipeline/mod.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/sources/kafka.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/sources/mod.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/sources/polling.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/sources/sync_config.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/statguardian.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/storage/mod.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/storage/repository.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/storage/schema.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/streaming/event_processor.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/streaming/event_schema.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/streaming/mod.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/streampdf.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/streamxl.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/sync.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/testing/connector_test.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/testing/harness.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/testing/metrics.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/testing/mock_http.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/testing/mod.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/testing/test_data.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/transformers/mod.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/transformers/spark.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/core/src/workflow.rs +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/python/Cargo.toml +0 -0
- {pyreverseetl-3.1.4 → pyreverseetl-3.1.6}/python/pyreverseetl/__init__.py +0 -0
- {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.
|
|
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.
|
|
3213
|
+
version = "3.1.6"
|
|
3214
3214
|
dependencies = [
|
|
3215
3215
|
"async-trait",
|
|
3216
3216
|
"aws-config",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyReverseETL
|
|
3
|
-
Version: 3.1.
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
603
|
-
tokio::task::spawn_blocking(move || -> crate::Result<(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
|
-
|
|
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
|
-
|
|
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,
|
|
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};
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|