The Ugly Org - una DB para mi amigo George Hotz y comma.ai en Rust

D

The Ugly Org - una DB para mi amigo George Hotz y comma.ai en Rust

Me encontraba en otra zona horaria, o más bien, reventado del jet lag y con menos lucidez que un político aprobando presupuestos, cuando coincidí con Jorgito Caliente por Discord. Resumiendo la conversación: biología y futurismo, Kanye, computadores biológicos que parecen diseñados en un mal trip de LSD, aliens y formas de conciencia no convencionales... formas de vida no basadas en carbono, silicio... origen de la vida... Kendrick Lamar vs Drake, y si podríamos construir una IA basada en las decisiones financieras de una Kardashian durante el Black Friday y batir el mercado…

El cabrón me soltó la chapa sobre los bounties de su nueva tiny corp, como si lo único que me faltara en la vida fuera otro trabajo mal pagado. Con ofertas que, entre nosotros, ni puedo ni quiero hacer. No es que falte talento, es que sobran ganas de no trabajar. Me llama fpero. Así que le suelto la chapa con mis proyectitos de The Ugly Org. Le ofrecí migrarle una base de datos de comma.ai a Rust, lo típico que haces cuando quieres parecer útil pero en realidad solo buscas facturar o justificar una promoción. Y de paso, arreglar algunos bugs de sistemas distribuidos que metió. Le llamo fpero.

Me respondió que antes se mete un dildo de dragón tamaño industrial por el culo que usar Rust. No sé si preocuparme más por su fetiche con dragones o porque seguramente tenga la colección completa en casa. También soltó unos términos que no voy a reproducir por respeto a la comunidad Rust]. Os los imagináis. Obviamente, toda esta conversación es mentira y ha ocurrido solo en mi mente. Si fuera real, estaríamos presos. Ni él ni yo somos tan idiotas, al menos no cuando hay testigos. O lo somos y ya no nos importa.

El objetivo de este diario es:

  • Follarse su código de mierda.
  • Imprimir el código y hacer un rollo de papel.
  • Pillar un vuelo a San Diego y metérselo por el culo.
  • Pasarse a saludar a Elon, Siriam, Calacanis, Sacks... y darles de ostias con el rollo (side quest).

El Lunes empezamos.

edit:

Terminado, ganamos 3-1:

  • eficiencia y performance +1 desu codigo 35% mas rapido y eficiente, mejor tail
  • bugs y sistema distribuido +1 desu 3-4 bugs arreglados
  • heavy write scenario: Golang runtime vs Tokio runtime, mucho IO +1 Jorgito Caliente
  • heavy read scenario: real world +1 desu, su codigo peta el mio escala hasta 30k req/s

no hemos hecho todos los features porque 1) no había test 2) no había benchmarks para probarlos.

por desgracia no podemos ganar 4-0!

hasta el proximo proyecto!

15
HeXaN

Un gusto leerte. Estaré atento al lunes.

D

Voy a analizar un tema importante antes de empezar.

1) 1000 LOC y un sistema distribuido de baja calidad:
Primero, la base de datos que vamos a migrar tiene menos de 1000 líneas de código en Go. Cuando lo portemos a Rust no pretendo mantener esta restricción, ya que Rust no es un ecosistema y lenguaje cohesionado a una estándar stdlib, sino que dependes de crates (librerías) independientes.

Creo que es interesante analizar el trabajo de Jorgito en este proyecto, porque es uno de los mejores ingenieros del planeta. En este post, comento los bugs en el sistema distribuido, las decisiones que tomó, y por qué Jorgito y yo somos 10 veces mejores de lo que tú serás jamás. Aunque, si lees mis posts, tal vez en un buen día no seas un fpero y te conviertas en un contribuidor neutro 0 (que ni suma ni resta). Cuando normalmente eres un -1.

2) Sistema distribuido: problemas y por qué no pasa nada
El proyecto que vamos a realizar tiene un master que escribe y volúmenes de lectura. El problema, desde el punto de vista distribuido, es que sin el master, no funciona nada.

En el pasado, ya he hecho leveldb distribuidos con Raft:
https://github.com/vrnvu/distributed-leveldb

3) Y no pasa nada, porque sabíamos lo que hacíamos
Explico esto porque quiero destacar la belleza y pragmatismo de Jorgito en este proyecto. Os creéis que Jorgito no sabe que su código tiene bugs? Claro que lo sabe. Pero se puso un límite de 1000 líneas de código, y ha tomado las decisiones adecuadas, justas y medidas al milímetro para que todo el sistema funcione a la perfección dentro de esta restricción.

Mucha gente está en contra de hacer soluciones in-house como esta.

Si sabes lo que haces, en este caso tener un master que se ejecuta en modo SERVER, REBUILD y REBALANCE, y una arquitectura master/slaves (no réplicas! porque un slave nunca será master), y entiendes la carga operacional de estas operaciones, puedes tomar la mejor decisión. Y en este caso, seguramente esto funciona mejor que tener una base de datos grande con mil features que no necesitas, que cuesta mantener y que te ocasiona bugs.

4) Conclusion

Existe una gran diferencia entre:

  • Saber hacer A, B, C y decidir C
  • Saber A y decidir B
D

Este sistema distribuido es una mierda parte 2!

Imaginaros que hacéis esta operación PUT para añadir un record a nuestra base de datos:

curl -v -L -X PUT -d bigswag localhost:3000/wehave

Imaginaros que tenemos 1 master, 3 replicas.

