P5 · Motor: transacciones
Publicado: semana 14 · Entrega: semana 16 · En parejas
La última pieza de quipu: varias transacciones a la vez y una caída en el
peor momento. Cubre las semanas 13 a 15 (Silberschatz, caps. 17 a 19; notas
11, 12 y 14 de CS186). Se presenta en el laboratorio de la semana 16.
Al final de P5, quipu tiene una tabla con transacciones: un lock manager con
strict 2PL que decide quién sigue y quién espera, un log write-ahead en un
archivo aparte y una recuperación al estilo de ARIES que, después de una
caída, deja exactamente los efectos de las transacciones que hicieron commit.
Node.js ejecuta un solo hilo y quipu es síncrono, así que la concurrencia se
simula: las pruebas intercalan las operaciones de varias transacciones paso
a paso, como los schedules de clase. Cuando una transacción tiene que esperar
un lock, su operación no se ejecuta: devuelve WAIT y la prueba la vuelve a
intentar más tarde, cuando el lock se liberó.
Archivos
Esta carpeta trae el punto de partida. Se copia a la carpeta del proyecto, encima de lo de P3 y P4; no trae ningún archivo de P3, así que no pisa nada de lo que ya hicieron:
| Archivo | Qué es |
|---|---|
quipu/lock-manager.mjs, quipu/log.mjs, quipu/recovery.mjs, quipu/database.mjs |
Los módulos nuevos, con los nombres que usan las pruebas; cada método lanza no implementado |
Makefile |
El de la segunda mitad, ahora con las pruebas de P5; reemplaza al de P4 |
p5.public.test.mjs |
Las pruebas públicas |
Además, agregan un método a su quipu/page.mjs de P3 (sección 1). P5 usa
DiskManager, BufferPool, SlottedPage, encode y decode de P3; no usa
HeapFile ni el B+ tree, ni nada de P4.
Reglas comunes
- Las mismas de P3: las pruebas usan exactamente los nombres de este
enunciado, pueden agregar todo lo que quieran por dentro, y cuando algo no se
puede hacer el método lanza un
Error(o una subclase). - Las pruebas de P3 tienen que seguir pasando: lo que agreguen a los módulos de P3 no cambia nada de lo que ya hacían.
- Una transacción se identifica con un entero que da
begin(): 1, 2, 3, … Una transacción es más joven que otra si su número es más grande. - Un RID es
{ pageId, slot }, como en P3. El recurso que se bloquea por una fila es el stringshipment:<pageId>:<slot>, por ejemploshipment:3:17.
1. SlottedPage.put (en quipu/page.mjs)
Para rehacer y deshacer un cambio hay que poder dejar un slot dado
exactamente como estaba: volver a poner una fila borrada en su slot, o
rehacer un insert en el slot que anotó el log. insert no sirve, porque elige
el slot. Agreguen a su SlottedPage:
| Método | Qué hace |
|---|---|
put(slot, bytes) |
Deja el slot slot con el registro bytes: lo ocupa si estaba libre o lo reemplaza si no. Con bytes = null, lo deja libre. Devuelve false si el registro no entra, y entonces la página queda igual |
- Si
slot ≥ numSlots, el arreglo de slots crece hastaslot + 1y los slots nuevos del medio quedan libres. - Al liberar, los slots libres que quedan al final del arreglo salen de él:
numSlotsbaja hasta el último slot ocupado más uno (0 si no queda ninguno). Así, liberar conputdevuelve todo el espacio que ocupó elputque llenó ese slot, también el de los slots nuevos, y deshacer nunca se queda corto. - Como
insertyupdateen P3,putcompacta sola cuando hace falta: que el registro entre depende solo del espacio libre total de la página, no de cómo estén repartidos los huecos. putno cambia los otros slots ni los bytes 0 a 7. Liberar un slot que ya está libre no hace nada y devuelvetrue.
2. Lock manager (quipu/lock-manager.mjs)
LockManager no sabe nada de páginas ni de filas: un recurso es un string y
una transacción, un entero. Exporta LockManager.
| Método | Qué hace |
|---|---|
acquire(txn, resource, mode) |
mode es 'S' o 'X'. Devuelve 'granted' o 'waiting' |
releaseAll(txn) |
Libera todos los locks de la transacción y la saca de la cola donde esperaba, si esperaba. Concede lo que se pueda y devuelve lo concedido: un arreglo de { txn, resource, mode } en el orden en que se concedió |
locksOf(txn) |
Los locks que tiene: [{ resource, mode }], en el orden en que los tomó (un upgrade no cambia el orden) |
holdersOf(resource) |
Quiénes tienen un lock sobre el recurso: [{ txn, mode }], en el orden en que se les concedió |
waitersOf(resource) |
La cola de espera del recurso: [{ txn, mode }], en orden |
waitingFor(txn) |
El pedido que espera la transacción, { resource, mode }, o null |
detectDeadlock() |
Un ciclo del waits-for graph, [T1, T2, …, Tk], o null |
Las reglas, que las pruebas revisan una por una:
- Compatibilidad.
Ses compatible conS;Xno es compatible con nada. - Pedir lo que ya se tiene. Si la transacción ya tiene el lock en ese modo
o en uno más fuerte (
Xcubre aS),acquiredevuelve'granted'sin cambiar nada. - Cola FIFO (sec. 18.1.4). Un pedido nuevo se concede si es compatible con
todos los locks concedidos y la cola del recurso está vacía. Si no, va
al final de la cola. Un
Sno se adelanta a unXque espera, aunque sea compatible con lo concedido: si no, un flujo de lectores dejaría esperando para siempre al escritor. - Upgrade. Si la transacción tiene
Sy pideX: si es la única que tiene un lock sobre el recurso, se le concede de inmediato. Si no, espera al principio de la cola, delante de todos los pedidos que no son upgrades (detrás de otros upgrades, si hay). No suelta suSmientras espera. - Una espera a la vez. Una transacción espera a lo más un pedido. Si una
transacción que espera repite ese mismo pedido,
acquiredevuelve'waiting'sin duplicarlo; si pide otra cosa, lanza un error. - Al liberar.
releaseAllrevisa los recursos que la transacción tenía, en el orden delocksOf, y al final el recurso donde esperaba. En cada uno, concede los pedidos desde el principio de la cola mientras sean compatibles con lo concedido (un upgrade, con lo concedido a las demás) y se detiene en el primero que no lo es. Así, variosSseguidos se conceden juntos. - Waits-for graph. Hay una arista Ti → Tj si Ti espera un recurso y Tj
tiene ahí un lock incompatible con el pedido de Ti, o Tj está antes que
Ti en la cola de ese recurso con un pedido incompatible (con FIFO, Ti
también espera a Tj).
detectDeadlockdevuelve un ciclo: cada transacción de la lista espera a la siguiente, y la última a la primera. Si hay varios ciclos, cualquiera.
Por ejemplo, T3 tiene X sobre b y T1 tiene S sobre a. T2 pide X
sobre a y espera. T3 pide S sobre a: es compatible con el S de T1,
pero espera detrás de T2. T1 pide S sobre b y espera a T3. El ciclo es
T1 → T3 → T2 → T1, y solo se ve con las aristas de la cola.
3. Log (quipu/log.mjs)
Un log write-ahead en un archivo aparte. Exporta LogManager.
| Método | Qué hace |
|---|---|
new LogManager(path) |
Abre el log del archivo, o lo crea vacío. Al abrir, están todos los registros que se forzaron antes |
append(record) |
Agrega el registro (un objeto) al final, con lsn igual a lastLSN + 1, y devuelve ese LSN. No escribe en el archivo |
flush(lsn) |
Escribe en el archivo los registros hasta lsn (sin argumento, todos). Si ya estaban, no hace nada |
flushedLSN |
El LSN más alto que está en el archivo (0 si ninguno) |
lastLSN |
El LSN del último registro, escrito o no (0 si ninguno) |
get(lsn) |
El registro con ese LSN |
*records(from = 1) |
Los registros desde from, en orden |
crash() |
Pierde los registros que no se forzaron y cierra el archivo |
close() |
Fuerza todo y cierra el archivo |
Los LSN son 1, 2, 3, … sin huecos. Un registro que vuelve de get o de
records tiene todos los campos con los que se agregó, más lsn, y los
Buffer vuelven como Buffer. El formato del archivo lo deciden ustedes
(una línea de JSON por registro está bien). Después de un crash(), los LSN
que se perdieron se vuelven a usar: no es un problema, porque por la regla
WAL ninguna página en disco tiene un pageLSN que no esté en el archivo.
Los registros que escribe quipu, todos con type, txn y prevLSN (el LSN
del registro anterior de la misma transacción; 0 en el begin):
type |
Campos | Cuándo |
|---|---|---|
begin |
begin() |
|
update |
pageId, slot, before, after |
Cada cambio de una fila. before y after son los bytes del registro antes y después (encode), o null si el slot estaba o queda libre: un insert tiene before = null y un delete, after = null |
commit |
commit(), que fuerza el log hasta aquí |
|
abort |
Al empezar el rollback de abort() |
|
clr |
pageId, slot, after, undone, undoNextLSN |
Cada cambio que se deshace: after es la imagen que deja (el before del update), undone es el LSN del update que compensa y undoNextLSN el siguiente registro de esa transacción que falta deshacer (el prevLSN del update) |
end |
La transacción terminó: después del commit, al final del rollback de abort() o al final del undo de una perdedora en recover() |
|
checkpoint |
active, dirty |
checkpoint(). active es la transaction table ([{ txn, lastLSN }] de las transacciones activas) y dirty la dirty page table ([{ pageId, recLSN }]). Va con txn = 0 y prevLSN = 0 |
Pueden agregar campos (por ejemplo, el próximo número de transacción en el
checkpoint). Es la versión de quipu de los registros de la sec. 19.9 y de la
semana 15: logging fisiológico, físico por página (pageId) y lógico dentro
de ella (el slot, no un offset).
4. Transacciones (quipu/database.mjs)
Database junta todo: una tabla en páginas con slots sobre un BufferPool,
un LockManager y un LogManager. Exporta Database y WAIT, un valor
único (un Symbol) que devuelven las operaciones que tienen que esperar.
| Método | Qué hace |
|---|---|
new Database(dir, { schema, capacity }) |
Abre la tabla de la carpeta dir, o la crea (con la carpeta) si no existe. Usa dos archivos: dir/table.heap (las páginas, con su DiskManager y un BufferPool de capacity frames, 64 por defecto) y dir/table.wal (el log). No lee páginas ni recupera nada: después de una caída hay que llamar a recover() |
disk, pool, log, locks |
Propiedades: el DiskManager, el BufferPool, el LogManager y el LockManager |
begin() |
Empieza una transacción y devuelve su número, mayor que todos los que hay en el log |
get(txn, rid) |
Pide S sobre la fila. La fila, null si en ese RID no hay ninguna, o WAIT |
insert(txn, row) |
Guarda la fila y devuelve su RID, con X sobre él. Nunca espera |
update(txn, rid, row) |
Pide X. Reemplaza la fila y devuelve su RID (el mismo, o uno nuevo si se movió), o WAIT. Lanza un error si no hay fila en rid |
delete(txn, rid) |
Pide X. Borra la fila y devuelve true, false si no había fila, o WAIT |
commit(txn) |
Escribe commit, fuerza el log, escribe end y suelta los locks. Devuelve lo que devolvió releaseAll |
abort(txn) |
Escribe abort, deshace los cambios de la transacción (sección 5), escribe end y suelta los locks. Devuelve lo que devolvió releaseAll. Sirve también para una transacción que espera |
status(txn) |
'active', 'waiting', 'committed', 'aborted', o null si no existe |
resolveDeadlocks() |
Mientras detectDeadlock() encuentre un ciclo, aborta a la transacción más joven del ciclo. Devuelve las víctimas, en orden |
checkpoint() |
Escribe un checkpoint y fuerza el log |
recover(options) |
La recuperación (sección 5) |
*rows() |
Todas las filas de la tabla, { rid, row }, en orden de página y slot, sin locks: para las pruebas y para depurar |
crash() |
Simula una caída: descarta el buffer pool sin escribirlo, pierde lo que no se forzó del log (log.crash()) y cierra los archivos. Al disco solo llegó lo que el buffer pool ya había escrito |
close() |
Apagado ordenado: escribe todas las páginas, fuerza el log y cierra |
Locks y strict 2PL
getpideSyupdateydeletepidenXsobreshipment:<pageId>:<slot>antes de leer o cambiar nada. Si el lock queda en espera, la operación no hace nada (ni lee, ni cambia la página, ni escribe en el log) y devuelveWAIT. La prueba la repite, con los mismos argumentos, cuando el lock se concedió (statusvuelve a'active'); la repetición encuentra el lock ya concedido y sigue.- Una transacción que espera no puede hacer otra cosa que repetir esa operación o abortar: cualquier otra operación lanza un error. Tampoco puede hacer commit. Una transacción que terminó no puede hacer nada.
- Strict 2PL: ninguna operación suelta locks; todos se sueltan juntos en
commitoabort. Ungetque encuentra el slot libre se queda igual con suS. insertelige una página y, en ella, el slot libre de número más bajo sobre el que ninguna otra transacción tiene ni espera un lock (puede ser un slot nuevo al final). Pone la fila conputy tomaXsobre ese RID, que se concede de inmediato. Por esoinsertnunca espera.- Un
updateque mueve la fila a otra página anota dos cambios en el log (el delete en la página vieja y el insert en la nueva) y tomaXtambién sobre el RID nuevo. - Cuando todas las transacciones que quedan esperan, la prueba llama a
resolveDeadlocks(). La víctima queda'aborted', con sus cambios deshechos, y lo que soltó se concede a las que esperaban.
Espacio reservado para el undo
Si T1 borra la fila del slot 3 de una página, T2 inserta en esa misma página y recibe el slot 3, y después T1 aborta, el undo de T1 ya no tiene dónde volver a poner su fila. Lo mismo pasa con los bytes: si T1 achica una fila y T2 usa el espacio que quedó, el undo de T1 no entra. Por eso:
Mientras una transacción esté activa, lo que liberó en una página (con un delete, un update que achica una fila o un update que la movió) queda reservado para ella: ninguna otra transacción ocupa espacio en esa página. Sus inserts van a otra página y sus updates que agrandan una fila de esa página la mueven a otra. Puede, en cambio, liberar espacio ahí (borrar o achicar). La reserva termina con el commit o el abort.
La misma transacción sí puede reusar lo que liberó: su undo va en el orden
inverso y deshace primero lo que reusó. Con esta regla y la de insert (no
usar slots con locks de otras), el undo siempre encuentra su slot libre y su
espacio. La reserva vive en memoria: después de una caída no hace falta,
porque durante la recuperación no hay transacciones nuevas.
Páginas de ceros
allocatePage escribe la página nueva en ceros en el disco en el momento,
antes de que llegue su primer cambio y su registro en el log. Después de una
caída, la tabla puede tener al final páginas de ceros que el log no menciona,
o que menciona en registros que hay que rehacer. Regla:
Una página con sus 4096 bytes en cero es una página vacía. Antes de leerla o cambiarla,
Databasele da formato en memoria conSlottedPage.init.
Su pageLSN es 0, menor que cualquier LSN, así que el redo le aplica todo lo que
el log tenga para ella. Ninguna página que Database haya cambiado queda en
ceros, porque su pageLSN es al menos 1.
Database usa las páginas directamente (fetchPage, SlottedPage, put,
unpinPage), no HeapFile. Como en P3, lleva en memoria una lista de páginas
con espacio libre que arma la primera vez que hace falta (en el primer
insert o en el primer update que mueve una fila), no al abrir ni en
recover().
5. Log y recuperación (quipu/recovery.mjs)
Cada cambio de una fila se hace así: fetchPage, put sobre el slot,
append del registro update (con la imagen anterior y la nueva), el LSN del
registro en el pageLSN de la página (setPageLSN) y unpinPage sucia. Como
quipu es síncrono, nadie escribe la página entre el put y el
setPageLSN. El buffer pool es steal (puede escribir una página con
cambios sin commit cuando la saca de memoria) y no-force (el commit no
escribe páginas de datos), así que hace falta undo y redo.
- La regla WAL. El buffer pool no escribe una página sin antes forzar el
log hasta el pageLSN de esa página. Se implementa con
pool.beforeWrite(P3):log.flush(getPageLSN(buf)). Las pruebas lo revisan desde afuera: en cada escritura de una página al disco, su pageLSN tiene que ser a lo máslog.flushedLSN. - Commit. Fuerza el log hasta su registro
commit; no escribe páginas de datos. - Dirty page table.
Databaselleva en memoria la DPT: cada página cambiada que todavía no llegó al disco, con su recLSN (el LSN del primer cambio que la ensució). Una página entra con su primer cambio y sale cuando el buffer pool la escribe (beforeWritetambién sirve para eso). - Abort (sec. 19.4.2). Escribe
aborty recorre los registros de la transacción hacia atrás porprevLSN. Por cadaupdateescribe unclrcon la imagen anterior y la aplica a la página (con el LSN delclrcomo pageLSN); unclrse salta hasta suundoNextLSN; al llegar albeginescribeend. Es el mismo undo de la recuperación.
recover({ redoLimit, undoLimit }) sigue ARIES (sec. 19.9) en versión
simplificada, sobre una Database recién abierta:
- Analysis. Empieza en el último
checkpoint(o en el LSN 1 si no hay), con su transaction table y su DPT, y lee el log hacia adelante. Unupdateoclractualiza el lastLSN de su transacción y agrega su página a la DPT si no estaba, con recLSN igual a su LSN; unbeginagrega la transacción; uncommitla marca; unendla saca. Al final, las perdedoras son las que quedaron sin commit. - Redo. Desde el menor recLSN de la DPT, cada
updatey cadaclrse rehace (dejando el slot con suaftery la página con su LSN como pageLSN) salvo que su página no esté en la DPT, que su LSN sea menor que el recLSN de la página, o que el pageLSN de la página ya sea mayor o igual que su LSN. Los dos primeros casos se deciden sin leer la página. Se rehace también lo de las perdedoras: se repite la historia. - Undo. Un conjunto con el lastLSN de cada perdedora. Se toma siempre el
LSN más grande: si es un
update, se escribe suclr, se aplica y se sigue con suprevLSN; si es unclr, se sigue con suundoNextLSN; si es unabort, con suprevLSN. Cuando una perdedora llega a subegin, se escribe suend.
recover() no escribe páginas ni fuerza el log al terminar: lo que hizo queda
en el buffer pool y en la cola del log, como cualquier otro cambio, y si hay
otra caída se vuelve a hacer. Por eso tiene que ser idempotente: caer en
medio de la recuperación (o justo después) y recuperar de nuevo deja lo mismo,
y gracias a los CLRs ningún update se deshace dos veces. Para simular esa
caída, redoLimit detiene la recuperación en cuanto rehízo ese número de
registros, sin llegar al undo, y undoLimit la detiene en cuanto escribió ese
número de CLRs (con 0, antes del primero). Si el redo o el undo tienen menos
trabajo que eso, la recuperación es completa. Sin opciones, también. Después de recover(), las
transacciones nuevas siguen con begin().
Lo que las pruebas revisan de la recuperación:
- Después de
crash()yrecover()quedan exactamente las filas de las transacciones con commit, en los mismos RIDs, también si las páginas con cambios sin commit llegaron al disco y si hubo caídas en medio derecover(). - En el log final, cada
updatede una transacción que terminó sin commit tiene exactamente unclr(por su campoundone) y los de las que hicieron commit, ninguno. Una transacción que abortó antes de la caída no recibe más CLRs. - Abrir una
Databaseno lee páginas.recover()solo lee las páginas que tiene que rehacer o deshacer: después de uncheckpointcon todas las páginas ya escritas, una caída cuesta tantas lecturas como páginas tocó lo que vino después.
Lo que queda a su criterio
El formato del archivo del log, cómo guardan la lock table, cómo arman el
waits-for graph y buscan el ciclo, cómo llevan la transaction table y la DPT,
y cómo reparten el código entre database.mjs y recovery.mjs (por ejemplo,
recovery.mjs con las tres fases y el undo que también usa abort()). La
prueba solo usa los métodos de este enunciado.
Bono. Locks de granularidad múltiple (IS e IX sobre la tabla,
sec. 18.3) y prevención de deadlocks con wait-die, en módulos u opciones
aparte: con las opciones por defecto, quipu se comporta como pide este
enunciado.
README y presentación
En su README: un schedule de ejemplo con deadlock y cómo lo resolvió quipu
(qué aristas tenía el waits-for graph y por qué esa víctima), y la traza de una
recuperación: qué encontró el analysis (perdedoras y DPT), qué rehízo el redo y
qué saltó, y qué CLRs escribió el undo, con los LSN de verdad de una corrida.
La presentación de la semana 16 dura 10 minutos y es una demostración por
línea de comandos de cuatro escenarios (un script suyo sobre Database):
- Dos transacciones comparten un
Sy una tercera pideXy queda en la cola. - Un upgrade de
SaX, sin y con otra transacción que también tieneS. - Un deadlock que el waits-for graph detecta, la víctima que se elige y por qué.
- Una caída con una transacción confirmada y otra en curso, y la recuperación con redo y undo que deja solo la confirmada.
Pruebas
make test # carga Chasqui en chasqui_test y corre las pruebas de quipu
QUIPU_SEED=7 make test # lo mismo, con otras secuencias al azar
make test corre p3.public.test.mjs, p4.public.test.mjs y
p5.public.test.mjs (los que estén en la carpeta) y las pruebas de ustedes en
quipu/, con nombre *.test.mjs. Las de P3 y P4 tienen que seguir pasando.
Las de P5 no usan PostgreSQL y dejan sus archivos en una carpeta temporal que
se borra al final.
Las pruebas públicas de P5 tienen una sección por pieza: SlottedPage.put,
LockManager, LogManager, las transacciones de Database (locks, strict
2PL, deadlocks, espacio reservado) y la recuperación (regla WAL, commit,
steal, CLRs, caídas en medio de recover(), páginas de ceros, checkpoint).
Las últimas pruebas de cada sección hacen miles de operaciones al azar: la del
lock manager revisa sus invariantes después de cada paso, y la de
recuperación ejecuta schedules de varias transacciones con caídas (también en
medio de recover()) y compara la tabla con un modelo de lo que se confirmó.
El azar sale de un generador con semilla, y cuando una falla, el mensaje dice
la semilla y el paso. Las pruebas privadas son las mismas con otras semillas.
Todas corren en pocos segundos.