Alcance de esta entrega: arquitectura distribuida funcionando + especificación de comunicaciones. Sin replicación, sin seguridad, sin consenso. Eso es Hito 3.
Meta verificable del hito: un archivo entra, se parte en chunks, los chunks quedan repartidos entre DataNodes distintos, y el archivo sale idéntico.
- 0.1 Instalar Go 1.22 o superior. Verificar con
go version. - 0.2 Crear el repositorio con esta estructura:
dfsha/
├── cmd/
│ ├── controlnode/main.go
│ └── datanode/main.go
├── internal/
│ ├── types/
│ ├── datanode/
│ └── controlnode/
├── api/
│ ├── openapi.yaml
│ └── dfsha.proto
├── client/ # Python
│ └── dfsha/
├── docker/
├── DECISIONES.md
└── go.mod
- 0.3
go mod init github.com/<usuario>/dfsha - 0.4 Crear
DECISIONES.mdvacío con el formato de bitácora.
Nota de Go: cmd/ contiene los ejecutables (cada carpeta con su main.go),
internal/ el código que no es importable desde fuera del módulo. Es la convención
estándar y conviene respetarla.
Aquí no hay lógica. Se decide la forma de los datos, que es lo caro de cambiar después.
Archivo: internal/types/types.go
- A.1.1 Definir los identificadores como tipos propios, no como
stringpelado:
type ChunkID string
type ObjectID string
type VersionID string
type NodeID stringPor qué: si todos fueran string, el compilador dejaría pasar un ObjectID donde
se espera un ChunkID. Con tipos propios, ese error no compila.
- A.1.2 Definir
ChunkMeta,Version,Inode. - A.1.3 Definir la constante
DefaultChunkSize = 64 * 1024 * 1024.
Pregunta de diseño: en Version, ¿por qué Chunks es una lista ordenada y no
un mapa? ¿Qué se rompe si se altera el orden?
Archivo: api/openapi.yaml
Solo las seis operaciones del camino feliz. El resto se especifica cuando se implemente.
- A.2.1
POST /v1/obj/open→ entrada: ruta, tamaño total. Salida:upload_id,version_id,chunk_size. - A.2.2
POST /v1/obj/{upload_id}/chunk→ entrada: índice, tamaño. Salida:chunk_id, lista de endpoints destino. - A.2.3
POST /v1/obj/{upload_id}/commit→ entrada: sha256 total. Salida: confirmación. - A.2.4
GET /v1/obj/manifest?path=→ salida: lista ordenada de{chunk_id, index, size, endpoint}+ sha256. - A.2.5
GET /v1/ns?path=yPOST /v1/ns/dirpara el namespace. - A.2.6 Catálogo de errores:
400,404ruta inexistente,409clave ocupada,503sin DataNodes disponibles.
Criterio de aceptación del bloque: alguien que no conozca el código puede implementar un cliente alterno leyendo solo este archivo. Si falta un campo para lograrlo, el contrato está incompleto.
Archivo: api/dfsha.proto
- A.3.1
service DataNodeServiceconHeartbeat(NodeReport) returns (CommandList). - A.3.2 Mensajes
NodeReport(id, capacidad, usado, lista de chunks) yCommand. - A.3.3 Generar el código:
protoc --go_out=. --go-grpc_out=. api/dfsha.proto
Nota: en este hito el heartbeat solo sirve para que el ControlNode sepa qué DataNodes existen y cuánto espacio tienen. Las órdenes vienen en Hito 3, pero el campo se deja declarado desde ahora.
El componente más simple y el mejor para aprender Go. No conoce el namespace:
solo guarda y sirve bytes identificados por chunk_id.
Archivo: internal/datanode/store.go
- B.1.1 Struct
Storecon el directorio base y el tamaño total usado.
type Store struct {
baseDir string
mu sync.RWMutex
used int64
}-
B.1.2
func NewStore(baseDir string) (*Store, error)— crea el directorio si no existe y calcula el espacio usado recorriéndolo. -
B.1.3
func (s *Store) Put(id ChunkID, data []byte) error
Pregunta de diseño que decide la implementación: ¿qué pasa si el proceso muere a mitad de la escritura? Un archivo a medias con nombre válido es peor que ningún archivo, porque el sistema lo creerá bueno.
Pista: la técnica se llama escritura atómica por renombrado: escribes en
<id>.tmp, haces f.Sync(), y solo entonces os.Rename() al nombre definitivo.
El renombrado dentro del mismo sistema de archivos es atómico, así que el archivo
final o existe completo o no existe.
-
B.1.4
func (s *Store) Get(id ChunkID) ([]byte, error)— lee y verifica el checksum antes de devolver. -
B.1.5
func (s *Store) Has(id ChunkID) bool -
B.1.6
func (s *Store) Inventory() ([]ChunkID, error)— lista los chunks presentes en disco. -
B.1.7
func (s *Store) Stats() (used, capacity int64)
Comentario obligatorio: en Put, explicar por qué se escribe a temporal y se
renombra. Si no puedes escribir ese comentario, no entiendes la función todavía.
Archivo: internal/datanode/checksum.go
- B.2.1
func Checksum(data []byte) []byte— SHA-256 del contenido. - B.2.2 Decidir dónde se guarda el checksum: ¿archivo aparte
<id>.sum, o recalculado en cada lectura?
Pregunta: recalcular en cada lectura es más simple pero cuesta CPU en cada get.
Guardarlo aparte es más rápido pero añade un archivo que puede desincronizarse.
Elige y anótalo en DECISIONES.md.
Archivo: internal/datanode/server.go
- B.3.1
func (d *DataNode) handlePutChunk(w http.ResponseWriter, r *http.Request)
Importante: leer el cuerpo con io.Copy hacia el archivo, no con
io.ReadAll. Con chunks de 64 MB y varias subidas concurrentes, ReadAll te come
la memoria. Este es el requisito RI-04.
- B.3.2
func (d *DataNode) handleGetChunk(...)— sirve el chunk por flujo. - B.3.3
func (d *DataNode) handleHealth(...)— devuelve espacio libre, usado y número de chunks. - B.3.4 Registrar rutas:
PUT /chunk/{id},GET /chunk/{id},GET /health.
Archivo: internal/datanode/heartbeat.go
- B.4.1
func (d *DataNode) startHeartbeat(ctx context.Context, interval time.Duration)
Lanza una goroutine con un time.Ticker que cada N segundos envía el NodeReport
al ControlNode.
Punto de diseño que hay que respetar desde ya: el DataNode siempre inicia la conexión. El ControlNode nunca abre una conexión hacia él. Aunque en este hito las órdenes estén vacías, la dirección del control queda fijada.
- B.4.2 Incluir en el reporte el
advertised_endpointconfigurable, distinto de la dirección interna (requisito RD-12).
Archivo: cmd/datanode/main.go
- B.5.1 Leer configuración de variables de entorno:
NODE_ID,DATA_DIR,LISTEN_ADDR,ADVERTISED_ENDPOINT,CONTROL_ADDR,CAPACITY. - B.5.2 Construir
Store, arrancar heartbeat y servidor HTTP. - B.5.3 Apagado ordenado con
signal.NotifyContext.
Criterio de aceptación del bloque B: levantas un DataNode, le haces PUT de un
archivo con curl, lo recuperas con GET, y los sha256sum coinciden. Todavía sin
ControlNode.
Archivo: internal/controlnode/registry.go
- C.1.1 Struct
Registrycon un mapaNodeID → NodeStateprotegido por mutex. - C.1.2
func (r *Registry) Update(report *NodeReport)— registra o actualiza el nodo con su último heartbeat. - C.1.3
func (r *Registry) Alive() []*NodeState— nodos con heartbeat reciente.
Nota de Go: un mapa no es seguro para acceso concurrente. Como el heartbeat
escribe y las peticiones leen a la vez, necesitas sync.RWMutex: RLock() para
leer, Lock() para escribir.
Archivo: internal/controlnode/namespace.go
- C.2.1 Struct
Namespacecon el árbol de inodes en memoria. - C.2.2
func (ns *Namespace) resolve(path string) (*Inode, error)— recorre el árbol segmento a segmento. Escribe esta primero: todas las demás la usan. - C.2.3
func (ns *Namespace) Mkdir(path string) error - C.2.4
func (ns *Namespace) List(path string) ([]*Inode, error) - C.2.5
func (ns *Namespace) Stat(path string) (*Inode, error) - C.2.6
func (ns *Namespace) CreateObject(path string) (*Inode, error) - C.2.7
func (ns *Namespace) Remove(path string) error
Pregunta de diseño: rmdir sobre un directorio con hijos debe fallar. ¿Dónde
haces esa comprobación, y qué error devuelves para que el cliente lo distinga de
"no existe"?
Archivo: internal/controlnode/placement.go
- C.3.1
func (p *Placer) Select(index uint32, r int) ([]*NodeState, error)
En este hito r = 1: sin replicación. Pero el reparto entre nodos ya debe
funcionar, porque es el requisito central del enunciado (RX-11, RX-12).
- C.3.2 Implementar la regla mínima: chunks consecutivos van a nodos distintos.
Deliberadamente simple por ahora. La heurística de dos candidatos con puntuación
es del Hito 3. Pero deja la firma preparada para recibir r > 1.
Archivo: internal/controlnode/allocator.go
- C.4.1 Struct
Uploadconupload_id, ruta, versión en construcción, chunks asignados. - C.4.2
func (a *Allocator) OpenUpload(path string, size uint64) (*Upload, error)— valida que la ruta sea válida y el padre exista. - C.4.3
func (a *Allocator) AllocateChunk(uploadID string, index uint32) (*ChunkPlan, error)— generachunk_idy elige destino. - C.4.4
func (a *Allocator) Commit(uploadID string, sha []byte) error— publica la versión; es la última operación y debe ser atómica. - C.4.5
func (a *Allocator) Manifest(path string) (*Manifest, error)— devuelve la lista ordenada de chunks con sus endpoints.
Pregunta central: hasta el Commit, ¿qué ve un cliente que hace get de esa
misma ruta? La respuesta define todo el modelo de consistencia.
Archivo: internal/controlnode/server.go
- C.5.1 Handlers de las seis operaciones de
openapi.yaml. - C.5.2 Servidor gRPC para recibir heartbeats.
- C.5.3 Middleware de logging que imprima cada asignación de chunk con su destino (requisito RC-08, el profesor lo pidió explícitamente).
Archivo: cmd/controlnode/main.go
- C.6.1 Configuración por variables de entorno.
- C.6.2 Arrancar HTTP y gRPC en puertos distintos, cada uno en su goroutine.
Criterio de aceptación del bloque C: con curl puedes hacer mkdir, ls, abrir
una carga, pedir asignación de tres chunks y ver que el ControlNode devuelve
DataNodes distintos para cada uno.
Archivo: client/dfsha/chunker.py
- D.1.1
def split_file(path, chunk_size) -> Iterator[tuple[int, bytes]]
Genera (índice, datos). El último chunk es más corto y no se rellena.
Esta es la función que el profesor mencionó con la historia del .exe mal
particionado. Escríbela con cuidado y coméntala bien.
- D.1.2
def sha256_of_file(path) -> str - D.1.3
def sha256_of_bytes(data) -> str
Prueba inmediata: partir un archivo, reensamblar los chunks en orden, comparar hashes. Antes de tocar la red.
Archivo: client/dfsha/api.py
- D.2.1 Clase
ControlClientcon un método por operación del contrato. - D.2.2 Clase
DataClientconput_chunkyget_chunk. - D.2.3 Manejo de errores que distinga reintentable de no reintentable.
Archivo: client/dfsha/transfer.py
- D.3.1
def put(local_path, remote_path)—open→ bucle deallocate+ subida →commit. - D.3.2 Log por cada chunk: índice, tamaño, DataNode destino, tiempo.
- D.3.3
def get(remote_path, local_path)—manifest→ descargas en paralelo → reensamblar en orden → verificar hash.
Para el paralelismo: ThreadPoolExecutor. Descarga concurrente, escritura ordenada.
Pregunta: si descargas en paralelo pero escribes en orden, ¿dónde guardas los chunks que llegan antes de que les toque? ¿Qué pasa con la memoria si el archivo tiene 240 chunks de 64 MB?
Archivo: client/dfsha/shell.py
- D.4.1 REPL con
ls,cd,mkdir,rmdir,rm,stat,put,get,exit. - D.4.2 El directorio de trabajo es estado del cliente, no del servidor.
- D.4.3 Resolución de rutas relativas, absolutas,
.y...
- E.1.1
docker/Dockerfile.controlnodeydocker/Dockerfile.datanodecon build multi-etapa. - E.1.2
docker-compose.ymlcon 1 ControlNode y 4 DataNodes, cada uno con su volumen.
- E.2.1 Levantar el clúster, crear directorios, listar.
- E.2.2
putde un archivo grande; capturar el log que muestra el reparto. - E.2.3 Entrar a los volúmenes de los 4 DataNodes y comprobar que cada uno tiene chunks distintos. Esta es la evidencia de RX-12.
- E.2.4
gety compararsha256sumcon el original. - E.2.5 Registrar todo en
DECISIONES.md.
| Día | Bloque |
|---|---|
| 1 | A completo (tipos y contrato) |
| 2–3 | B.1 a B.3 (almacén y servidor del DataNode) |
| 4 | B.4, B.5 y C.1 (heartbeat y registro) |
| 5–6 | C.2 a C.4 (namespace y asignación) |
| 7 | C.5, C.6 |
| 8–9 | D completo (cliente) |
| 10 | E (docker y verificación) |
Si el tiempo se acorta: el namespace puede quedar solo en memoria, sin WAL, y se declara como limitación conocida. Lo que no se puede sacrificar es el reparto real de chunks entre nodos y la verificación de integridad.
- ¿Por qué el último chunk no se rellena?
- ¿Por qué
Putescribe a temporal y renombra? - ¿Por qué el DataNode inicia el heartbeat y no al revés?
- ¿Por qué los datos no pasan por el ControlNode?
- ¿Qué ve un lector antes del
commit? - ¿Por qué el DataNode no conoce el namespace?
- ¿Por qué
io.Copyy noio.ReadAll? - ¿Cómo garantizas que dos chunks consecutivos no caigan en el mismo nodo?