Imaginaros que al realizar esta operación todo va bien pero 1 replica falla, y si mirais el codigo, veréis que no se hace ningún tipo de rollback, o existe un concepto de transaccion... Asi que el PUT me dará un error aunque haya lecturas parciales...

Y que implica esto? Que cuando haga el GET, aunque haya lecturas parciales:

curl -v -L localhost:3000/wehave

Me va a dar puto error... Y que tenemos que hacer? Pues volver a hacer un PUT:

curl -v -L -X PUT -d bigswag localhost:3000/wehave

Y entonces todo se habra arreglado. Pero. Que pasa con las escrituras parciales que han fallado? En este caso, el diseño de esta database, si no me fallan los ojos a un primer vistazo, no habrá ningún problema. Se sobre-escribira todo. Pero existen otras db muy habituales, que sin transaccion ni rollbacks, esto se va a quedar colgando.

Puedes elegir entre:

  • Hacer un sistema transaccional, ACID, y mil palabras técnicas
  • Tener un cron job que cada día te borre los elementos corruptos (si existen, porque por diseño pueden NO existir)

No se cuanta experiencia tenéis en el mundo real, y en db que tienen millones de escrituras y lecturas. Yo he trabajado con algunas con trafico infernal, y os aseguro que correr un cron job son menos de 10 minutos y tiene un coste de un par de euros al dia. Tener una DB distribuida perfecta que te haga masajes en camilla, aun no se ha inventado, sin entrar en los cientos de ingenieros que están en nomina y los costes de infraestructura.

Conclusion:

Un fpero te dirá que hay que hacer todo transaccional y perfecto. Un ingeniero comprende que esto no existe y que todo tiene un coste. En este caso el tener downtime 1 vez al año durante 5-10 minutos es menor al que te esta metiendo el fpero.

Conclusion 2:

Esta es una thumb rule que os voy a regalar. Puedes elegir entre:

  • devolver error al usuario
  • gestionar el error internamente para NO dar error al usuario

Empieza siempre devolviendo el error al usuario y que el usuario lo intente de nuevo. Tratar de gestionar los errores, es el principio de todo mal en ingeniería. Si devuelves un error al usuario y tu diseño es correcto, nunca tendrás estados inconsistentes ni bugs!

Si vais a mi diario de torrent: https://www.mediavida.com/foro/dev/the-ugly-org-bittorrent-toque-712011 fijaros como el 99% de los errores los devuelvo al usuario y todo peta salvo cosas que se que el OS o yo puedo re-intentar de manera segura y fiable.

Partir de este estado consistente y diseño correcto, y trabajar en mejorar la gestión de errores, es siempre mas simple y productivo que tratar de resolver errores antes de tener un diseño correcto y funcional.

1 respuesta
D

Como hacer un fork/port/migración:

Esto es muy simple y fácil.

Pillas los tests y los copias.
https://github.com/vrnvu/rust-minikeyvalue/blob/master/tools/test.py

Pillas scripts de configuración y arranque y los copias.
https://github.com/vrnvu/rust-minikeyvalue/blob/master/tools/bringup.sh
https://github.com/vrnvu/rust-minikeyvalue/blob/master/volume

Pillas la API y la copias. (implícito en los tests de integración y scripts de arranque y configuración).

Implementas los features uno a uno hasta tener el mismo producto. Fin.

Voy a empezar haciendo primero la base de datos distribuida, y después mirare de portar el arranque y re-balanceo. Como hemos comentado atrás son dos procesos separados.

https://github.com/geohot/minikeyvalue/blob/master/src/main.go#L85
https://github.com/geohot/minikeyvalue/blob/master/src/rebalance.go
https://github.com/geohot/minikeyvalue/blob/master/src/rebuild.go

Espero que llegados a este punto quede claro porque son separados y porque no hay problemas.

Tengo el master distribuido con raft, link arriba, y tengo también un rebalanceo en runtime que estuve trabajando hace poco... Así que quizás os lo comparto en un proyecto aparte para que lo veáis.

  • El master distribuido sígnica que si peta master, utilizando un protocolo de consenso como Raft o Paxos, se elegirá entre las replicas un nuevo master.

  • El rebalance en runtime signfica que si hay que mover datos, se puede hacer sin apagar el sistema y afectando lo mínimo posible a los GET y PUT de usuarios.


Creo que esta pequeña explicación del contexto y algunas ideas básicas de desarrollo son suficientes para que mañana ningún chavalin de DAW me pregunte estupideces.

Kaledros
#4desu:

existen otras db muy habituales, que sin transaccion ni rollbacks

¿Qué db habituales funcionan sin transacciones ni rollbacks?

1 respuesta
D

#6 Pregunta resulta y editada.

D

Hoy empiezo y no tengo claro si primero tirar a los volúmenes de lectura o implementar el leveldb y avanzar el master un poco. No importa demasiado. Lo importante es avanzar y escribir código que te permita hacerlo. Da igual si luego hay que tirarlo todo y re-hacerlo. La gente pone demasiado hincapié en tratar de tener algo perfecto en lugar de iterar rápido y que el código sea correcto. Y la gente se cree que su código de mierda importa a alguien o es util. Todo errores. Tu codigo ni importa, ni le importa a nadie salvo a ti.

https://github.com/vrnvu/rust-minikeyvalue/commit/1f89b6d953b58f47d8c6b072e9c3b066937ceb64
Le he metido un hashmap en memoria para comprobar que el server funciona e ir añadiendo algunos status code y respuestas. Y bueno, con esto podría hacer las lecturas a volúmenes o el leveldb, no tengo claro que me da menos pereza. Hare lo contrario que decida.

