mirror of https://github.com/mastodon/flodgatt
Finish substantive work for Redis error handling
This commit is contained in:
parent
7fc19c33b3
commit
d5528aaf0c
|
@ -1,4 +1,4 @@
|
||||||
//use std::{error::Error, fmt};
|
use std::fmt;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum TimelineErr {
|
pub enum TimelineErr {
|
||||||
|
@ -11,3 +11,14 @@ impl From<std::num::ParseIntError> for TimelineErr {
|
||||||
Self::InvalidInput
|
Self::InvalidInput
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for TimelineErr {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
|
||||||
|
use TimelineErr::*;
|
||||||
|
let msg = match self {
|
||||||
|
RedisNamespaceMismatch => "TODO: Cut this error",
|
||||||
|
InvalidInput => "The timeline text from Redis could not be parsed into a supported timeline. TODO: add incoming timeline text"
|
||||||
|
};
|
||||||
|
write!(f, "{}", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
89
src/main.rs
89
src/main.rs
|
@ -1,11 +1,11 @@
|
||||||
use flodgatt::{
|
use flodgatt::{
|
||||||
config::{DeploymentConfig, EnvVar, PostgresConfig, RedisConfig},
|
config::{DeploymentConfig, EnvVar, PostgresConfig, RedisConfig},
|
||||||
parse_client_request::{PgPool, Subscription},
|
parse_client_request::{PgPool, Subscription},
|
||||||
redis_to_client_stream::{ClientAgent, EventStream},
|
redis_to_client_stream::{ClientAgent, EventStream, Receiver},
|
||||||
};
|
};
|
||||||
use std::{collections::HashMap, env, fs, net, os::unix::fs::PermissionsExt};
|
use std::{env, fs, net::SocketAddr, os::unix::fs::PermissionsExt};
|
||||||
use tokio::net::UnixListener;
|
use tokio::net::UnixListener;
|
||||||
use warp::{path, ws::Ws2, Filter};
|
use warp::{http::StatusCode, path, ws::Ws2, Filter, Rejection};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
dotenv::from_filename(match env::var("ENV").ok().as_ref().map(String::as_str) {
|
dotenv::from_filename(match env::var("ENV").ok().as_ref().map(String::as_str) {
|
||||||
|
@ -14,36 +14,35 @@ fn main() {
|
||||||
Some(unsupported) => EnvVar::err("ENV", unsupported, "`production` or `development`"),
|
Some(unsupported) => EnvVar::err("ENV", unsupported, "`production` or `development`"),
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
let env_vars_map: HashMap<_, _> = dotenv::vars().collect();
|
let env_vars = EnvVar::new(dotenv::vars().collect());
|
||||||
let env_vars = EnvVar::new(env_vars_map);
|
|
||||||
pretty_env_logger::init();
|
pretty_env_logger::init();
|
||||||
|
log::info!("Environmental variables Flodgatt received: {}", &env_vars);
|
||||||
|
|
||||||
log::info!(
|
let postgres_cfg = PostgresConfig::from_env(env_vars.clone());
|
||||||
"Flodgatt recognized the following environmental variables:{}",
|
|
||||||
env_vars.clone()
|
|
||||||
);
|
|
||||||
let redis_cfg = RedisConfig::from_env(env_vars.clone());
|
let redis_cfg = RedisConfig::from_env(env_vars.clone());
|
||||||
let cfg = DeploymentConfig::from_env(env_vars.clone());
|
let cfg = DeploymentConfig::from_env(env_vars.clone());
|
||||||
|
|
||||||
let postgres_cfg = PostgresConfig::from_env(env_vars.clone());
|
|
||||||
let pg_pool = PgPool::new(postgres_cfg);
|
let pg_pool = PgPool::new(postgres_cfg);
|
||||||
|
|
||||||
let client_agent_sse = ClientAgent::blank(redis_cfg);
|
let sharable_receiver = Receiver::try_from(redis_cfg)
|
||||||
let client_agent_ws = client_agent_sse.clone_with_shared_receiver();
|
.unwrap_or_else(|e| {
|
||||||
|
log::error!("{}\nFlodgatt shutting down...", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
})
|
||||||
|
.into_arc();
|
||||||
log::info!("Streaming server initialized and ready to accept connections");
|
log::info!("Streaming server initialized and ready to accept connections");
|
||||||
|
|
||||||
// Server Sent Events
|
// Server Sent Events
|
||||||
|
let sse_receiver = sharable_receiver.clone();
|
||||||
let (sse_interval, whitelist_mode) = (*cfg.sse_interval, *cfg.whitelist_mode);
|
let (sse_interval, whitelist_mode) = (*cfg.sse_interval, *cfg.whitelist_mode);
|
||||||
let sse_routes = Subscription::from_sse_query(pg_pool.clone(), whitelist_mode)
|
let sse_routes = Subscription::from_sse_query(pg_pool.clone(), whitelist_mode)
|
||||||
.and(warp::sse())
|
.and(warp::sse())
|
||||||
.map(
|
.map(
|
||||||
move |subscription: Subscription, sse_connection_to_client: warp::sse::Sse| {
|
move |subscription: Subscription, sse_connection_to_client: warp::sse::Sse| {
|
||||||
log::info!("Incoming SSE request for {:?}", subscription.timeline);
|
log::info!("Incoming SSE request for {:?}", subscription.timeline);
|
||||||
// Create a new ClientAgent
|
let mut client_agent = ClientAgent::new(sse_receiver.clone(), &subscription);
|
||||||
let mut client_agent = client_agent_sse.clone_with_shared_receiver();
|
client_agent.subscribe();
|
||||||
// Assign ClientAgent to generate stream of updates for the user/timeline pair
|
|
||||||
client_agent.init_for_user(subscription);
|
|
||||||
// send the updates through the SSE connection
|
// send the updates through the SSE connection
|
||||||
EventStream::to_sse(client_agent, sse_connection_to_client, sse_interval)
|
EventStream::to_sse(client_agent, sse_connection_to_client, sse_interval)
|
||||||
},
|
},
|
||||||
|
@ -51,24 +50,20 @@ fn main() {
|
||||||
.with(warp::reply::with::header("Connection", "keep-alive"));
|
.with(warp::reply::with::header("Connection", "keep-alive"));
|
||||||
|
|
||||||
// WebSocket
|
// WebSocket
|
||||||
|
let ws_receiver = sharable_receiver.clone();
|
||||||
let (ws_update_interval, whitelist_mode) = (*cfg.ws_interval, *cfg.whitelist_mode);
|
let (ws_update_interval, whitelist_mode) = (*cfg.ws_interval, *cfg.whitelist_mode);
|
||||||
let websocket_routes = Subscription::from_ws_request(pg_pool.clone(), whitelist_mode)
|
let ws_routes = Subscription::from_ws_request(pg_pool.clone(), whitelist_mode)
|
||||||
.and(warp::ws::ws2())
|
.and(warp::ws::ws2())
|
||||||
.map(move |subscription: Subscription, ws: Ws2| {
|
.map(move |subscription: Subscription, ws: Ws2| {
|
||||||
log::info!("Incoming websocket request for {:?}", subscription.timeline);
|
log::info!("Incoming websocket request for {:?}", subscription.timeline);
|
||||||
|
let mut client_agent = ClientAgent::new(ws_receiver.clone(), &subscription);
|
||||||
|
client_agent.subscribe();
|
||||||
|
|
||||||
let token = subscription.access_token.clone();
|
// send the updates through the WS connection
|
||||||
// Create a new ClientAgent
|
// (along with the User's access_token which is sent for security)
|
||||||
let mut client_agent = client_agent_ws.clone_with_shared_receiver();
|
|
||||||
// Assign that agent to generate a stream of updates for the user/timeline pair
|
|
||||||
client_agent.init_for_user(subscription);
|
|
||||||
// send the updates through the WS connection (along with the User's access_token
|
|
||||||
// which is sent for security)
|
|
||||||
(
|
(
|
||||||
ws.on_upgrade(move |socket| {
|
ws.on_upgrade(move |s| EventStream::to_ws(s, client_agent, ws_update_interval)),
|
||||||
EventStream::to_ws(socket, client_agent, ws_update_interval)
|
subscription.access_token.unwrap_or_else(String::new),
|
||||||
}),
|
|
||||||
token.unwrap_or_else(String::new),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.map(|(reply, token)| warp::reply::with_header(reply, "sec-websocket-protocol", token));
|
.map(|(reply, token)| warp::reply::with_header(reply, "sec-websocket-protocol", token));
|
||||||
|
@ -84,33 +79,23 @@ fn main() {
|
||||||
log::info!("Using Unix socket {}", socket);
|
log::info!("Using Unix socket {}", socket);
|
||||||
fs::remove_file(socket).unwrap_or_default();
|
fs::remove_file(socket).unwrap_or_default();
|
||||||
let incoming = UnixListener::bind(socket).unwrap().incoming();
|
let incoming = UnixListener::bind(socket).unwrap().incoming();
|
||||||
|
|
||||||
fs::set_permissions(socket, PermissionsExt::from_mode(0o666)).unwrap();
|
fs::set_permissions(socket, PermissionsExt::from_mode(0o666)).unwrap();
|
||||||
|
|
||||||
warp::serve(
|
warp::serve(
|
||||||
health.or(websocket_routes.or(sse_routes).with(cors).recover(
|
health.or(ws_routes.or(sse_routes).with(cors).recover(|r: Rejection| {
|
||||||
|rejection: warp::reject::Rejection| {
|
let json_err = match r.cause() {
|
||||||
let err_txt = match rejection.cause() {
|
Some(text) if text.to_string() == "Missing request header 'authorization'" => {
|
||||||
Some(text)
|
warp::reply::json(&"Error: Missing access token".to_string())
|
||||||
if text.to_string() == "Missing request header 'authorization'" =>
|
}
|
||||||
{
|
Some(text) => warp::reply::json(&text.to_string()),
|
||||||
"Error: Missing access token".to_string()
|
None => warp::reply::json(&"Error: Nonexistant endpoint".to_string()),
|
||||||
}
|
};
|
||||||
Some(text) => text.to_string(),
|
Ok(warp::reply::with_status(json_err, StatusCode::UNAUTHORIZED))
|
||||||
None => "Error: Nonexistant endpoint".to_string(),
|
})),
|
||||||
};
|
|
||||||
let json = warp::reply::json(&err_txt);
|
|
||||||
|
|
||||||
Ok(warp::reply::with_status(
|
|
||||||
json,
|
|
||||||
warp::http::StatusCode::UNAUTHORIZED,
|
|
||||||
))
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
)
|
)
|
||||||
.run_incoming(incoming);
|
.run_incoming(incoming);
|
||||||
} else {
|
} else {
|
||||||
let server_addr = net::SocketAddr::new(*cfg.address, cfg.port.0);
|
let server_addr = SocketAddr::new(*cfg.address, *cfg.port);
|
||||||
warp::serve(health.or(websocket_routes.or(sse_routes).with(cors))).run(server_addr);
|
warp::serve(health.or(ws_routes.or(sse_routes).with(cors))).run(server_addr);
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@ use crate::err::TimelineErr;
|
||||||
use crate::log_fatal;
|
use crate::log_fatal;
|
||||||
use lru::LruCache;
|
use lru::LruCache;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
use uuid::Uuid;
|
||||||
use warp::reject::Rejection;
|
use warp::reject::Rejection;
|
||||||
|
|
||||||
use super::query;
|
use super::query;
|
||||||
|
@ -50,6 +51,7 @@ macro_rules! parse_sse_query {
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct Subscription {
|
pub struct Subscription {
|
||||||
|
pub id: Uuid,
|
||||||
pub timeline: Timeline,
|
pub timeline: Timeline,
|
||||||
pub allowed_langs: HashSet<String>,
|
pub allowed_langs: HashSet<String>,
|
||||||
pub blocks: Blocks,
|
pub blocks: Blocks,
|
||||||
|
@ -60,6 +62,7 @@ pub struct Subscription {
|
||||||
impl Default for Subscription {
|
impl Default for Subscription {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
timeline: Timeline(Stream::Unset, Reach::Local, Content::Notification),
|
timeline: Timeline(Stream::Unset, Reach::Local, Content::Notification),
|
||||||
allowed_langs: HashSet::new(),
|
allowed_langs: HashSet::new(),
|
||||||
blocks: Blocks::default(),
|
blocks: Blocks::default(),
|
||||||
|
@ -123,6 +126,7 @@ impl Subscription {
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Subscription {
|
Ok(Subscription {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
timeline,
|
timeline,
|
||||||
allowed_langs: user.allowed_langs,
|
allowed_langs: user.allowed_langs,
|
||||||
blocks: Blocks {
|
blocks: Blocks {
|
||||||
|
|
|
@ -15,9 +15,8 @@
|
||||||
//! Because `StreamManagers` are lightweight data structures that do not directly
|
//! Because `StreamManagers` are lightweight data structures that do not directly
|
||||||
//! communicate with Redis, it we create a new `ClientAgent` for
|
//! communicate with Redis, it we create a new `ClientAgent` for
|
||||||
//! each new client connection (each in its own thread).use super::{message::Message, receiver::Receiver}
|
//! each new client connection (each in its own thread).use super::{message::Message, receiver::Receiver}
|
||||||
use super::{receiver::Receiver, redis::RedisConnErr};
|
use super::receiver::{Receiver, ReceiverErr};
|
||||||
use crate::{
|
use crate::{
|
||||||
config,
|
|
||||||
messages::Event,
|
messages::Event,
|
||||||
parse_client_request::{Stream::Public, Subscription, Timeline},
|
parse_client_request::{Stream::Public, Subscription, Timeline},
|
||||||
};
|
};
|
||||||
|
@ -25,33 +24,20 @@ use futures::{
|
||||||
Async::{self, NotReady, Ready},
|
Async::{self, NotReady, Ready},
|
||||||
Poll,
|
Poll,
|
||||||
};
|
};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex, MutexGuard};
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// Struct for managing all Redis streams.
|
/// Struct for managing all Redis streams.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct ClientAgent {
|
pub struct ClientAgent {
|
||||||
receiver: Arc<Mutex<Receiver>>,
|
receiver: Arc<Mutex<Receiver>>,
|
||||||
id: Uuid,
|
|
||||||
pub subscription: Subscription,
|
pub subscription: Subscription,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClientAgent {
|
impl ClientAgent {
|
||||||
/// Create a new `ClientAgent` with no shared data.
|
pub fn new(receiver: Arc<Mutex<Receiver>>, subscription: &Subscription) -> Self {
|
||||||
pub fn blank(redis_cfg: config::RedisConfig) -> Self {
|
|
||||||
ClientAgent {
|
ClientAgent {
|
||||||
receiver: Arc::new(Mutex::new(Receiver::new(redis_cfg))),
|
receiver,
|
||||||
id: Uuid::default(),
|
subscription: subscription.clone(),
|
||||||
subscription: Subscription::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clones the `ClientAgent`, sharing the `Receiver`.
|
|
||||||
pub fn clone_with_shared_receiver(&self) -> Self {
|
|
||||||
Self {
|
|
||||||
receiver: self.receiver.clone(),
|
|
||||||
id: self.id,
|
|
||||||
subscription: self.subscription.clone(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -63,25 +49,32 @@ impl ClientAgent {
|
||||||
/// a different user, the `Receiver` is responsible for figuring
|
/// a different user, the `Receiver` is responsible for figuring
|
||||||
/// that out and avoiding duplicated connections. Thus, it is safe to
|
/// that out and avoiding duplicated connections. Thus, it is safe to
|
||||||
/// use this method for each new client connection.
|
/// use this method for each new client connection.
|
||||||
pub fn init_for_user(&mut self, subscription: Subscription) {
|
pub fn subscribe(&mut self) {
|
||||||
use std::time::Instant;
|
let mut receiver = self.lock_receiver();
|
||||||
self.id = Uuid::new_v4();
|
receiver
|
||||||
self.subscription = subscription;
|
.add_subscription(&self.subscription)
|
||||||
let start_time = Instant::now();
|
.unwrap_or_else(|e| log::error!("Could not subscribe to the Redis channel: {}", e))
|
||||||
let mut receiver = self.receiver.lock().expect("No thread panic (stream.rs)");
|
}
|
||||||
receiver.manage_new_timeline(
|
|
||||||
self.id,
|
fn lock_receiver(&self) -> MutexGuard<Receiver> {
|
||||||
self.subscription.timeline,
|
match self.receiver.lock() {
|
||||||
self.subscription.hashtag_name.clone(),
|
Ok(inner) => inner,
|
||||||
);
|
Err(e) => {
|
||||||
log::info!("init_for_user had lock for: {:?}", start_time.elapsed());
|
log::error!(
|
||||||
|
"Another thread crashed: {}\n
|
||||||
|
Attempting to continue, possibly with invalid data",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
e.into_inner()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The stream that the `ClientAgent` manages. `Poll` is the only method implemented.
|
/// The stream that the `ClientAgent` manages. `Poll` is the only method implemented.
|
||||||
impl futures::stream::Stream for ClientAgent {
|
impl futures::stream::Stream for ClientAgent {
|
||||||
type Item = Event;
|
type Item = Event;
|
||||||
type Error = RedisConnErr;
|
type Error = ReceiverErr;
|
||||||
|
|
||||||
/// Checks for any new messages that should be sent to the client.
|
/// Checks for any new messages that should be sent to the client.
|
||||||
///
|
///
|
||||||
|
@ -93,11 +86,8 @@ impl futures::stream::Stream for ClientAgent {
|
||||||
/// errors from the underlying data structures.
|
/// errors from the underlying data structures.
|
||||||
fn poll(&mut self) -> Poll<Option<Self::Item>, Self::Error> {
|
fn poll(&mut self) -> Poll<Option<Self::Item>, Self::Error> {
|
||||||
let result = {
|
let result = {
|
||||||
let mut receiver = self
|
let mut receiver = self.lock_receiver();
|
||||||
.receiver
|
receiver.poll_for(self.subscription.id, self.subscription.timeline)
|
||||||
.lock()
|
|
||||||
.expect("ClientAgent: No other thread panic");
|
|
||||||
receiver.poll_for(self.id, self.subscription.timeline)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let allowed_langs = &self.subscription.allowed_langs;
|
let allowed_langs = &self.subscription.allowed_langs;
|
||||||
|
@ -131,7 +121,7 @@ impl futures::stream::Stream for ClientAgent {
|
||||||
},
|
},
|
||||||
Ok(Ready(None)) => Ok(Ready(None)),
|
Ok(Ready(None)) => Ok(Ready(None)),
|
||||||
Ok(NotReady) => Ok(NotReady),
|
Ok(NotReady) => Ok(NotReady),
|
||||||
Err(_e) => todo!("Handle err gracefully"),
|
Err(e) => Err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,6 @@ use warp::{
|
||||||
sse::Sse,
|
sse::Sse,
|
||||||
ws::{Message, WebSocket},
|
ws::{Message, WebSocket},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct EventStream;
|
pub struct EventStream;
|
||||||
|
|
||||||
impl EventStream {
|
impl EventStream {
|
||||||
|
@ -32,7 +31,7 @@ impl EventStream {
|
||||||
.map(|_r| ())
|
.map(|_r| ())
|
||||||
.map_err(|e| match e.to_string().as_ref() {
|
.map_err(|e| match e.to_string().as_ref() {
|
||||||
"IO error: Broken pipe (os error 32)" => (), // just closed unix socket
|
"IO error: Broken pipe (os error 32)" => (), // just closed unix socket
|
||||||
_ => log::warn!("websocket send error: {}", e),
|
_ => log::warn!("WebSocket send error: {}", e),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -42,7 +41,6 @@ impl EventStream {
|
||||||
match ws_rx.poll() {
|
match ws_rx.poll() {
|
||||||
Ok(Async::NotReady) | Ok(Async::Ready(Some(_))) => futures::future::ok(true),
|
Ok(Async::NotReady) | Ok(Async::Ready(Some(_))) => futures::future::ok(true),
|
||||||
Ok(Async::Ready(None)) => {
|
Ok(Async::Ready(None)) => {
|
||||||
// TODO: consider whether we should manually drop closed connections here
|
|
||||||
log::info!("Client closed WebSocket connection for {:?}", timeline);
|
log::info!("Client closed WebSocket connection for {:?}", timeline);
|
||||||
futures::future::ok(false)
|
futures::future::ok(false)
|
||||||
}
|
}
|
||||||
|
@ -58,27 +56,35 @@ impl EventStream {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut time = Instant::now();
|
let mut last_ping_time = Instant::now();
|
||||||
// Every time you get an event from that stream, send it through the pipe
|
// Every time you get an event from that stream, send it through the pipe
|
||||||
event_stream
|
event_stream
|
||||||
.for_each(move |_instant| {
|
.for_each(move |_instant| {
|
||||||
if let Ok(Async::Ready(Some(msg))) = client_agent.poll() {
|
match client_agent.poll() {
|
||||||
tx.unbounded_send(Message::text(msg.to_json_string()))
|
Ok(Async::Ready(Some(msg))) => tx
|
||||||
.expect("No send error");
|
.unbounded_send(Message::text(msg.to_json_string()))
|
||||||
};
|
.unwrap_or_else(|e| {
|
||||||
if time.elapsed() > Duration::from_secs(30) {
|
log::error!("Could not send message to WebSocket: {}", e)
|
||||||
tx.unbounded_send(Message::text("{}")).expect("Can ping");
|
}),
|
||||||
time = Instant::now();
|
Ok(Async::Ready(None)) => log::info!("WebSocket ClientAgent got Ready(None)"),
|
||||||
|
Ok(Async::NotReady) if last_ping_time.elapsed() > Duration::from_secs(30) => {
|
||||||
|
tx.unbounded_send(Message::text("{}")).unwrap_or_else(|e| {
|
||||||
|
log::error!("Could not send ping to WebSocket: {}", e)
|
||||||
|
});
|
||||||
|
last_ping_time = Instant::now();
|
||||||
|
}
|
||||||
|
Ok(Async::NotReady) => (), // no new messages; nothing to do
|
||||||
|
Err(e) => log::error!("{}\n Dropping WebSocket message and continuing.", e),
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.then(move |result| {
|
.then(move |result| {
|
||||||
// TODO: consider whether we should manually drop closed connections here
|
|
||||||
log::info!("WebSocket connection for {:?} closed.", timeline);
|
log::info!("WebSocket connection for {:?} closed.", timeline);
|
||||||
result
|
result
|
||||||
})
|
})
|
||||||
.map_err(move |e| log::warn!("Error sending to {:?}: {}", timeline, e))
|
.map_err(move |e| log::warn!("Error sending to {:?}: {}", timeline, e))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_sse(mut client_agent: ClientAgent, sse: Sse, interval: Duration) -> impl Reply {
|
pub fn to_sse(mut client_agent: ClientAgent, sse: Sse, interval: Duration) -> impl Reply {
|
||||||
let event_stream =
|
let event_stream =
|
||||||
tokio::timer::Interval::new(Instant::now(), interval).filter_map(move |_| {
|
tokio::timer::Interval::new(Instant::now(), interval).filter_map(move |_| {
|
||||||
|
@ -87,7 +93,15 @@ impl EventStream {
|
||||||
warp::sse::event(event.event_name()),
|
warp::sse::event(event.event_name()),
|
||||||
warp::sse::data(event.payload().unwrap_or_else(String::new)),
|
warp::sse::data(event.payload().unwrap_or_else(String::new)),
|
||||||
)),
|
)),
|
||||||
_ => None,
|
Ok(Async::Ready(None)) => {
|
||||||
|
log::info!("SSE ClientAgent got Ready(None)");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Ok(Async::NotReady) => None,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("{}\n Dropping SSE message and continuing.", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,6 @@ pub use redis::redis_msg;
|
||||||
//#[cfg(test)]
|
//#[cfg(test)]
|
||||||
//pub use receiver::process_messages;
|
//pub use receiver::process_messages;
|
||||||
//#[cfg(test)]
|
//#[cfg(test)]
|
||||||
pub use receiver::{MessageQueues, MsgQueue};
|
pub use receiver::{MessageQueues, MsgQueue, Receiver, ReceiverErr};
|
||||||
//#[cfg(test)]
|
//#[cfg(test)]
|
||||||
//pub use redis::redis_msg::{RedisMsg, RedisUtf8};
|
//pub use redis::redis_msg::{RedisMsg, RedisUtf8};
|
||||||
|
|
|
@ -2,6 +2,7 @@ use super::super::{redis::RedisConnErr, redis_msg::RedisParseErr};
|
||||||
use crate::err::TimelineErr;
|
use crate::err::TimelineErr;
|
||||||
|
|
||||||
use serde_json;
|
use serde_json;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum ReceiverErr {
|
pub enum ReceiverErr {
|
||||||
|
@ -11,6 +12,19 @@ pub enum ReceiverErr {
|
||||||
RedisConnErr(RedisConnErr),
|
RedisConnErr(RedisConnErr),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ReceiverErr {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
|
||||||
|
use ReceiverErr::*;
|
||||||
|
match self {
|
||||||
|
EventErr(inner) => write!(f, "{}", inner),
|
||||||
|
RedisParseErr(inner) => write!(f, "{}", inner),
|
||||||
|
RedisConnErr(inner) => write!(f, "{}", inner),
|
||||||
|
TimelineErr(inner) => write!(f, "{}", inner),
|
||||||
|
}?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<serde_json::Error> for ReceiverErr {
|
impl From<serde_json::Error> for ReceiverErr {
|
||||||
fn from(error: serde_json::Error) -> Self {
|
fn from(error: serde_json::Error) -> Self {
|
||||||
Self::EventErr(error)
|
Self::EventErr(error)
|
||||||
|
|
|
@ -12,37 +12,42 @@ use super::redis::{redis_connection::RedisCmd, RedisConn};
|
||||||
use crate::{
|
use crate::{
|
||||||
config,
|
config,
|
||||||
messages::Event,
|
messages::Event,
|
||||||
parse_client_request::{Stream, Timeline},
|
parse_client_request::{Stream, Subscription, Timeline},
|
||||||
};
|
};
|
||||||
|
|
||||||
use futures::{Async, Poll};
|
use futures::{Async, Poll};
|
||||||
use std::collections::HashMap;
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
result,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
type Result<T> = result::Result<T, ReceiverErr>;
|
||||||
|
|
||||||
/// The item that streams from Redis and is polled by the `ClientAgent`
|
/// The item that streams from Redis and is polled by the `ClientAgent`
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Receiver {
|
pub struct Receiver {
|
||||||
redis_connection: RedisConn,
|
redis_connection: RedisConn,
|
||||||
pub msg_queues: MessageQueues,
|
pub msg_queues: MessageQueues,
|
||||||
clients_per_timeline: HashMap<Timeline, i32>,
|
clients_per_timeline: HashMap<Timeline, i32>,
|
||||||
// hashtag_cache: LruCache<i64, String>,
|
|
||||||
// TODO: eventually, it might make sense to have Mastodon publish to timelines with
|
|
||||||
// the tag number instead of the tag name. This would save us from dealing
|
|
||||||
// with a cache here and would be consistent with how lists/users are handled.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Receiver {
|
impl Receiver {
|
||||||
/// Create a new `Receiver`, with its own Redis connections (but, as yet, no
|
/// Create a new `Receiver`, with its own Redis connections (but, as yet, no
|
||||||
/// active subscriptions).
|
/// active subscriptions).
|
||||||
pub fn new(redis_cfg: config::RedisConfig) -> Self {
|
pub fn try_from(redis_cfg: config::RedisConfig) -> Result<Self> {
|
||||||
let redis_connection = RedisConn::new(redis_cfg).expect("TODO");
|
let redis_connection = RedisConn::new(redis_cfg)?;
|
||||||
|
|
||||||
Self {
|
Ok(Self {
|
||||||
redis_connection,
|
redis_connection,
|
||||||
msg_queues: MessageQueues(HashMap::new()),
|
msg_queues: MessageQueues(HashMap::new()),
|
||||||
clients_per_timeline: HashMap::new(),
|
clients_per_timeline: HashMap::new(),
|
||||||
// should this be a run-time option?
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn into_arc(self) -> Arc<Mutex<Self>> {
|
||||||
|
Arc::new(Mutex::new(self))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assigns the `Receiver` a new timeline to monitor and runs other
|
/// Assigns the `Receiver` a new timeline to monitor and runs other
|
||||||
|
@ -51,13 +56,15 @@ impl Receiver {
|
||||||
/// Note: this method calls `subscribe_or_unsubscribe_as_needed`,
|
/// Note: this method calls `subscribe_or_unsubscribe_as_needed`,
|
||||||
/// so Redis PubSub subscriptions are only updated when a new timeline
|
/// so Redis PubSub subscriptions are only updated when a new timeline
|
||||||
/// comes under management for the first time.
|
/// comes under management for the first time.
|
||||||
pub fn manage_new_timeline(&mut self, id: Uuid, tl: Timeline, hashtag: Option<String>) {
|
pub fn add_subscription(&mut self, subscription: &Subscription) -> Result<()> {
|
||||||
if let (Some(hashtag), Timeline(Stream::Hashtag(id), _, _)) = (hashtag, tl) {
|
let (tag, tl) = (subscription.hashtag_name.clone(), subscription.timeline);
|
||||||
|
|
||||||
|
if let (Some(hashtag), Timeline(Stream::Hashtag(id), _, _)) = (tag, tl) {
|
||||||
self.redis_connection.update_cache(hashtag, id);
|
self.redis_connection.update_cache(hashtag, id);
|
||||||
};
|
};
|
||||||
|
self.msg_queues.insert(subscription.id, MsgQueue::new(tl));
|
||||||
self.msg_queues.insert(id, MsgQueue::new(tl));
|
self.subscribe_or_unsubscribe_as_needed(tl)?;
|
||||||
self.subscribe_or_unsubscribe_as_needed(tl);
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the oldest message in the `ClientAgent`'s queue (if any).
|
/// Returns the oldest message in the `ClientAgent`'s queue (if any).
|
||||||
|
@ -102,8 +109,8 @@ impl Receiver {
|
||||||
/// Drop any PubSub subscriptions that don't have active clients and check
|
/// Drop any PubSub subscriptions that don't have active clients and check
|
||||||
/// that there's a subscription to the current one. If there isn't, then
|
/// that there's a subscription to the current one. If there isn't, then
|
||||||
/// subscribe to it.
|
/// subscribe to it.
|
||||||
fn subscribe_or_unsubscribe_as_needed(&mut self, timeline: Timeline) {
|
fn subscribe_or_unsubscribe_as_needed(&mut self, tl: Timeline) -> Result<()> {
|
||||||
let timelines_to_modify = self.msg_queues.calculate_timelines_to_add_or_drop(timeline);
|
let timelines_to_modify = self.msg_queues.calculate_timelines_to_add_or_drop(tl);
|
||||||
|
|
||||||
// Record the lower number of clients subscribed to that channel
|
// Record the lower number of clients subscribed to that channel
|
||||||
for change in timelines_to_modify {
|
for change in timelines_to_modify {
|
||||||
|
@ -118,14 +125,11 @@ impl Receiver {
|
||||||
// If no clients, unsubscribe from the channel
|
// If no clients, unsubscribe from the channel
|
||||||
use RedisCmd::*;
|
use RedisCmd::*;
|
||||||
if *count_of_subscribed_clients <= 0 {
|
if *count_of_subscribed_clients <= 0 {
|
||||||
self.redis_connection
|
self.redis_connection.send_cmd(Unsubscribe, &timeline)?;
|
||||||
.send_cmd(Unsubscribe, &timeline)
|
|
||||||
.expect("TODO");
|
|
||||||
} else if *count_of_subscribed_clients == 1 && change.in_subscriber_number == 1 {
|
} else if *count_of_subscribed_clients == 1 && change.in_subscriber_number == 1 {
|
||||||
self.redis_connection
|
self.redis_connection.send_cmd(Subscribe, &timeline)?
|
||||||
.send_cmd(Subscribe, &timeline)
|
|
||||||
.expect("TODO");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
pub mod redis_cmd;
|
|
||||||
pub mod redis_connection;
|
pub mod redis_connection;
|
||||||
pub mod redis_msg;
|
pub mod redis_msg;
|
||||||
|
|
||||||
|
|
|
@ -3,8 +3,8 @@ use std::fmt;
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum RedisConnErr {
|
pub enum RedisConnErr {
|
||||||
ConnectionErr { addr: String, inner: std::io::Error },
|
ConnectionErr { addr: String, inner: std::io::Error },
|
||||||
// TODO ^^^^ better name?
|
InvalidRedisReply(String),
|
||||||
UnknownRedisErr(String),
|
UnknownRedisErr(std::io::Error),
|
||||||
IncorrectPassword(String),
|
IncorrectPassword(String),
|
||||||
MissingPassword,
|
MissingPassword,
|
||||||
NotRedis(String),
|
NotRedis(String),
|
||||||
|
@ -28,10 +28,13 @@ impl fmt::Display for RedisConnErr {
|
||||||
Connection Error: {}",
|
Connection Error: {}",
|
||||||
addr, inner
|
addr, inner
|
||||||
),
|
),
|
||||||
UnknownRedisErr(unexpected_reply) => format!(
|
InvalidRedisReply(unexpected_reply) => format!(
|
||||||
"Could not connect to Redis for an unknown reason. Expected `+PONG` reply but got `{}`",
|
"Received and unexpected reply from Redis. Expected `+PONG` reply but got `{}`",
|
||||||
unexpected_reply
|
unexpected_reply
|
||||||
),
|
),
|
||||||
|
UnknownRedisErr(io_err) => {
|
||||||
|
format!("Unexpected failure communicating with Redis: {}", io_err)
|
||||||
|
}
|
||||||
IncorrectPassword(attempted_password) => format!(
|
IncorrectPassword(attempted_password) => format!(
|
||||||
"Incorrect Redis password. You supplied `{}`.\n \
|
"Incorrect Redis password. You supplied `{}`.\n \
|
||||||
Please supply correct password with REDIS_PASSWORD environmental variable.",
|
Please supply correct password with REDIS_PASSWORD environmental variable.",
|
||||||
|
@ -51,48 +54,8 @@ impl fmt::Display for RedisConnErr {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// die_with_msg(format!(
|
impl From<std::io::Error> for RedisConnErr {
|
||||||
// r"Incorrect Redis password. You supplied `{}`.
|
fn from(e: std::io::Error) -> RedisConnErr {
|
||||||
// Please supply correct password with REDIS_PASSWORD environmental variable.",
|
RedisConnErr::UnknownRedisErr(e)
|
||||||
// password,
|
}
|
||||||
// ))
|
}
|
||||||
|
|
||||||
// impl fmt::Display for RedisParseErr {
|
|
||||||
// fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
|
|
||||||
// use RedisParseErr::*;
|
|
||||||
// let msg = match self {
|
|
||||||
// Incomplete => "The input from Redis does not form a complete message, likely because \
|
|
||||||
// the input buffer filled partway through a message. Save this input \
|
|
||||||
// and try again with additional input from Redis."
|
|
||||||
// .to_string(),
|
|
||||||
// InvalidNumber(parse_int_err) => format!(
|
|
||||||
// "Redis indicated that an item would be a number, but it could not be parsed: {}",
|
|
||||||
// parse_int_err
|
|
||||||
// ),
|
|
||||||
|
|
||||||
// InvalidLineStart(line_start_char) => format!(
|
|
||||||
// "A line from Redis started with `{}`, which is not a valid character to indicate \
|
|
||||||
// the type of the Redis line.",
|
|
||||||
// line_start_char
|
|
||||||
// ),
|
|
||||||
// InvalidLineEnd => "A Redis line ended before expected line length".to_string(),
|
|
||||||
// IncorrectRedisType => "Received a Redis type that is not supported in this context. \
|
|
||||||
// Flodgatt expects each message from Redis to be a Redis array \
|
|
||||||
// consisting of bulk strings or integers."
|
|
||||||
// .to_string(),
|
|
||||||
// MissingField => "Redis input was missing a field Flodgatt expected (e.g., a `message` \
|
|
||||||
// without a payload line)"
|
|
||||||
// .to_string(),
|
|
||||||
// UnsupportedTimeline => {
|
|
||||||
// "The raw timeline received from Redis could not be parsed into a \
|
|
||||||
// supported timeline"
|
|
||||||
// .to_string()
|
|
||||||
// }
|
|
||||||
// UnsupportedEvent(e) => format!(
|
|
||||||
// "The event text from Redis could not be parsed into a valid event: {}",
|
|
||||||
// e
|
|
||||||
// ),
|
|
||||||
// };
|
|
||||||
// write!(f, "{}", msg)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
|
@ -20,6 +20,8 @@ use std::{
|
||||||
use futures::{Async, Poll};
|
use futures::{Async, Poll};
|
||||||
use lru::LruCache;
|
use lru::LruCache;
|
||||||
|
|
||||||
|
type Result<T> = std::result::Result<T, RedisConnErr>;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct RedisConn {
|
pub struct RedisConn {
|
||||||
primary: TcpStream,
|
primary: TcpStream,
|
||||||
|
@ -33,32 +35,38 @@ pub struct RedisConn {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RedisConn {
|
impl RedisConn {
|
||||||
pub fn new(redis_cfg: RedisConfig) -> Result<Self, RedisConnErr> {
|
pub fn new(redis_cfg: RedisConfig) -> Result<Self> {
|
||||||
let addr = format!("{}:{}", *redis_cfg.host, *redis_cfg.port);
|
let addr = format!("{}:{}", *redis_cfg.host, *redis_cfg.port);
|
||||||
let conn = Self::new_connection(&addr, &redis_cfg.password.as_ref())?;
|
let conn = Self::new_connection(&addr, &redis_cfg.password.as_ref())?;
|
||||||
conn.set_nonblocking(true)
|
conn.set_nonblocking(true)
|
||||||
.map_err(|e| RedisConnErr::with_addr(&addr, e))?;
|
.map_err(|e| RedisConnErr::with_addr(&addr, e))?;
|
||||||
|
let redis_conn = Self {
|
||||||
Ok(Self {
|
|
||||||
primary: conn,
|
primary: conn,
|
||||||
secondary: Self::new_connection(&addr, &redis_cfg.password.as_ref())?,
|
secondary: Self::new_connection(&addr, &redis_cfg.password.as_ref())?,
|
||||||
tag_id_cache: LruCache::new(1000),
|
tag_id_cache: LruCache::new(1000),
|
||||||
tag_name_cache: LruCache::new(1000),
|
tag_name_cache: LruCache::new(1000),
|
||||||
|
// TODO: eventually, it might make sense to have Mastodon publish to timelines with
|
||||||
|
// the tag number instead of the tag name. This would save us from dealing
|
||||||
|
// with a cache here and would be consistent with how lists/users are handled.
|
||||||
redis_namespace: redis_cfg.namespace.clone(),
|
redis_namespace: redis_cfg.namespace.clone(),
|
||||||
redis_poll_interval: *redis_cfg.polling_interval,
|
redis_poll_interval: *redis_cfg.polling_interval,
|
||||||
redis_input: Vec::new(),
|
redis_input: Vec::new(),
|
||||||
redis_polled_at: Instant::now(),
|
redis_polled_at: Instant::now(),
|
||||||
})
|
};
|
||||||
|
|
||||||
|
Ok(redis_conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn poll_redis(&mut self) -> Poll<Option<(Timeline, Event)>, ReceiverErr> {
|
pub fn poll_redis(&mut self) -> Poll<Option<(Timeline, Event)>, ReceiverErr> {
|
||||||
let mut buffer = vec![0u8; 6000];
|
let mut buffer = vec![0u8; 6000];
|
||||||
if self.redis_polled_at.elapsed() > self.redis_poll_interval {
|
if self.redis_polled_at.elapsed() > self.redis_poll_interval {
|
||||||
match self.primary.read(&mut buffer) {
|
if let Ok(bytes_read) = self.primary.read(&mut buffer) {
|
||||||
Ok(bytes_read) => self.redis_input.extend_from_slice(&buffer[..bytes_read]),
|
self.redis_input.extend_from_slice(&buffer[..bytes_read]);
|
||||||
Err(e) => log::error!("Error polling Redis: {}\nRetrying...", e),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if self.redis_input.is_empty() {
|
||||||
|
return Ok(Async::NotReady);
|
||||||
|
}
|
||||||
let input = self.redis_input.clone();
|
let input = self.redis_input.clone();
|
||||||
self.redis_input.clear();
|
self.redis_input.clear();
|
||||||
|
|
||||||
|
@ -100,7 +108,7 @@ impl RedisConn {
|
||||||
self.tag_name_cache.put(id, hashtag);
|
self.tag_name_cache.put(id, hashtag);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new_connection(addr: &String, pass: &Option<&String>) -> Result<TcpStream, RedisConnErr> {
|
fn new_connection(addr: &String, pass: &Option<&String>) -> Result<TcpStream> {
|
||||||
match TcpStream::connect(&addr) {
|
match TcpStream::connect(&addr) {
|
||||||
Ok(mut conn) => {
|
Ok(mut conn) => {
|
||||||
if let Some(password) = pass {
|
if let Some(password) = pass {
|
||||||
|
@ -115,7 +123,7 @@ impl RedisConn {
|
||||||
Err(e) => Err(RedisConnErr::with_addr(&addr, e)),
|
Err(e) => Err(RedisConnErr::with_addr(&addr, e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn auth_connection(conn: &mut TcpStream, addr: &str, pass: &str) -> Result<(), RedisConnErr> {
|
fn auth_connection(conn: &mut TcpStream, addr: &str, pass: &str) -> Result<()> {
|
||||||
conn.write_all(&format!("*2\r\n$4\r\nauth\r\n${}\r\n{}\r\n", pass.len(), pass).as_bytes())
|
conn.write_all(&format!("*2\r\n$4\r\nauth\r\n${}\r\n{}\r\n", pass.len(), pass).as_bytes())
|
||||||
.map_err(|e| RedisConnErr::with_addr(&addr, e))?;
|
.map_err(|e| RedisConnErr::with_addr(&addr, e))?;
|
||||||
let mut buffer = vec![0u8; 5];
|
let mut buffer = vec![0u8; 5];
|
||||||
|
@ -129,7 +137,7 @@ impl RedisConn {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_connection(conn: &mut TcpStream, addr: &str) -> Result<(), RedisConnErr> {
|
fn validate_connection(conn: &mut TcpStream, addr: &str) -> Result<()> {
|
||||||
conn.write_all(b"PING\r\n")
|
conn.write_all(b"PING\r\n")
|
||||||
.map_err(|e| RedisConnErr::with_addr(&addr, e))?;
|
.map_err(|e| RedisConnErr::with_addr(&addr, e))?;
|
||||||
let mut buffer = vec![0u8; 7];
|
let mut buffer = vec![0u8; 7];
|
||||||
|
@ -140,11 +148,11 @@ impl RedisConn {
|
||||||
"+PONG\r\n" => Ok(()),
|
"+PONG\r\n" => Ok(()),
|
||||||
"-NOAUTH" => Err(RedisConnErr::MissingPassword),
|
"-NOAUTH" => Err(RedisConnErr::MissingPassword),
|
||||||
"HTTP/1." => Err(RedisConnErr::NotRedis(addr.to_string())),
|
"HTTP/1." => Err(RedisConnErr::NotRedis(addr.to_string())),
|
||||||
_ => Err(RedisConnErr::UnknownRedisErr(reply.to_string())),
|
_ => Err(RedisConnErr::InvalidRedisReply(reply.to_string())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn send_cmd(&mut self, cmd: RedisCmd, timeline: &Timeline) -> Result<(), RedisConnErr> {
|
pub fn send_cmd(&mut self, cmd: RedisCmd, timeline: &Timeline) -> Result<()> {
|
||||||
let hashtag = match timeline {
|
let hashtag = match timeline {
|
||||||
Timeline(Stream::Hashtag(id), _, _) => self.tag_name_cache.get(id),
|
Timeline(Stream::Hashtag(id), _, _) => self.tag_name_cache.get(id),
|
||||||
_non_hashtag_timeline => None,
|
_non_hashtag_timeline => None,
|
||||||
|
@ -161,12 +169,8 @@ impl RedisConn {
|
||||||
format!("*3\r\n$3\r\nSET\r\n${}\r\n{}\r\n$1\r\n0\r\n", tl.len(), tl),
|
format!("*3\r\n$3\r\nSET\r\n${}\r\n{}\r\n$1\r\n0\r\n", tl.len(), tl),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
self.secondary
|
self.primary.write_all(&primary_cmd.as_bytes())?;
|
||||||
.write_all(&primary_cmd.as_bytes())
|
self.secondary.write_all(&secondary_cmd.as_bytes())?;
|
||||||
.expect("TODO");
|
|
||||||
self.secondary
|
|
||||||
.write_all(&secondary_cmd.as_bytes())
|
|
||||||
.expect("TODO");
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue