fix: Docker support, WAL race condition, pusher panic fixes

- Add multi-stage Dockerfile with tini, non-root user, healthcheck
- Fix SQLite WAL mode race between bal-server and bal-pusher (busy_timeout + retry)
- Fix tbl_stats missing UNIQUE index for ON CONFLICT clause
- Replace unwrap() panics in bal-pusher with graceful error handling
- Add docker/entrypoint.sh with BAL_PUSHER_NETWORK support
- cargo fmt across all files
This commit is contained in:
2026-07-16 21:24:09 -04:00
parent 56696050ec
commit fbe61a5862
8 changed files with 355 additions and 99 deletions

View File

@@ -247,7 +247,13 @@ async fn main_result(cfg: &MyConfig, network_params: &NetworkParams) -> Result<(
info!("db open {}", &cfg.db_file);
let sqlquery = "SELECT * FROM tbl_tx WHERE network = :network AND status = :status AND ( locktime < :bestblock_height OR locktime > :locktime_threshold AND locktime < :bestblock_time);";
let query_tx = db.prepare(sqlquery).unwrap().into_iter();
let query_tx = match db.prepare(sqlquery) {
Ok(q) => q.into_iter(),
Err(e) => {
warn!("tbl_tx not ready yet (tables may not exist): {}", e);
return Ok(());
}
};
trace!("query_tx: {}", sqlquery);
trace!(":locktime_threshold: {}", LOCKTIME_THRESHOLD);
trace!(":bestblock_time: {}", average_time);
@@ -257,19 +263,28 @@ async fn main_result(cfg: &MyConfig, network_params: &NetworkParams) -> Result<(
//let query_tx = db.prepare("SELECT * FROM tbl_tx where status = :status").unwrap().into_iter();
let mut pushed_txs: Vec<String> = Vec::new();
let mut invalid_txs: std::collections::HashMap<String, String> = HashMap::new();
for row in query_tx
.bind::<&[(_, Value)]>(
&[
(":locktime_threshold", (LOCKTIME_THRESHOLD as i64).into()),
(":bestblock_time", (average_time as i64).into()),
(":bestblock_height", (bcinfo.blocks as i64).into()),
(":network", network_params.db_field.clone().into()),
(":status", 0.into()),
][..],
)
.unwrap()
.map(|row| row.unwrap())
{
for row_result in match query_tx.bind::<&[(_, Value)]>(
&[
(":locktime_threshold", (LOCKTIME_THRESHOLD as i64).into()),
(":bestblock_time", (average_time as i64).into()),
(":bestblock_height", (bcinfo.blocks as i64).into()),
(":network", network_params.db_field.clone().into()),
(":status", 0.into()),
][..],
) {
Ok(bound) => bound,
Err(e) => {
error!("Failed to bind query parameters: {}", e);
return Ok(());
}
} {
let row = match row_result {
Ok(r) => r,
Err(e) => {
warn!("Failed to read row: {}", e);
continue;
}
};
let tx = row.read::<&str, _>("tx");
let txid = row.read::<&str, _>("txid");
let locktime = row.read::<i64, _>("locktime");

View File

@@ -266,13 +266,15 @@ async fn echo_info(
&netconfig.address,
&remote_addr,
) {
Some(address) => return HttpResponse::Ok().json(InfoResponse {
address,
base_fee: netconfig.fixed_fee,
chain: netconfig.network.to_string(),
info: data.cfg.info.to_string(),
version: VERSION.to_string(),
}),
Some(address) => {
return HttpResponse::Ok().json(InfoResponse {
address,
base_fee: netconfig.fixed_fee,
chain: netconfig.network.to_string(),
info: data.cfg.info.to_string(),
version: VERSION.to_string(),
});
}
None => {
let next = get_next_address_index(&db, &netconfig.name, &netconfig.address);
next
@@ -281,16 +283,15 @@ async fn echo_info(
}; // lock released
// Derive address (CPU-bound, no lock held)
let derived = match new_address_from_xpub(
&netconfig.address, next_idx.1, netconfig.network
) {
Ok(address) => address,
Err(e) => {
error!("Failed to derive address from xpub: {}", e);
return HttpResponse::BadRequest()
.body(format!("Failed to derive address: {}", e));
}
};
let derived =
match new_address_from_xpub(&netconfig.address, next_idx.1, netconfig.network) {
Ok(address) => address,
Err(e) => {
error!("Failed to derive address from xpub: {}", e);
return HttpResponse::BadRequest()
.body(format!("Failed to derive address: {}", e));
}
};
// Lock #2: save the newly derived address
{
@@ -363,14 +364,46 @@ async fn echo_stats(path: web::Path<String>, data: web::Data<AppState>) -> impl
while let Ok(State::Row) = stmt.next() {
let report_date = stmt.read("report_date").unwrap_or("0".to_string());
let chain = stmt.read("chain").unwrap_or("?".to_string());
let totals = stmt.read("totals").unwrap_or("0".to_string()).parse::<i64>().unwrap_or(0);
let waiting = stmt.read("waiting").unwrap_or("0".to_string()).parse::<i64>().unwrap_or(0);
let sent = stmt.read("sent").unwrap_or("0".to_string()).parse::<i64>().unwrap_or(0);
let failed = stmt.read("failed").unwrap_or("0".to_string()).parse::<i64>().unwrap_or(0);
let waiting_profit = stmt.read("waiting_profit").unwrap_or("0".to_string()).parse::<i64>().unwrap_or(0);
let sent_profit = stmt.read("sent_profit").unwrap_or("0".to_string()).parse::<i64>().unwrap_or(0);
let missed_profit = stmt.read("missed_profit").unwrap_or("0".to_string()).parse::<i64>().unwrap_or(0);
let unique_inputs = stmt.read("unique_inputs").unwrap_or("0".to_string()).parse::<i64>().unwrap_or(0);
let totals = stmt
.read("totals")
.unwrap_or("0".to_string())
.parse::<i64>()
.unwrap_or(0);
let waiting = stmt
.read("waiting")
.unwrap_or("0".to_string())
.parse::<i64>()
.unwrap_or(0);
let sent = stmt
.read("sent")
.unwrap_or("0".to_string())
.parse::<i64>()
.unwrap_or(0);
let failed = stmt
.read("failed")
.unwrap_or("0".to_string())
.parse::<i64>()
.unwrap_or(0);
let waiting_profit = stmt
.read("waiting_profit")
.unwrap_or("0".to_string())
.parse::<i64>()
.unwrap_or(0);
let sent_profit = stmt
.read("sent_profit")
.unwrap_or("0".to_string())
.parse::<i64>()
.unwrap_or(0);
let missed_profit = stmt
.read("missed_profit")
.unwrap_or("0".to_string())
.parse::<i64>()
.unwrap_or(0);
let unique_inputs = stmt
.read("unique_inputs")
.unwrap_or("0".to_string())
.parse::<i64>()
.unwrap_or(0);
stats.push(StatsResponse {
report_date,
chain,
@@ -403,7 +436,8 @@ async fn echo_search(body: Bytes, data: web::Data<AppState>) -> impl Responder {
};
info!("{}", strbody);
if strbody.is_empty() || strbody.len() != 64 || !strbody.chars().all(|c| c.is_ascii_hexdigit()) {
if strbody.is_empty() || strbody.len() != 64 || !strbody.chars().all(|c| c.is_ascii_hexdigit())
{
return HttpResponse::BadRequest().body("Invalid txid");
}
@@ -485,8 +519,8 @@ struct ParsedTx {
ntxid: String,
raw_hex: String, // the original line
locktime: String,
inputs: Vec<(String, String)>, // (in_txid, in_vout)
outputs: Vec<(usize, String, u64)> // (idx, script_pubkey, amount_sat)
inputs: Vec<(String, String)>, // (in_txid, in_vout)
outputs: Vec<(usize, String, u64)>, // (idx, script_pubkey, amount_sat)
}
/// Parse all transactions from the request body **without** needing the DB lock.
@@ -560,7 +594,7 @@ fn parse_request_transactions(
if known_addresses.contains(&address) {
address.clone()
} else {
continue
continue;
}
} else {
netconfig.address.clone()
@@ -658,9 +692,7 @@ async fn echo_push(
}; // lock released here
// Parse all transactions (CPU-bound, no DB needed)
let parsed = match parse_request_transactions(
strbody, req_time, netconfig, &known_addresses,
) {
let parsed = match parse_request_transactions(strbody, req_time, netconfig, &known_addresses) {
Ok(v) => v,
Err(resp) => return resp,
};
@@ -763,9 +795,15 @@ async fn echo_push(
}
sqlouts.push_str(" SELECT ?, ?, ?, ?");
pouts.push((lineout, Value::String(parsed.txid.clone())));
pouts.push((lineout + 1, Value::Integer(i64::try_from(*idx).unwrap_or(-1))));
pouts.push((
lineout + 1,
Value::Integer(i64::try_from(*idx).unwrap_or(-1)),
));
pouts.push((lineout + 2, Value::String(script.clone())));
pouts.push((lineout + 3, Value::Integer(i64::try_from(*amount).unwrap_or(0))));
pouts.push((
lineout + 3,
Value::Integer(i64::try_from(*amount).unwrap_or(0)),
));
lineout += 4;
}
}