Y ya un detalle sutil pero importante, que expande en lo que explique el otro dia en la introducción del contexto del desarrollo del diario.

Comente en #4 la importancia de entender el sistema y el problema al 100% antes de tratar de gestionar errores internos. Y en otro diario de desarrollo explique a la gente que un gran ingeniero es el que logra resolverlos de manera eficiente. Creo que el fpero medio se confunde. El fpero medio, tu, piensa que resolver todos los errores que pueda le hace bueno. Y esta mentalidad es equivoca y te llevara a implementaciones incorrectas. Por eso tu código ademas de no importar, y no ser importante, es inútil.

Thumb rule: Siempre hacer un panic en un binario o devolver error en una librería para empezar es la manera de proceder. En el snippet de arriba, que es super simple, gestiono el error de no poder adquirir un lock. Es algo muy trivial que podría re-intentar o incluso podría sacarme la polla de SRE architect senior staff para metertela en al esófago y distribuir las key mediante un algoritmo a los N cores de mi sistema para reducir la contención o podría tener un array de atómicos de buckets y locks mas granulares o como haremos con leveldb un log con lock (jeje) para... blah blah blah. Realizar cualquiera de estas soluciones es un error porque no entiendes como se va a comportar el sistema (1) y si te falla el adquirir un puto lock de mierda seguramente hay un problema mayor (2).

El (1) lo explique el otro día. Este mismo código con un retry, solo que lo pases de master/slave a master/replica, va a ser un bug.

El (2) es la típica solución de fpero que se come los mocos y trabaja con kotlin porque se cree que es mejor que los programadores en Java. El que hace Kotlin no se piensa que Kotlin es mejor que Java, o el que hace Scala. Se creen mejores que los programadores de Java. Tenedlo claro. Tienes una API que hace llamadas a una DB o a algún servicio externo y cuando empieza a fallar la petición le pones retries, timeouts, y demás soluciones típicas que has leído en un blog post de otro fpero que tiene las neuronitas justas para encender el PC y escribir bobadas que te crees porque su habitación tiene LEDs y usa un teclado mecánico de 200 euros.

Si tu sistema esta fallando peticiones lo que tienes que hacer es analizar, sacar datos, debug, mirar logs... Y entender porque (2), para poder pasar a (1) y tomar la decision adecuada.

D

He encontrado un problema en el port de la implementación original del protocolo HTTP. Pero lo he resuelto rápido.

La idea es devolver 411 (LENGTH_REQUIRED) si no hay el header Content-Length. Pero warp, devuelve 400 (BAD_REQUEST).
https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.10
https://github.com/geohot/minikeyvalue/blob/master/src/server.go#L329

En warp tienes varios métodos:
https://docs.rs/warp/latest/warp/filters/header/fn.header.html
https://docs.rs/warp/latest/warp/filters/header/fn.optional.html

He usado el header primero y me devolvía 400 (BAD_REQUEST), no esta mal pero rompería la compatibilidad. Por suerte he visto el otro mientras escribía este post.

flox [default] ➜  ~ curl -v -L -X PUT -d bigswag localhost:3000/bigswag -H "Content-Length:"
* Host localhost:3000 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:3000...
* connect to ::1 port 3000 from ::1 port 52918 failed: Connection refused
*   Trying 127.0.0.1:3000...
* Connected to localhost (127.0.0.1) port 3000
PUT /bigswag HTTP/1.1
Host: localhost:3000
User-Agent: curl/8.7.1
Accept: */*
Content-Type: application/x-www-form-urlencoded

* upload completely sent off: 7 bytes
< HTTP/1.1 411 Length Required
< content-length: 26
< date: Mon, 16 Sep 2024 10:10:24 GMT
< 
* Connection #0 to host localhost left intact
Content-Length is required% 

Por suerte estaba testado, pero es normal dejarse status code y casos en los tests de integración. Recomiendo tirar code coverage siempre para la API porque luego cuando tienes millones de usuarios usándolo cualquier cambio es breaking change y te dan por culo.

Si no tuviese el metodo de warp de optional, lo habria escrito a mano. Pero la verdad no ahora, habria dejado el codigo con un TODO.


Tambien he visto un POST no documentado:
https://github.com/geohot/minikeyvalue/blob/master/src/server.go#L254

Mirando los tests creo que es para la compatibilidad de S3. Que no voy a soportar.

D

Este proyecto tiene muchísimo jugo, aun no he hecho nada y ya he visto varias decisiones técnicas criticas que discutir.

Relacionado también con la pregunta estupida que me hicieron el otro día sobre transaccionalidad...

Como gestionamos las modificaciones de estado en nuestra base de datos? PUT/DELETE/UNLINK...
https://github.com/geohot/minikeyvalue/blob/master/src/main.go#L35C1-L50C1

La solución simple es bloquear estas keys para estas operaciones y devolver un conflicto mientras modificamos las cosas. Si me mandas:

curl -v -L -X PUT -d body1 localhost:3000/key
curl -v -L -X PUT -d body2 localhost:3000/key
curl -v -L -X DELETE localhost:3000/key
...

Se van a ejecutar en orden de llegada al master, y potencialmente, puede ser que una de estas te devuelva conflicto y nunca se ejecute, porque no estamos encolando nada... @Kaledros Una DB habitualmente tiene un WAL y a continuación decidir como procesar estos datos, ya que lo mas eficiente es trabajar en batch, compactar put1/delete1/put1, el resultado sera el ultimo put1 y nos ahorramos 2 operaciones.. etc. Creo que se sigue la idea principal aunque os falle un poquito el aplicar el IQ.

Y por lo que respecta a GET, puede ser que de resultados temporalmente **inválidos, si hacemos PUT/DELETE/PUT, podria ser que hiciésemos el GET justo después del DELETE y obtuviésemos un error. Cest la vie mon fperie. **Entre comillas. Lo importante aquí es darse cuenta que el sistema es CORRECTO vs lo que tu esperas que haga o quieras que haga en un mundo ideal.

Ahora mismo tengo esto, cambiando la "db" por "lock_keys" y el HashMap por un HashSet.

Y esto el fpero medio va a decir que huele. Huele a middleware... jeje. En warp, lo que estoy usando hablaríamos de filtros, podria mover estas piezas a filtros y re-utlizarlas. Porque clean code me dice que hay que re-utilizar el codigo! De hecho el clean code me diria que mi codigo esta mal porque en lugar de escribir todo en funciones que devuelven void estoy haciendo funciones demasiado largas y mil historias del estilo para pelo ricitos y miudevs de la vida que no han trabajado en su vida.

Los middleware son la mayor fuente de bugs en APIs. Existen dos posibles fallos:

  • Program crash. No pasa nada, como todo es in-memory, al re-start todo funcionara. Si es distribuido en cambio... Hay que agregar el estado inconsistente mediante consenso.
  • Thread crash. El programa no peta, pero el hilo si... entonces la key se queda para siempre en el HashSet y nunca podrás modificarlo... Uno de los fallos mas comunes en el event loop de Netty y Spring... jajajaj

Dos soluciones simples: tener un TTL en el hashset, o la mejor, detectar que peta y arreglarlo, y en teoría Rust esto lo hace bien. Segun SO, a saber si es verdad o mentira o esta el comentario deprecated, tendré que añadir un poco de error handling para liberar los recursos: https://stackoverflow.com/questions/68331445/how-to-properly-handle-errors-from-warp-route

Y quizás cuando tenga todo y sepa que funcione correctamente ante cualquier tipo de crash, volveré a explicar porque tener middleware/filter seguramente esta mal si lo puedes evitar.

Y enlazando con todo los que hemos visto hasta ahora. A las transacciones, durability y demas conceptos de DB, podria tener un WAL y compactar operaciones. Leer #8. Si no entiendes porque esta mal, hazte panadero o cuélgate con el cable del teclado mecánico de 200 euros.

D

Warp tiene cosas horribles chavales. En BitTorrent al toque #23 ya comente el tema de devolver las respuestas http que tienen un par de helpers que parece que los ha escrito un niño pequeño.

Traigo otro mojon de mierda. Para que veáis que por mucho Rust y libreria super popular que sea, no significa que la gente que escriba estas librerías tiene idea de lo que hace. La mayoria de gente estaba en el momento y lugar indicado para aprovecharse del tirón... y poco mas.

https://github.com/seanmonstar/warp/issues/388#issuecomment-576453485

Quiero gestionar errores de manera eficiente en mis endpoints. Como os he comentado en caso de que algo pete para liberar las keys de nuestro lock. Pues bien, la manera de hacerlo es o creando filtros de mappear errores o tener un recover genérico. Que esto ultimo es la recomendación oficial...

Tengo:

    let put_record = warp::put()
        .and(lock_keys.clone())
        .and(warp::header::optional::<u64>("content-length"))
        .and(warp::path::param::<String>())
        .and(warp::body::bytes())
        .and(warp::path::end())
        .and_then(handle_put_record);

let get_record = warp::get()
    .and(warp::path::param::<String>())
    .and(warp::path::end())
    .and_then(handle_get_record);

let api = put_record.or(get_record).recover(handle_rejection);

No podemos tener:

    let put_record = warp::put()
        .and(lock_keys.clone())
        .and(warp::header::optional::<u64>("content-length"))
        .and(warp::path::param::<String>())
        .and(warp::body::bytes())
        .and(warp::path::end())
        .and_then(handle_put_record).recover(put_recover); // RECOVER

let get_record = warp::get()
    .and(warp::path::param::<String>())
    .and(warp::path::end())
    .and_then(handle_get_record).recover(get_recover); // RECOVER

let api = put_record.or(get_record);

Menudo coñazo... https://github.com/seanmonstar/warp/blob/master/examples/rejections.rs

Imagino que con el sistema de tirado que ofrece la API lo puedo tener como un filtro que hace wrap de otro filtro: https://github.com/seanmonstar/warp/blob/master/examples/wrapping.rs

Pero vamos. La conclusion es que esto es una puta porquería de API. Se nota que lo han escrito niños de DAW que no han trabajado en su vida. Como el 99% del código open source que por desgracia toca usar si no quieres re-escribir hasta las notas del apuntador.

Con lo fácil que es tener un map_err : - ) pero no, me tocara si quiero tener un map que haga match de si es Ok o Err yo a mano y si es Err arreglarlo. Y si es un panic cogerlo en el recover... Y tener la lógica de gestión de errores repartida por todos lados. Genial fperos. Como la gente puede ser tan mala escribiendo código. Cuando la propia std lib de rust tiene map, map_err, ok_or_else, y mil historias del estilo para estos casos de uso! Que pereza.

Fijaros en la API, en lugar de tener helpers para los métodos solo tienen uno para el not_found, sino tienes que ir uno a uno comprovando:

if err.is_not_found() {
        code = StatusCode::NOT_FOUND;
        message = "NOT_FOUND";
    } else if let Some(DivideByZero) = err.find() {
        code = StatusCode::BAD_REQUEST;
        message = "DIVIDE_BY_ZERO";
    } else if let Some(e) = err.find::<warp::filters::body::BodyDeserializeError>() {

} else if let Some(_) = err.find::<warp::reject::MethodNotAllowed>() {

} else {

}

vs algo que podria ser asi:

match err {
  rejection::NotFound => {}
  rejection::BodyDeserializeError => {}
  rejection::MethodNotAllowed => {}
  _ =>
}

Que automáticamente te pusiese todos los branches possibles de errores y tu si quieres lo simplificas con un else (_)... Madre mia. Que malos programadores de verdad. Que ademas podrías hacer un into_error() o into_reply() y que te ponga el status code automaticamente... Imaginaos que mal diseñado esta toda esta API que no se puede hacer nada de esto...

async fn handle_rejection(err: Rejection) -> Result<impl Reply, Infallible> {
   err.into_reply()
}

Y asi cubres ell 99% de los casos de uso de la gente normal. Devolver un error con el estatus code que toca sin body o un body default. Y si quieres meter body haces el match...

match err {
  rejection::NotFound => reply::with_status("not found bro", StatusCode::NOT_FOUND)
  _ => err.into_reply()
}

Estos errores son completamente inútiles si no puedes devolver:

404, "your request for object id: 1234 was not found in volume: abc_def"

Así que vas a tener que hacer custom errors para todos los errores que te surgen o sera un mensaje genérico, que mejor dejar vacío...

Lo voy a dejar así por si tenéis curiosidad:

Ya digo que para ir bien, todos nuestros handlers deben SI o SI devolver custom errors con información de runtime para devolver a usuario o no valen para nada... asi que esta función la usare para matchear mis errores y construir la respuesta... la libreria de rejection de Warp no vale absolutamente para nada, es una mierda.

Fijaros que yo en mi codigo, si pasa un error de aplicación, ya puedo construir la respuesta cuando ocurre como hice aqui:

   if lock_keys.contains(&key) {
        return Ok(warp::http::Response::builder()
            .status(warp::http::StatusCode::CONFLICT)
            .body(String::new()));
    }

Estos recovery solo deben usarse para cosas excepcionales que no has podido recoger y limpiar como el caso que digo que pete algo mientras escribimos en un volume y pete el codigo... En fin, libreria inutil donde las alla, no hay por donde cogerla.

@desu illuminanos, entonces por que existe toda esta parafernalia? pues para detectar automaticamente estos errores:

        .and(warp::body::content_length_limit(64))
        .and(warp::body::json::<SearchQuery>())

Si falla el content length limit o el json a SearchQuery, son un ejemplo de internet, pues te devolverá el error que toca, pero tu como usuario de esta API no puedes hacer un cagado.

Asi que quizás me lo cargo todo y hago un map del error y fuera...

D

He integrado la leveldb ya y los algoritmos de balanceo a los volumenes. Mi código funciona igual que el de Jorgito Caliente. Pero tengo pensado cambiar unos detallitaos que tiene mal y causan problemas.

No voy a explayarme mucho hasta que no lo tenga ya que estoy tratando de entender bien como funciona el sistema y los bugs que estoy resolviendo.

edit: estaba en la ducha cuando de repente! faaak brah!!! que estamos bloqueando el lock mas de lo debido:

https://github.com/vrnvu/rust-minikeyvalue/commit/9852f70221926c373c4ac8a3ed13eeaabc05e14b

3
D

No me voy a dormir sin tener el PUT distribuido : - )

Ejecutamos:

sudo ./tools/bringup.sh 

Necesito sudo o configurar nginx... quizás lo dockerizo para que sea mas simple, pero estoy usando flox.

Luego hacemos el PUT:

flox [default] ➜  ~ curl -v -L -X PUT -d bigswag localhost:3000/bigswag
* Host localhost:3000 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:3000...
* connect to ::1 port 3000 from ::1 port 59746 failed: Connection refused
*   Trying 127.0.0.1:3000...
* Connected to localhost (127.0.0.1) port 3000
PUT /bigswag HTTP/1.1
Host: localhost:3000
User-Agent: curl/8.7.1
Accept: */*
Content-Length: 7
Content-Type: application/x-www-form-urlencoded

* upload completely sent off: 7 bytes
< HTTP/1.1 201 Created
< content-length: 0
< date: Mon, 16 Sep 2024 22:15:25 GMT
< 
* Connection #0 to host localhost left intact

Mañana o pasado mañana, que tengo un poco de lio, a ver si tenemos el PUT/GET/HEAD funcional.

Commit: https://github.com/vrnvu/rust-minikeyvalue/commit/ad78b740f3d2869c9e21ae8176f0de0bcac7ac67

Si os fijáis he deshecho lo del liberar el mutex... ya lo gestionare mas adelante.

Muy avanzados y unas 500 lineas de código... a ver si podemos quedarnos sobre las 1000 como Jorgito.

2
D

PUT y GET distribuido completado.

Pongo bigswag y bigswag2, bigswag2 hago dos GET y veis como devuelve dos replicas distintas. El algoritmo de elección de replica es random.

Mi objetivo ahora va a ser pegar una primera limpieza del código para que sea mas entendible, mi código ya es bastante mas entendible que el de geohotz, y empezar con los tests de integración.

Podemos decir que prácticamente en un día, hemos hecho la gran mayoría de la solución. Portar código es fácil.

La funcionalidad es prácticamente la misma, casi casi interoperables, por ejemplo yo devuelvo a veces headers vacío y geohotz no devuelve el header en la respuesta... algún detalle de este estilo... que para tener el código mas claro lo he movido.

Los algoritmos y contenido de los headers son idénticos. StatusCode, body y headers importantes como location iguales. Si te redije a "'http://localhost:3005/sv03/bd/ee/Ymlnc3dhZzI='" con mi codigo con el de geohotz tambien.

2
D

Integration tests pasando con un test (put and get).

https://github.com/vrnvu/rust-minikeyvalue/commit/f995d57d4c03765fd170f994c97cbfcf27a8269e

Los he comentado todo porque tengo que resolver el problema de los mutex.

Sorpresa Rust compila, "funciona", pero no hace las cosas bien... No todo es magia como te lo venden, hay que usar las neuronas.

Hare también el DELETE, con GET/PUT/DELETE tendremos los 3 básicos.

D

Arreglando el bug del lock que no liberaba. Es un tema interesante, un gran ejemplo de porque se inventaron los defer y gotos para limpiar recursos.

https://github.com/vrnvu/rust-minikeyvalue/pull/3/files
edit: este comentario queda deprecated porque re-use la PR para la solución del final XD, pero vamos, los que estaba haciendo era en cada return meter un remove(key).

Fijaros como he tenido que buscar todos los return de mi función, y antes de ejecutarlo, limpiar el lock. Si no hago esto, que no lo hacia, tenia un bug en que si un PUT se quedaba pillado ya nunca lo podría sobre-escribir.

Pero es bastante error-prone que le llaman, que pasa si me dejo un caso? No es muy mantenible. Crea una carga cognitiva en que yo como programador, y todo el equipo de desarrollo debe comprender como funciona al completo el sistema. No es cosa de una función aislada, es que si no libero ese lock, otras operaciones como DELETE en el futuro tampoco funcionaran.

Nota Rust: el mutex del lock se suelta automáticamente en cualquier return de función, drop. Se podría optimizar la granularidad, para que puedas tener 2 PUT de 2 key y el lock no bloquease, que ahora existe la posibilidad de que si durante unos ms. Esto con unas miles de req/s no importa, pero si lo arreglas es un buen boost.

En golang y zig es facil, hariamos algo asi:

    let mut lock_keys = lock_keys.lock().await;
    lock_keys.insert(key);
    defer lock_keys.remove(key)

Ese defer se ejecutara automáticamente en todos los sitios donde la función haga return.

Mucha gente esta en contra de los defers, porque no saben gestionar casos mas complejos, pero no os engañéis, la gente que se queja de no poder hacer X en un defer suelen ser malos programadores. Al menos en Go y Zig se puede resolver todo fácilmente.

La alternativa a la C, es tener un goto! Los gotos no son malos, que nadie os engañe, tener una etiqueta o función de exit, para limpiar recursos y devolver errores o resultados unifica el código.

Rust no soporta defer. Pero tiene muchísimo control con gotos y se usan muchísimo en codigo donde la performance y la eficiencia es importante, saltar en loops y optimizar cosas de assembly. en este caso, supongo que la solución es realizar un clean_up o exit a la C. Teniendo una función que gestione esto. No es un gran problema, en este caso algo como Go o Zig para un caso super simple me ahorraría hacer esta solución que es mas sobre-enginieria y requiere unificar mas cosas del codigo.

Una alternativa siguiendo el RAII de rust, de que cuando haga drop, las cosas se limpien seria tener un struct asi:

struct LockGuard {
    lock_keys: Arc<Mutex<HashSet<String>>>,
    key: String,
}

impl LockGuard {
    fn new(lock_keys: Arc<Mutex<HashSet<String>>>, key: String) -> Self {
        let mut lock_keys = lock_keys.lock().await;
        lock_keys.insert(key.clone());
        LockGuard { lock_keys, key }
    }
}

impl Drop for LockGuard {
    fn drop(&mut self) {
        let mut lock_keys = self.lock_keys.lock().await;
        lock_keys.remove(&self.key);
    }
}

El lock automaticamente se droppea y ahora ademas tenemos un remove! Pero el precio a pagar es tener un struct extra en nuestra libreria...

En el get tambien podemos hacer uso del drop para gestionar el mutex:

   let record = {
         let leveldb = leveldb.lock().await;
         match leveldb.get_record_or_default(&key) {
             Ok(record) => record,
             Err(e) => {
                 error!(
                     "get_record: failed to get record {} from leveldb: {}",
                     key, e
                 );
                 return Ok(warp::http::Response::builder()
                     .status(warp::http::StatusCode::INTERNAL_SERVER_ERROR)
                     .body(e.to_string()));
             }
         }
     };

Este es un patron muy tipico en RAII, creas una scope { } temporal y asi se gestiona por ti.

D

Una cosa que no me gusta de los tests de geohotz que le tirara para atras en CR es que hace esto:

r = requests.put(key, data="onyou")
self.assertEqual(r.status_code, 201)
r = requests.put(key, data="onyou")
self.assertNotEqual(r.status_code, 201) **** NOT EQUAL

ASSERT NOT EQUAL, en lugar de EQUAL lo que quiere, un 409/CONFLICT. Mala practica, mal codigo. No estable un contrato claro.

D

DELETE and HEAD funcionando
https://github.com/vrnvu/rust-minikeyvalue/commit/b3d4c1d0a090853e7f8747bcd23c3e58b54f449a

El DELETE me falta volar de los volúmenes que me espero a hacer el link/unlink.

Ya solo nos falta el UNLINK y LIST por prefijo.

Cosas raras que he visto en el port, pues si algo falla en inicializarse lo marca como borrado... asi en el DELETE no hace nada jajaja Menuda gilipollez... Todo esto cuando termine lo voy a arreglar y hacer mejor para que se entienda.

D

Me he encontrado el primer problema que no se resolver.

Cuando le meto el test de un archivo/body grande, 16MiB, se queda pillado... En el runner de GitHub lleva 50 minutos y no termina... Obviamente hay algo mas mal. A saber.

1) No entiendo porque se queda pillado y no termina, 16MiB no es tanto, debería ir lento pero no pillarse
2) No entiendo xq la gente que hace librerías y clientes http hace las cosas mal. Quien se levanta y dice, voy a hacer que esta petición HTTP cargue todo en memoria y copie todo en varios buffers y luego lo transmitire lo mas lento posible? Es que no entiendo el ecosistema de fperos que monta la gente... En esto se nota que Go lo hicieron profesionales y la stdlib todo esta bien diseñado.
3) lo mismo no es reqwest y es la configuración de nginx u otra cosa, pero no entiendo porque los tests, que son en python funcionan en su repo y no en el mio.

En fin, he descubierto que el fpero que mantiene estos proyectos es este tio:
https://github.com/seanmonstar
https://seanmonstar.com

Como os he explicado en anteriores posts este tio no tiene ni puta idea de programar y por desgracia sus dos mojones de librerías son de lo mas usado para http en Rust... que puta porqueria. Ahora que he visto que las dos mierdas que usaba son del mismo tio todo me encaja... Estoy por cambiar las dependencias porque este tio es un fpero que vive del cuento.

1 respuesta
Kaledros

#19 Tengo comprobadísimo que Github se queda perchado con ficheros de más de 10MB. Con menos de eso tarda la puta vida, pero con un fichero de 20MB lo dejé toda la noche corriendo y me tardó como cinco horas.

1 respuesta
D

#20 Efectivamente. Aun asi 4 segundos me tarda la puta tontería... tendré que optimizarlo porque ademas estoy calculando un md5 de todo el contenido, asi que cargo los 16mib en memoria y creo que se debería poder hacer en chunks. edit: ya lo hace en chunks vaya..

Pero vamos, suelto el rage igual porque hay cosas que me tocan los cojones. Me he estado 1h mirando los streams y hacer io mas eficiente con estas librerías warp/reqwest y es infumable.

Estoy por poner hyper, que aunque este subnormal haya contribuido, al ser mas bajo nivel quizás me ahorra algun dolor de cabeza con el sistema de tipado.

D

Vamos, una optimización al código de George Hotz.

Su código por defecto, crea un Record con el estado Deleted::Hard y lo mete en leveldb, cuando haces un PUT e inicias el proceso, hace un put a leveldb y mete un Deleted::No con el hash vacío, y por ultimo cuando se escribe de manera adecuada a todas las replicas hace un Deleted::No y pone el hash md5 del contenido.

No se si queda claro, no es obvio entenderlo en como funciona todo el sistema, pero en resumen la secuencia es:

  1. escribir en leveldb Deleted::Hard
  2. escribir en leveldb antes de empezar Deleted::Init
  3. escribir en leveldb si falla algo durante la replica Deleted::Soft (de esta manera podemos borrar y diferenciar los Init de los Soft!)
  4. escribir en leveldb si todo funciona Deleted::No

Con esta MR nos ahorramos una escritura: https://github.com/geohot/minikeyvalue/pull/48/files porque inicializo en Deleted::Init

Nos podríamos ahorrar 3 creo tambien, de momento lo dejo. Porque utiliza ese estado para temas de Link/Unlink, pero yo creo que para el tema de borrado de elementos fallidos no hace falta, porque 1) chafas los Init o 2) te los cargas si son viejos.

El tema de la leveldb, como solo tenemos un master que hace todo, solo es un WAL básico de toda la vida... No hacemos muchas virguerías, al menos en el modo server. Quizás en los modos de re-balance y re-build que aun no los he mirado hay algo de logica. Pero de momento, no lo veo necesario.

D

He hecho unos experimentos con los de hacer stream... pero no me empano de nada con el ecosistema de Rust... madre mia... que la stdlib no funcione directamente todo asíncrono o con buffers optimizados que se reusen y fuera... que mal. No tendría problema si al menos al googlear me saliese, usa este crate X y esta función, pero que va, hay 3 o4 crates de : future_utils, tokio_utils, futures, y demas traits y extensiones...

https://github.com/vrnvu/rust-minikeyvalue/pull/8/files

Si alguien sabe orientarme, se lo agradecería.


Para mañana pues estoy pensando en cargarme lo de unlink y alguna cosita... y simplificar la DB... para el benchmark que quiero batir no hace falta, solo hace get/put/delete... asi que lo mismo lo hago y re-factorizo todo para centrarme en mejorar bugs y que vaya rapido.

Estoy pensando casos de uso y ya os digo que para mi, una kv no necesita el soft / unlink a nivel de usuario, me parece un detalle de implementación leakeado. que ademas miras el rebalance y rebuild y sobre-escriben las keys sin pensar en el estado anterior... jaja asi que solo lo usaran para limpiar con algun cron de compact o yo que se...

D

https://github.com/vrnvu/rust-minikeyvalue/pull/10/files

Las range request funcionan sin tener que hacer nada porque lo hace nginx en el redirect :-)

D

Optimizando nuestro código un x10! Because we are x10 engineers!

Mi código en Rust va a pedales chavales, ni 1k req/s! En Golang no te preocupas de nada y me iría un orden de magnitud mas rápido que lo que tengo, un x100! Para que veáis que no todo es re-escribirlo en Rust. Hay que saber hacerlo. Y que por eso Go es lo mejor para el dia a dia.

El x10:
https://github.com/vrnvu/rust-minikeyvalue/pull/11/files
Re-usar el cliente http :-) y quitar los info! por debug! para no tener output en stdout.

Y otra cosa importante, aqui otro x2:

cargo build --release

Recordad compilar --release!!! En la primera captura no había compilado aun en release mode todos los cambios.

D

Por que va tan lento?

Mis peticiones van mas rápido, tienen mejor desviación, y el tail es parecido. He tirado varias veces el benchmark y siempre tengo peticiones mucho mas rápidas, mejor desviación y mejor tail/max. Esta captura es de las mas parejas para asumir un worst case scenario. Pero ya veis que en un caso super simple de tirar una request es muy parejo.

Esta claro que algo literal no escala... si todo mi código va mas rápido, como puede tragar menos req/s?

Old value (before latency): 835
New value (current latency): 564
Performance increase = (835 - 564) / 835 100%
= 271 / 835 100%
≈ 32.46%

He tirado un flamegraph al test de thrasher pero no veo un cagado, salvo que el 98% de la cpu son cosas de Tokio e Hyper... tendrá que escribir benchmarks mas granulares para cada operación para ver que hacer.

He probado varias configuraciones para el cliente de reqwest y googleado para warp pero no veo que puede ser. El ecosistema da puto asco el pollo que tienen montado con traits entre ellos y conversiones.

En conclusion mi código es mas rápido (?) hace menos operaciones (?) y aun así Go tiene mejor throughput. El doble en concreto en le test heavy write:

No he podido hacer profile de la memoria porque heaptrack por algún motivo falla con flox wtf y lo tengo que builder a mano.

flox [rust-minikeyvalue default] ➜  rust-minikeyvalue git:(master) ✗ flox install heaptrack
✅ 'heaptrack' installed to environment 'rust-minikeyvalue'
flox [rust-minikeyvalue default] ➜  rust-minikeyvalue git:(master) ✗ heaptrack
zsh: command not found: heaptrack
pantocreitor

Lo de las librerías en RUST es para hacérselo mirar, he tenido que descartar varios proyectos porque aunque el código vaya mejor que en Java (no es muy dificil xD) por temas de librerías y drivers para Oracle acaba siendo mas lento a la hora de meterle tests de rendimiento.

Me está molando el blog

1 respuesta
D

Esto seria el samply, se ve mejor que el flamegraph:

Recordad cuando hacéis sampling activar los stackframes, debug flags necesarios etc:

[profile.release]
debug = true

Como veis todo es el runtime esperando y haciendo cosas:

Nuestro codigo hay que buscarlo en la B:

Ya veis a cantidad de caca, quizás si supiese de tokio se me iluminaria la cabeza y lo resolvería rapido. jajaja

D

#27 El problema son las decisiones que toman con los defaults y lo mal que diseñan las APIs, es que no veo donde cojones, teniendo ambos 8 threads, usando un runtime que spawnea por request... estoy perdiendo 1k req/s en throughput. Si todo va mas rapido jaja

Rust es un lenguaje para hacer todo a mano, como la gente de https://github.com/oxidecomputer. Y si hay una libreria que sea lo mas pequeña posible como rand, regex, derive, base64... cosas que estén optimizadas y vayan volando.

Es que ademas, hacen todas estas librerías para que sean interoperables si quieres tokio o async-std, o cualquier otro runtime en el futuro, que si esta libreria u esta otra para noseque mira.. que no. Que el sistema de tipado y abstracciones que han metido es una mierda, Todo se resuelve con que el input/output sean bytes y un buffer. Filosofia unix de toda la vida. Todo el resto fumadas que no aportan.

Ya se me ha pasado por la cabeza quitar warp y reqwest y hacerlo todo a mano con hyper y tokio... es que es un caso de uso super simple...

D

Me he dado cuenta de que me sobraba el Mutex de la leveldb...

https://github.com/vrnvu/rust-minikeyvalue/pull/13/files

   lock_keys: Arc<RwLock<HashSet<String>>>,
     leveldb: Arc<record::LevelDb>,

Y de paso he cambiado por un RwLock el Mutex para que no afecto a los read/contains.

Pero vamos, esto no arregla nada del rendimiento.


Una diferencia entre Go y Tokio (Rust) es que uno es preemptivo y el otro colaborativo, asi que le he metido mas awaits donde he visto que tenian sentido pero no cambia nada jaja pero vamos, es lo unico que puedo hacer desde mi parte, sin tocar estos frameworks de warp, para mejorar el async.

https://github.com/vrnvu/rust-minikeyvalue/pull/15/files

Bueno, voy a ponerme a mirar las cosas a nivel granular en lugar de tests grandes... y si no puedo mejorar nada y sigo mejor que el codigo original pues aqui termina el proyecto. Go > Warp.