initial commit
This commit is contained in:
@@ -0,0 +1,720 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base32"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/metazla/meta-core/internal/leader"
|
||||
)
|
||||
|
||||
// contentTypeByExt maps file extensions to MIME types
|
||||
var contentTypeByExt = map[string]string{
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".mp4": "video/mp4",
|
||||
".mkv": "video/x-matroska",
|
||||
".avi": "video/x-msvideo",
|
||||
".webm": "video/webm",
|
||||
".mov": "video/quicktime",
|
||||
".ts": "video/mp2t",
|
||||
".m3u8": "application/vnd.apple.mpegurl",
|
||||
}
|
||||
|
||||
// HealthResponse is the response for /health
|
||||
type HealthResponse struct {
|
||||
Status string `json:"status"`
|
||||
Role string `json:"role"`
|
||||
Redis bool `json:"redis"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Leader *leader.LeaderLockInfo `json:"leader,omitempty"`
|
||||
}
|
||||
|
||||
// StatusResponse is the response for /status
|
||||
type StatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
Role string `json:"role"`
|
||||
Redis bool `json:"redis"`
|
||||
ServiceName string `json:"serviceName"`
|
||||
Version string `json:"version"`
|
||||
Uptime int64 `json:"uptimeSeconds"`
|
||||
FileCount int `json:"fileCount"`
|
||||
Leader *leader.LeaderLockInfo `json:"leader,omitempty"`
|
||||
}
|
||||
|
||||
// MetadataResponse is the response for /meta/{hash}
|
||||
type MetadataResponse struct {
|
||||
HashID string `json:"hashId"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
// DataPathResponse is the response for /data/{hash}/path
|
||||
type DataPathResponse struct {
|
||||
HashID string `json:"hashId"`
|
||||
Path string `json:"path"`
|
||||
Exists bool `json:"exists"`
|
||||
}
|
||||
|
||||
// ErrorResponse is the response for errors
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
var startTime = time.Now()
|
||||
|
||||
// handleHealth handles GET /health
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
response := HealthResponse{
|
||||
Status: "ok",
|
||||
Role: string(s.election.Role()),
|
||||
Redis: s.storage.Health(),
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
Leader: s.election.LeaderInfo(),
|
||||
}
|
||||
|
||||
if !response.Redis {
|
||||
response.Status = "degraded"
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// handleStatus handles GET /status
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
fileCount := 0
|
||||
if s.storage.IsConnected() {
|
||||
count, _ := s.storage.CountFiles()
|
||||
fileCount = count
|
||||
}
|
||||
|
||||
response := StatusResponse{
|
||||
Status: "ok",
|
||||
Role: string(s.election.Role()),
|
||||
Redis: s.storage.Health(),
|
||||
ServiceName: s.config.ServiceName,
|
||||
Version: s.config.ServiceVersion,
|
||||
Uptime: int64(time.Since(startTime).Seconds()),
|
||||
FileCount: fileCount,
|
||||
Leader: s.election.LeaderInfo(),
|
||||
}
|
||||
|
||||
if !response.Redis {
|
||||
response.Status = "degraded"
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// handleLeader handles GET /leader
|
||||
func (s *Server) handleLeader(w http.ResponseWriter, r *http.Request) {
|
||||
info := s.election.LeaderInfo()
|
||||
if info == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "no leader available")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, info)
|
||||
}
|
||||
|
||||
// handleRole handles GET /role
|
||||
func (s *Server) handleRole(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"role": string(s.election.Role()),
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetMeta handles GET /meta/{hash}
|
||||
func (s *Server) handleGetMeta(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
hashID := vars["hash"]
|
||||
|
||||
if hashID == "" {
|
||||
writeError(w, http.StatusBadRequest, "hash is required")
|
||||
return
|
||||
}
|
||||
|
||||
if !s.storage.IsConnected() {
|
||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||
return
|
||||
}
|
||||
|
||||
metadata, err := s.storage.GetMetadataFlat(hashID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if metadata == nil {
|
||||
writeError(w, http.StatusNotFound, "metadata not found")
|
||||
return
|
||||
}
|
||||
|
||||
response := MetadataResponse{
|
||||
HashID: hashID,
|
||||
Metadata: metadata,
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// handlePutMeta handles PUT /meta/{hash}
|
||||
func (s *Server) handlePutMeta(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
hashID := vars["hash"]
|
||||
|
||||
if hashID == "" {
|
||||
writeError(w, http.StatusBadRequest, "hash is required")
|
||||
return
|
||||
}
|
||||
|
||||
if !s.storage.IsConnected() {
|
||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||
return
|
||||
}
|
||||
|
||||
var metadata map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&metadata); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.storage.SetMetadataFlat(hashID, metadata); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"success": true,
|
||||
"hashId": hashID,
|
||||
})
|
||||
}
|
||||
|
||||
// handleDeleteMeta handles DELETE /meta/{hash}
|
||||
func (s *Server) handleDeleteMeta(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
hashID := vars["hash"]
|
||||
|
||||
if hashID == "" {
|
||||
writeError(w, http.StatusBadRequest, "hash is required")
|
||||
return
|
||||
}
|
||||
|
||||
if !s.storage.IsConnected() {
|
||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||
return
|
||||
}
|
||||
|
||||
deleted, err := s.storage.DeleteMetadata(hashID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"success": true,
|
||||
"hashId": hashID,
|
||||
"deleted": deleted,
|
||||
})
|
||||
}
|
||||
|
||||
// handleListMeta handles GET /meta
|
||||
func (s *Server) handleListMeta(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.storage.IsConnected() {
|
||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||
return
|
||||
}
|
||||
|
||||
hashIDs, err := s.storage.GetAllHashIDs()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"hashIds": hashIDs,
|
||||
"count": len(hashIDs),
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetDataPath handles GET /data/{hash}/path
|
||||
func (s *Server) handleGetDataPath(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
hashID := vars["hash"]
|
||||
|
||||
if hashID == "" {
|
||||
writeError(w, http.StatusBadRequest, "hash is required")
|
||||
return
|
||||
}
|
||||
|
||||
if !s.storage.IsConnected() {
|
||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||
return
|
||||
}
|
||||
|
||||
// Get the file path from metadata
|
||||
filePath, err := s.storage.GetProperty(hashID, "filePath")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if filePath == "" {
|
||||
writeError(w, http.StatusNotFound, "file path not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
fullPath := s.config.FilesPath + "/" + filePath
|
||||
_, statErr := os.Stat(fullPath)
|
||||
exists := statErr == nil
|
||||
|
||||
response := DataPathResponse{
|
||||
HashID: hashID,
|
||||
Path: fullPath,
|
||||
Exists: exists,
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// handleHeadData handles HEAD /data/{hash}
|
||||
func (s *Server) handleHeadData(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
hashID := vars["hash"]
|
||||
|
||||
if hashID == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !s.storage.IsConnected() {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the file path from metadata
|
||||
filePath, err := s.storage.GetProperty(hashID, "filePath")
|
||||
if err != nil || filePath == "" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
fullPath := s.config.FilesPath + "/" + filePath
|
||||
if _, err := os.Stat(fullPath); err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// handleGetFileByCID handles GET /file/{cid}
|
||||
// Serves a file by looking up its CID in poster/backdrop metadata
|
||||
func (s *Server) handleGetFileByCID(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
cid := vars["cid"]
|
||||
|
||||
if cid == "" {
|
||||
writeError(w, http.StatusBadRequest, "cid is required")
|
||||
return
|
||||
}
|
||||
|
||||
if !s.storage.IsConnected() {
|
||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||
return
|
||||
}
|
||||
|
||||
// Look up the file path by CID
|
||||
relPath, err := s.storage.LookupPathByCID(cid)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if relPath == "" {
|
||||
writeError(w, http.StatusNotFound, "file not found for CID")
|
||||
return
|
||||
}
|
||||
|
||||
// Construct full path
|
||||
fullPath := filepath.Join(s.config.FilesPath, relPath)
|
||||
|
||||
// Check if file exists
|
||||
fileInfo, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "file does not exist on disk")
|
||||
return
|
||||
}
|
||||
|
||||
// Determine content type from extension
|
||||
ext := strings.ToLower(filepath.Ext(fullPath))
|
||||
contentType, ok := contentTypeByExt[ext]
|
||||
if !ok {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
// Open the file
|
||||
file, err := os.Open(fullPath)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to open file")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Set content type header
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
|
||||
// Serve the file (supports range requests)
|
||||
http.ServeContent(w, r, fileInfo.Name(), fileInfo.ModTime(), file)
|
||||
}
|
||||
|
||||
// handleListServices handles GET /services
|
||||
func (s *Server) handleListServices(w http.ResponseWriter, r *http.Request) {
|
||||
services, err := s.discovery.DiscoverAll()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"services": services,
|
||||
"count": len(services),
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetService handles GET /services/{name}
|
||||
func (s *Server) handleGetService(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
name := vars["name"]
|
||||
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "service name is required")
|
||||
return
|
||||
}
|
||||
|
||||
service, err := s.discovery.Discover(name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if service == nil {
|
||||
writeError(w, http.StatusNotFound, "service not found")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, service)
|
||||
}
|
||||
|
||||
// writeJSON writes a JSON response
|
||||
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
// writeError writes an error response
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, ErrorResponse{
|
||||
Error: http.StatusText(status),
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// handlePatchMeta handles PATCH /meta/{hash}
|
||||
// Merges the provided metadata into existing (does not delete missing keys)
|
||||
func (s *Server) handlePatchMeta(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
hashID := vars["hash"]
|
||||
|
||||
if hashID == "" {
|
||||
writeError(w, http.StatusBadRequest, "hash is required")
|
||||
return
|
||||
}
|
||||
|
||||
if !s.storage.IsConnected() {
|
||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||
return
|
||||
}
|
||||
|
||||
var metadata map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&metadata); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.storage.MergeMetadataFlat(hashID, metadata)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"success": true,
|
||||
"hashId": hashID,
|
||||
"updated": updated,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetProperty handles GET /meta/{hash}/{key}
|
||||
// Gets a single property value
|
||||
func (s *Server) handleGetProperty(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
hashID := vars["hash"]
|
||||
key := vars["key"]
|
||||
|
||||
if hashID == "" {
|
||||
writeError(w, http.StatusBadRequest, "hash is required")
|
||||
return
|
||||
}
|
||||
|
||||
if key == "" {
|
||||
writeError(w, http.StatusBadRequest, "key is required")
|
||||
return
|
||||
}
|
||||
|
||||
if !s.storage.IsConnected() {
|
||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||
return
|
||||
}
|
||||
|
||||
value, err := s.storage.GetProperty(hashID, key)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if value == "" {
|
||||
writeError(w, http.StatusNotFound, "property not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Return plain text for single property
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(value))
|
||||
}
|
||||
|
||||
// handlePutProperty handles PUT /meta/{hash}/{key}
|
||||
// Sets a single property value
|
||||
func (s *Server) handlePutProperty(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
hashID := vars["hash"]
|
||||
key := vars["key"]
|
||||
|
||||
if hashID == "" {
|
||||
writeError(w, http.StatusBadRequest, "hash is required")
|
||||
return
|
||||
}
|
||||
|
||||
if key == "" {
|
||||
writeError(w, http.StatusBadRequest, "key is required")
|
||||
return
|
||||
}
|
||||
|
||||
if !s.storage.IsConnected() {
|
||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||
return
|
||||
}
|
||||
|
||||
// Read body as plain text
|
||||
buf := make([]byte, 1024*1024) // 1MB max
|
||||
n, err := r.Body.Read(buf)
|
||||
if err != nil && err.Error() != "EOF" {
|
||||
writeError(w, http.StatusBadRequest, "failed to read body")
|
||||
return
|
||||
}
|
||||
value := string(buf[:n])
|
||||
|
||||
if err := s.storage.SetProperty(hashID, key, value); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"success": true,
|
||||
"hashId": hashID,
|
||||
"property": key,
|
||||
})
|
||||
}
|
||||
|
||||
// handleDeleteProperty handles DELETE /meta/{hash}/{key}
|
||||
// Deletes a single property
|
||||
func (s *Server) handleDeleteProperty(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
hashID := vars["hash"]
|
||||
key := vars["key"]
|
||||
|
||||
if hashID == "" {
|
||||
writeError(w, http.StatusBadRequest, "hash is required")
|
||||
return
|
||||
}
|
||||
|
||||
if key == "" {
|
||||
writeError(w, http.StatusBadRequest, "key is required")
|
||||
return
|
||||
}
|
||||
|
||||
if !s.storage.IsConnected() {
|
||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.storage.DeleteProperty(hashID, key); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"success": true,
|
||||
"hashId": hashID,
|
||||
"property": key,
|
||||
})
|
||||
}
|
||||
|
||||
// handleAddToSet handles POST /meta/{hash}/_add/{key}
|
||||
// Adds a value to a set-type field
|
||||
func (s *Server) handleAddToSet(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
hashID := vars["hash"]
|
||||
key := vars["key"]
|
||||
|
||||
if hashID == "" {
|
||||
writeError(w, http.StatusBadRequest, "hash is required")
|
||||
return
|
||||
}
|
||||
|
||||
if key == "" {
|
||||
writeError(w, http.StatusBadRequest, "key is required")
|
||||
return
|
||||
}
|
||||
|
||||
if !s.storage.IsConnected() {
|
||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||
return
|
||||
}
|
||||
|
||||
// Read body as plain text
|
||||
buf := make([]byte, 1024*1024) // 1MB max
|
||||
n, err := r.Body.Read(buf)
|
||||
if err != nil && err.Error() != "EOF" {
|
||||
writeError(w, http.StatusBadRequest, "failed to read body")
|
||||
return
|
||||
}
|
||||
value := string(buf[:n])
|
||||
|
||||
if value == "" {
|
||||
writeError(w, http.StatusBadRequest, "value is required")
|
||||
return
|
||||
}
|
||||
|
||||
added, err := s.storage.AddToSet(hashID, key, value)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"success": true,
|
||||
"hashId": hashID,
|
||||
"property": key,
|
||||
"added": added,
|
||||
})
|
||||
}
|
||||
|
||||
// CIDRequest is the request body for POST /file/cid
|
||||
type CIDRequest struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// CIDResponse is the response for POST /file/cid
|
||||
type CIDResponse struct {
|
||||
CID string `json:"cid"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// handleComputeFileCID handles POST /file/cid
|
||||
// Computes the IPFS-compatible CIDv1 (sha256) for a file
|
||||
func (s *Server) handleComputeFileCID(w http.ResponseWriter, r *http.Request) {
|
||||
var req CIDRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Path == "" {
|
||||
writeError(w, http.StatusBadRequest, "path is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Construct full path - path should be relative to FILES_PATH
|
||||
fullPath := filepath.Join(s.config.FilesPath, req.Path)
|
||||
|
||||
// Security check: ensure path is within FILES_PATH
|
||||
absFilesPath, _ := filepath.Abs(s.config.FilesPath)
|
||||
absFullPath, _ := filepath.Abs(fullPath)
|
||||
if !strings.HasPrefix(absFullPath, absFilesPath) {
|
||||
writeError(w, http.StatusBadRequest, "path must be within files directory")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
fileInfo, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "file not found")
|
||||
return
|
||||
}
|
||||
|
||||
if fileInfo.IsDir() {
|
||||
writeError(w, http.StatusBadRequest, "path is a directory, not a file")
|
||||
return
|
||||
}
|
||||
|
||||
// Open and hash the file
|
||||
file, err := os.Open(fullPath)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to open file")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Compute SHA-256 hash
|
||||
hasher := sha256.New()
|
||||
if _, err := io.Copy(hasher, file); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to read file")
|
||||
return
|
||||
}
|
||||
hashBytes := hasher.Sum(nil)
|
||||
|
||||
// Build CIDv1 with raw codec (0x55) and sha256 multihash
|
||||
// CIDv1 format: version (0x01) + codec (0x55) + multihash
|
||||
// Multihash format: hash-code (0x12) + length (0x20) + hash
|
||||
cidBytes := make([]byte, 0, 2+2+32)
|
||||
cidBytes = append(cidBytes, 0x01) // CIDv1
|
||||
cidBytes = append(cidBytes, 0x55) // raw codec
|
||||
cidBytes = append(cidBytes, 0x12) // sha256 code
|
||||
cidBytes = append(cidBytes, 0x20) // 32 bytes
|
||||
cidBytes = append(cidBytes, hashBytes...)
|
||||
|
||||
// Encode as base32lower with 'b' prefix (multibase)
|
||||
cid := "b" + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(cidBytes))
|
||||
|
||||
writeJSON(w, http.StatusOK, CIDResponse{
|
||||
CID: cid,
|
||||
Path: req.Path,
|
||||
Size: fileInfo.Size(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/metazla/meta-core/internal/config"
|
||||
"github.com/metazla/meta-core/internal/discovery"
|
||||
"github.com/metazla/meta-core/internal/leader"
|
||||
"github.com/metazla/meta-core/internal/storage"
|
||||
)
|
||||
|
||||
// Server is the HTTP API server for meta-core
|
||||
type Server struct {
|
||||
config *config.Config
|
||||
election *leader.Election
|
||||
discovery *discovery.Service
|
||||
storage *storage.Client
|
||||
router *mux.Router
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
// NewServer creates a new API server
|
||||
func NewServer(
|
||||
cfg *config.Config,
|
||||
election *leader.Election,
|
||||
disc *discovery.Service,
|
||||
stor *storage.Client,
|
||||
) *Server {
|
||||
s := &Server{
|
||||
config: cfg,
|
||||
election: election,
|
||||
discovery: disc,
|
||||
storage: stor,
|
||||
router: mux.NewRouter(),
|
||||
}
|
||||
|
||||
s.setupRoutes()
|
||||
return s
|
||||
}
|
||||
|
||||
// setupRoutes configures all API routes
|
||||
func (s *Server) setupRoutes() {
|
||||
// Health and status
|
||||
s.router.HandleFunc("/health", s.handleHealth).Methods("GET")
|
||||
s.router.HandleFunc("/status", s.handleStatus).Methods("GET")
|
||||
s.router.HandleFunc("/leader", s.handleLeader).Methods("GET")
|
||||
s.router.HandleFunc("/role", s.handleRole).Methods("GET")
|
||||
|
||||
// Metadata operations - base endpoints
|
||||
s.router.HandleFunc("/meta/{hash}", s.handleGetMeta).Methods("GET")
|
||||
s.router.HandleFunc("/meta/{hash}", s.handlePutMeta).Methods("PUT")
|
||||
s.router.HandleFunc("/meta/{hash}", s.handlePatchMeta).Methods("PATCH")
|
||||
s.router.HandleFunc("/meta/{hash}", s.handleDeleteMeta).Methods("DELETE")
|
||||
s.router.HandleFunc("/meta", s.handleListMeta).Methods("GET")
|
||||
|
||||
// Metadata operations - set operations (must be before property routes)
|
||||
s.router.HandleFunc("/meta/{hash}/_add/{key:.*}", s.handleAddToSet).Methods("POST")
|
||||
|
||||
// Metadata operations - property-level (key can contain slashes)
|
||||
s.router.HandleFunc("/meta/{hash}/{key:.*}", s.handleGetProperty).Methods("GET")
|
||||
s.router.HandleFunc("/meta/{hash}/{key:.*}", s.handlePutProperty).Methods("PUT")
|
||||
s.router.HandleFunc("/meta/{hash}/{key:.*}", s.handleDeleteProperty).Methods("DELETE")
|
||||
|
||||
// Data operations
|
||||
s.router.HandleFunc("/data/{hash}/path", s.handleGetDataPath).Methods("GET")
|
||||
s.router.HandleFunc("/data/{hash}", s.handleHeadData).Methods("HEAD")
|
||||
|
||||
// File operations (by CID)
|
||||
s.router.HandleFunc("/file/{cid}", s.handleGetFileByCID).Methods("GET")
|
||||
s.router.HandleFunc("/file/cid", s.handleComputeFileCID).Methods("POST")
|
||||
|
||||
// Service discovery
|
||||
s.router.HandleFunc("/services", s.handleListServices).Methods("GET")
|
||||
s.router.HandleFunc("/services/{name}", s.handleGetService).Methods("GET")
|
||||
|
||||
// Add middleware
|
||||
s.router.Use(loggingMiddleware)
|
||||
s.router.Use(corsMiddleware)
|
||||
}
|
||||
|
||||
// Start starts the HTTP server
|
||||
func (s *Server) Start() error {
|
||||
addr := fmt.Sprintf("%s:%d", s.config.HTTPHost, s.config.HTTPPort)
|
||||
|
||||
s.server = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: s.router,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
log.Printf("[API] Starting HTTP server on %s", addr)
|
||||
|
||||
go func() {
|
||||
if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("[API] HTTP server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully stops the HTTP server
|
||||
func (s *Server) Stop() error {
|
||||
if s.server == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Println("[API] Stopping HTTP server...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
return s.server.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// loggingMiddleware logs all requests
|
||||
func loggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
log.Printf("[API] %s %s %s", r.Method, r.URL.Path, time.Since(start))
|
||||
})
|
||||
}
|
||||
|
||||
// corsMiddleware adds CORS headers
|
||||
func corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Config holds all configuration for meta-core
|
||||
type Config struct {
|
||||
// Core paths
|
||||
MetaCorePath string // Path to meta-core volume (default: /meta-core)
|
||||
FilesPath string // Path to files volume (default: /files)
|
||||
|
||||
// Service identification
|
||||
ServiceName string // Service name (e.g., "meta-sort", "meta-fuse")
|
||||
ServiceVersion string // Service version (default: "1.0.0")
|
||||
APIPort int // Service HTTP port (for leader info)
|
||||
BaseURL string // Base URL for stable service discovery
|
||||
|
||||
// Redis configuration
|
||||
RedisPort int // Redis port (default: 6379)
|
||||
|
||||
// HTTP API configuration
|
||||
HTTPPort int // HTTP API port (default: 9000)
|
||||
HTTPHost string // HTTP API host (default: "127.0.0.1")
|
||||
|
||||
// Timing configuration
|
||||
HealthCheckIntervalMS int // Health check interval in ms (default: 5000)
|
||||
HeartbeatIntervalMS int // Service heartbeat interval in ms (default: 30000)
|
||||
StaleThresholdMS int // Stale service threshold in ms (default: 60000)
|
||||
}
|
||||
|
||||
// Load creates a Config from environment variables
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
MetaCorePath: getEnv("META_CORE_PATH", "/meta-core"),
|
||||
FilesPath: getEnv("FILES_PATH", "/files"),
|
||||
ServiceName: getEnv("SERVICE_NAME", "meta-core"),
|
||||
ServiceVersion: getEnv("SERVICE_VERSION", "1.0.0"),
|
||||
APIPort: getEnvInt("API_PORT", 8180),
|
||||
BaseURL: getEnv("BASE_URL", ""),
|
||||
RedisPort: getEnvInt("REDIS_PORT", 6379),
|
||||
HTTPPort: getEnvInt("META_CORE_HTTP_PORT", 9000),
|
||||
HTTPHost: getEnv("META_CORE_HTTP_HOST", "127.0.0.1"),
|
||||
HealthCheckIntervalMS: getEnvInt("HEALTH_CHECK_INTERVAL_MS", 5000),
|
||||
HeartbeatIntervalMS: getEnvInt("HEARTBEAT_INTERVAL_MS", 30000),
|
||||
StaleThresholdMS: getEnvInt("STALE_THRESHOLD_MS", 60000),
|
||||
}
|
||||
}
|
||||
|
||||
// LockFilePath returns the path to the leader lock file
|
||||
func (c *Config) LockFilePath() string {
|
||||
return c.MetaCorePath + "/locks/kv-leader.lock"
|
||||
}
|
||||
|
||||
// InfoFilePath returns the path to the leader info file
|
||||
func (c *Config) InfoFilePath() string {
|
||||
return c.MetaCorePath + "/locks/kv-leader.info"
|
||||
}
|
||||
|
||||
// RedisDataDir returns the path to Redis data directory
|
||||
func (c *Config) RedisDataDir() string {
|
||||
return c.MetaCorePath + "/db/redis"
|
||||
}
|
||||
|
||||
// ServicesDir returns the path to services directory
|
||||
func (c *Config) ServicesDir() string {
|
||||
return c.MetaCorePath + "/services"
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvInt(key string, defaultValue int) int {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
if intVal, err := strconv.Atoi(value); err == nil {
|
||||
return intVal
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/metazla/meta-core/internal/config"
|
||||
)
|
||||
|
||||
// ServiceInfo matches the TypeScript ServiceInfo interface
|
||||
type ServiceInfo struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
API string `json:"api"`
|
||||
Status string `json:"status"`
|
||||
PID int `json:"pid"`
|
||||
Hostname string `json:"hostname"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
LastHeartbeat string `json:"lastHeartbeat"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Endpoints map[string]string `json:"endpoints"`
|
||||
}
|
||||
|
||||
// Service handles service registration and discovery
|
||||
type Service struct {
|
||||
config *config.Config
|
||||
servicesDir string
|
||||
serviceFile string
|
||||
info *ServiceInfo
|
||||
mu sync.RWMutex
|
||||
|
||||
stopChan chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewService creates a new service discovery instance
|
||||
func NewService(cfg *config.Config) *Service {
|
||||
return &Service{
|
||||
config: cfg,
|
||||
servicesDir: cfg.ServicesDir(),
|
||||
serviceFile: filepath.Join(cfg.ServicesDir(), cfg.ServiceName+".json"),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins service registration and heartbeat
|
||||
func (s *Service) Start() error {
|
||||
log.Printf("[Discovery] Starting service discovery for %s", s.config.ServiceName)
|
||||
|
||||
// Ensure services directory exists
|
||||
if err := os.MkdirAll(s.servicesDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create services directory: %w", err)
|
||||
}
|
||||
|
||||
// Build and register service info
|
||||
s.info = s.buildServiceInfo()
|
||||
if err := s.register(); err != nil {
|
||||
return fmt.Errorf("failed to register service: %w", err)
|
||||
}
|
||||
|
||||
// Start heartbeat loop
|
||||
s.wg.Add(1)
|
||||
go s.heartbeatLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops service discovery and unregisters
|
||||
func (s *Service) Stop() error {
|
||||
log.Println("[Discovery] Stopping service discovery...")
|
||||
close(s.stopChan)
|
||||
s.wg.Wait()
|
||||
|
||||
// Unregister by removing service file
|
||||
if err := os.Remove(s.serviceFile); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("[Discovery] Failed to remove service file: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildServiceInfo creates the service info for this instance
|
||||
func (s *Service) buildServiceInfo() *ServiceInfo {
|
||||
hostname, _ := os.Hostname()
|
||||
ip := getLocalIP()
|
||||
|
||||
return &ServiceInfo{
|
||||
Name: s.config.ServiceName,
|
||||
Version: s.config.ServiceVersion,
|
||||
API: fmt.Sprintf("http://%s:%d", ip, s.config.APIPort),
|
||||
Status: "running",
|
||||
PID: os.Getpid(),
|
||||
Hostname: hostname,
|
||||
StartedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
LastHeartbeat: time.Now().UTC().Format(time.RFC3339),
|
||||
Capabilities: []string{"meta-core"},
|
||||
Endpoints: map[string]string{
|
||||
"health": fmt.Sprintf("http://%s:%d/health", ip, s.config.HTTPPort),
|
||||
"meta": fmt.Sprintf("http://%s:%d/meta", ip, s.config.HTTPPort),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// register writes service info to file
|
||||
func (s *Service) register() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
return s.writeServiceInfo(s.info)
|
||||
}
|
||||
|
||||
// writeServiceInfo atomically writes service info to file
|
||||
func (s *Service) writeServiceInfo(info *ServiceInfo) error {
|
||||
tempPath := s.serviceFile + ".tmp"
|
||||
|
||||
data, err := json.MarshalIndent(info, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(tempPath, data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.Rename(tempPath, s.serviceFile)
|
||||
}
|
||||
|
||||
// heartbeatLoop periodically updates the last heartbeat
|
||||
func (s *Service) heartbeatLoop() {
|
||||
defer s.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(time.Duration(s.config.HeartbeatIntervalMS) * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.stopChan:
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.heartbeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// heartbeat updates the last heartbeat timestamp
|
||||
func (s *Service) heartbeat() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.info == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.info.LastHeartbeat = time.Now().UTC().Format(time.RFC3339)
|
||||
if err := s.writeServiceInfo(s.info); err != nil {
|
||||
log.Printf("[Discovery] Failed to update heartbeat: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateStatus updates the service status
|
||||
func (s *Service) UpdateStatus(status string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.info == nil {
|
||||
return fmt.Errorf("service not registered")
|
||||
}
|
||||
|
||||
s.info.Status = status
|
||||
s.info.LastHeartbeat = time.Now().UTC().Format(time.RFC3339)
|
||||
return s.writeServiceInfo(s.info)
|
||||
}
|
||||
|
||||
// Discover finds a service by name
|
||||
func (s *Service) Discover(name string) (*ServiceInfo, error) {
|
||||
filePath := filepath.Join(s.servicesDir, name+".json")
|
||||
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var info ServiceInfo
|
||||
if err := json.Unmarshal(data, &info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if service is stale
|
||||
lastHeartbeat, err := time.Parse(time.RFC3339, info.LastHeartbeat)
|
||||
if err == nil {
|
||||
staleThreshold := time.Duration(s.config.StaleThresholdMS) * time.Millisecond
|
||||
if time.Since(lastHeartbeat) > staleThreshold {
|
||||
info.Status = "stale"
|
||||
}
|
||||
}
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// DiscoverAll finds all registered services
|
||||
func (s *Service) DiscoverAll() ([]*ServiceInfo, error) {
|
||||
entries, err := os.ReadDir(s.servicesDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []*ServiceInfo{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var services []*ServiceInfo
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
|
||||
name := entry.Name()[:len(entry.Name())-5] // Remove .json extension
|
||||
info, err := s.Discover(name)
|
||||
if err != nil {
|
||||
log.Printf("[Discovery] Failed to read service %s: %v", name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if info != nil {
|
||||
services = append(services, info)
|
||||
}
|
||||
}
|
||||
|
||||
return services, nil
|
||||
}
|
||||
|
||||
// getLocalIP returns the local IP address
|
||||
func getLocalIP() string {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
hostname, _ := os.Hostname()
|
||||
return hostname
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
|
||||
if ipnet.IP.To4() != nil {
|
||||
return ipnet.IP.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hostname, _ := os.Hostname()
|
||||
return hostname
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
package leader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/metazla/meta-core/internal/config"
|
||||
)
|
||||
|
||||
// LeaderLockInfo matches the TypeScript LeaderLockInfo interface
|
||||
type LeaderLockInfo struct {
|
||||
Host string `json:"host"`
|
||||
API string `json:"api"`
|
||||
HTTP string `json:"http"`
|
||||
BaseURL string `json:"baseUrl,omitempty"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
PID int `json:"pid"`
|
||||
}
|
||||
|
||||
// Role represents the current role of this instance
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleUnknown Role = "unknown"
|
||||
RoleLeader Role = "leader"
|
||||
RoleFollower Role = "follower"
|
||||
)
|
||||
|
||||
// Election handles leader election using flock on shared filesystem
|
||||
type Election struct {
|
||||
config *config.Config
|
||||
|
||||
lockFile *os.File
|
||||
role Role
|
||||
leaderInfo *LeaderLockInfo
|
||||
mu sync.RWMutex
|
||||
|
||||
redisManager *RedisManager
|
||||
storage StorageConnector
|
||||
|
||||
// Callbacks
|
||||
onBecomeLeader func()
|
||||
onBecomeFollower func(info *LeaderLockInfo)
|
||||
onLeaderLost func()
|
||||
|
||||
// Lifecycle
|
||||
stopChan chan struct{}
|
||||
isShuttingDown bool
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// StorageConnector interface for connecting to Redis
|
||||
type StorageConnector interface {
|
||||
Connect(url string) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
// NewElection creates a new leader election instance
|
||||
func NewElection(cfg *config.Config) *Election {
|
||||
return &Election{
|
||||
config: cfg,
|
||||
role: RoleUnknown,
|
||||
redisManager: NewRedisManager(cfg),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// SetStorageConnector sets the storage connector for Redis connections
|
||||
func (e *Election) SetStorageConnector(s StorageConnector) {
|
||||
e.storage = s
|
||||
}
|
||||
|
||||
// OnBecomeLeader sets callback for when this instance becomes leader
|
||||
func (e *Election) OnBecomeLeader(fn func()) {
|
||||
e.onBecomeLeader = fn
|
||||
}
|
||||
|
||||
// OnBecomeFollower sets callback for when this instance becomes follower
|
||||
func (e *Election) OnBecomeFollower(fn func(info *LeaderLockInfo)) {
|
||||
e.onBecomeFollower = fn
|
||||
}
|
||||
|
||||
// OnLeaderLost sets callback for when leadership is lost
|
||||
func (e *Election) OnLeaderLost(fn func()) {
|
||||
e.onLeaderLost = fn
|
||||
}
|
||||
|
||||
// Start begins the leader election process
|
||||
func (e *Election) Start() error {
|
||||
log.Println("[Election] Starting leader election...")
|
||||
|
||||
// Ensure lock directory exists
|
||||
lockDir := filepath.Dir(e.config.LockFilePath())
|
||||
if err := os.MkdirAll(lockDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create lock directory: %w", err)
|
||||
}
|
||||
|
||||
// Try to acquire the lock
|
||||
acquired, err := e.tryAcquireLock()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to acquire lock: %w", err)
|
||||
}
|
||||
|
||||
if acquired {
|
||||
if err := e.transitionToLeader(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := e.transitionToFollower(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Start health check loop
|
||||
e.wg.Add(1)
|
||||
go e.healthCheckLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the election
|
||||
func (e *Election) Stop() error {
|
||||
log.Println("[Election] Stopping leader election...")
|
||||
e.isShuttingDown = true
|
||||
close(e.stopChan)
|
||||
|
||||
e.wg.Wait()
|
||||
|
||||
// Stop Redis if we were leader
|
||||
if e.Role() == RoleLeader {
|
||||
if err := e.redisManager.Stop(); err != nil {
|
||||
log.Printf("[Election] Error stopping Redis: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Release the lock
|
||||
e.releaseLock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Role returns the current role (thread-safe)
|
||||
func (e *Election) Role() Role {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
return e.role
|
||||
}
|
||||
|
||||
// LeaderInfo returns the current leader info (thread-safe)
|
||||
func (e *Election) LeaderInfo() *LeaderLockInfo {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
if e.leaderInfo == nil {
|
||||
return nil
|
||||
}
|
||||
// Return a copy
|
||||
info := *e.leaderInfo
|
||||
return &info
|
||||
}
|
||||
|
||||
// IsLeader returns true if this instance is the leader
|
||||
func (e *Election) IsLeader() bool {
|
||||
return e.Role() == RoleLeader
|
||||
}
|
||||
|
||||
// tryAcquireLock attempts to acquire exclusive flock on the lock file
|
||||
func (e *Election) tryAcquireLock() (bool, error) {
|
||||
lockPath := e.config.LockFilePath()
|
||||
|
||||
// Open or create the lock file
|
||||
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0666)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to open lock file: %w", err)
|
||||
}
|
||||
|
||||
// Try non-blocking exclusive flock
|
||||
err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
if err == syscall.EWOULDBLOCK {
|
||||
log.Println("[Election] Lock is held by another process")
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("flock failed: %w", err)
|
||||
}
|
||||
|
||||
e.lockFile = f
|
||||
log.Printf("[Election] Acquired flock on %s", lockPath)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// releaseLock releases the flock
|
||||
func (e *Election) releaseLock() {
|
||||
if e.lockFile != nil {
|
||||
syscall.Flock(int(e.lockFile.Fd()), syscall.LOCK_UN)
|
||||
e.lockFile.Close()
|
||||
e.lockFile = nil
|
||||
log.Println("[Election] Released flock")
|
||||
}
|
||||
}
|
||||
|
||||
// transitionToLeader handles becoming the leader
|
||||
func (e *Election) transitionToLeader() error {
|
||||
log.Println("[Election] Transitioning to LEADER role")
|
||||
|
||||
e.mu.Lock()
|
||||
e.role = RoleLeader
|
||||
e.mu.Unlock()
|
||||
|
||||
// Start Redis
|
||||
if err := e.redisManager.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start Redis: %w", err)
|
||||
}
|
||||
|
||||
// Wait for Redis to be ready
|
||||
if err := e.redisManager.WaitForReady(30 * time.Second); err != nil {
|
||||
return fmt.Errorf("Redis not ready: %w", err)
|
||||
}
|
||||
|
||||
// Build and write leader info
|
||||
info := e.buildLeaderInfo()
|
||||
e.mu.Lock()
|
||||
e.leaderInfo = info
|
||||
e.mu.Unlock()
|
||||
|
||||
if err := e.writeLeaderInfo(info); err != nil {
|
||||
return fmt.Errorf("failed to write leader info: %w", err)
|
||||
}
|
||||
|
||||
// Connect storage to local Redis
|
||||
if e.storage != nil {
|
||||
if err := e.storage.Connect(info.API); err != nil {
|
||||
log.Printf("[Election] Warning: failed to connect storage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Notify callback
|
||||
if e.onBecomeLeader != nil {
|
||||
e.onBecomeLeader()
|
||||
}
|
||||
|
||||
log.Println("[Election] Now acting as LEADER")
|
||||
return nil
|
||||
}
|
||||
|
||||
// transitionToFollower handles becoming a follower
|
||||
func (e *Election) transitionToFollower() error {
|
||||
log.Println("[Election] Transitioning to FOLLOWER role")
|
||||
|
||||
e.mu.Lock()
|
||||
e.role = RoleFollower
|
||||
e.mu.Unlock()
|
||||
|
||||
// Read leader info
|
||||
info, err := e.readLeaderInfo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read leader info: %w", err)
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
e.leaderInfo = info
|
||||
e.mu.Unlock()
|
||||
|
||||
if info != nil {
|
||||
log.Printf("[Election] Leader is at %s", info.API)
|
||||
|
||||
// Connect storage to leader's Redis
|
||||
if e.storage != nil {
|
||||
if err := e.storage.Connect(info.API); err != nil {
|
||||
log.Printf("[Election] Warning: failed to connect to leader: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Notify callback
|
||||
if e.onBecomeFollower != nil {
|
||||
e.onBecomeFollower(info)
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("[Election] Now acting as FOLLOWER")
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildLeaderInfo creates leader info for this instance
|
||||
func (e *Election) buildLeaderInfo() *LeaderLockInfo {
|
||||
ip := getLocalIP()
|
||||
hostname, _ := os.Hostname()
|
||||
|
||||
return &LeaderLockInfo{
|
||||
Host: hostname,
|
||||
API: fmt.Sprintf("redis://%s:%d", ip, e.config.RedisPort),
|
||||
HTTP: fmt.Sprintf("http://%s:%d", ip, e.config.APIPort),
|
||||
BaseURL: e.config.BaseURL,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
PID: os.Getpid(),
|
||||
}
|
||||
}
|
||||
|
||||
// writeLeaderInfo atomically writes leader info to file
|
||||
func (e *Election) writeLeaderInfo(info *LeaderLockInfo) error {
|
||||
infoPath := e.config.InfoFilePath()
|
||||
tempPath := infoPath + ".tmp"
|
||||
|
||||
data, err := json.MarshalIndent(info, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(tempPath, data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.Rename(tempPath, infoPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("[Election] Wrote leader info to %s", infoPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// readLeaderInfo reads leader info from file
|
||||
func (e *Election) readLeaderInfo() (*LeaderLockInfo, error) {
|
||||
infoPath := e.config.InfoFilePath()
|
||||
|
||||
data, err := os.ReadFile(infoPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var info LeaderLockInfo
|
||||
if err := json.Unmarshal(data, &info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// healthCheckLoop periodically checks health and updates timestamps
|
||||
func (e *Election) healthCheckLoop() {
|
||||
defer e.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(time.Duration(e.config.HealthCheckIntervalMS) * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-e.stopChan:
|
||||
return
|
||||
case <-ticker.C:
|
||||
e.performHealthCheck()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// performHealthCheck checks health based on current role
|
||||
func (e *Election) performHealthCheck() {
|
||||
switch e.Role() {
|
||||
case RoleLeader:
|
||||
// Update timestamp in leader info
|
||||
e.updateLeaderTimestamp()
|
||||
|
||||
// Check if Redis is still running
|
||||
if !e.redisManager.IsRunning() {
|
||||
log.Println("[Election] Redis not running, attempting restart...")
|
||||
if err := e.redisManager.Start(); err != nil {
|
||||
log.Printf("[Election] Failed to restart Redis: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
case RoleFollower:
|
||||
// Re-read leader info in case it changed
|
||||
info, err := e.readLeaderInfo()
|
||||
if err != nil {
|
||||
log.Printf("[Election] Failed to read leader info: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if info != nil {
|
||||
e.mu.Lock()
|
||||
e.leaderInfo = info
|
||||
e.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updateLeaderTimestamp updates the timestamp in leader info
|
||||
func (e *Election) updateLeaderTimestamp() {
|
||||
e.mu.Lock()
|
||||
if e.leaderInfo != nil {
|
||||
e.leaderInfo.Timestamp = time.Now().UnixMilli()
|
||||
info := *e.leaderInfo
|
||||
e.mu.Unlock()
|
||||
|
||||
if err := e.writeLeaderInfo(&info); err != nil {
|
||||
log.Printf("[Election] Failed to update timestamp: %v", err)
|
||||
}
|
||||
} else {
|
||||
e.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// getLocalIP returns the local IP address
|
||||
func getLocalIP() string {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
hostname, _ := os.Hostname()
|
||||
return hostname
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
|
||||
if ipnet.IP.To4() != nil {
|
||||
return ipnet.IP.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hostname, _ := os.Hostname()
|
||||
return hostname
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package leader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/metazla/meta-core/internal/config"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// RedisManager handles spawning and managing the Redis process
|
||||
type RedisManager struct {
|
||||
config *config.Config
|
||||
cmd *exec.Cmd
|
||||
running bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewRedisManager creates a new Redis manager
|
||||
func NewRedisManager(cfg *config.Config) *RedisManager {
|
||||
return &RedisManager{
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// Start spawns the Redis server process
|
||||
func (rm *RedisManager) Start() error {
|
||||
rm.mu.Lock()
|
||||
defer rm.mu.Unlock()
|
||||
|
||||
if rm.running {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure Redis data directory exists
|
||||
dataDir := rm.config.RedisDataDir()
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create Redis data directory: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[Redis] Spawning Redis on port %d...", rm.config.RedisPort)
|
||||
|
||||
// Build Redis arguments matching TypeScript implementation
|
||||
args := []string{
|
||||
"--port", fmt.Sprintf("%d", rm.config.RedisPort),
|
||||
"--bind", "0.0.0.0",
|
||||
"--dir", dataDir,
|
||||
"--appendonly", "yes",
|
||||
"--appendfilename", "appendonly.aof",
|
||||
"--dbfilename", "dump.rdb",
|
||||
"--save", "60", "1", // Save after 60 seconds if at least 1 key changed
|
||||
"--loglevel", "warning",
|
||||
}
|
||||
|
||||
rm.cmd = exec.Command("redis-server", args...)
|
||||
rm.cmd.Stdout = os.Stdout
|
||||
rm.cmd.Stderr = os.Stderr
|
||||
|
||||
if err := rm.cmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start Redis: %w", err)
|
||||
}
|
||||
|
||||
rm.running = true
|
||||
log.Printf("[Redis] Started with PID %d", rm.cmd.Process.Pid)
|
||||
|
||||
// Monitor the process in background
|
||||
go rm.monitorProcess()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully stops the Redis server
|
||||
func (rm *RedisManager) Stop() error {
|
||||
rm.mu.Lock()
|
||||
defer rm.mu.Unlock()
|
||||
|
||||
if !rm.running || rm.cmd == nil || rm.cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Println("[Redis] Stopping Redis...")
|
||||
|
||||
// Try graceful shutdown first (SIGTERM)
|
||||
if err := rm.cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
log.Printf("[Redis] Failed to send SIGTERM: %v", err)
|
||||
}
|
||||
|
||||
// Wait up to 10 seconds for graceful shutdown
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- rm.cmd.Wait()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
log.Println("[Redis] Stopped gracefully")
|
||||
case <-time.After(10 * time.Second):
|
||||
log.Println("[Redis] Timeout, sending SIGKILL...")
|
||||
rm.cmd.Process.Kill()
|
||||
<-done
|
||||
}
|
||||
|
||||
rm.running = false
|
||||
rm.cmd = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsRunning returns true if Redis is currently running
|
||||
func (rm *RedisManager) IsRunning() bool {
|
||||
rm.mu.RLock()
|
||||
defer rm.mu.RUnlock()
|
||||
return rm.running
|
||||
}
|
||||
|
||||
// WaitForReady waits for Redis to be ready to accept connections
|
||||
func (rm *RedisManager) WaitForReady(timeout time.Duration) error {
|
||||
log.Println("[Redis] Waiting for Redis to be ready...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: fmt.Sprintf("localhost:%d", rm.config.RedisPort),
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("timeout waiting for Redis")
|
||||
case <-ticker.C:
|
||||
if err := client.Ping(ctx).Err(); err == nil {
|
||||
log.Println("[Redis] Ready to accept connections")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ping checks if Redis is responding
|
||||
func (rm *RedisManager) Ping() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: fmt.Sprintf("localhost:%d", rm.config.RedisPort),
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
return client.Ping(ctx).Err()
|
||||
}
|
||||
|
||||
// monitorProcess monitors the Redis process and updates state when it exits
|
||||
func (rm *RedisManager) monitorProcess() {
|
||||
if rm.cmd == nil {
|
||||
return
|
||||
}
|
||||
|
||||
err := rm.cmd.Wait()
|
||||
|
||||
rm.mu.Lock()
|
||||
rm.running = false
|
||||
rm.mu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[Redis] Process exited with error: %v", err)
|
||||
} else {
|
||||
log.Println("[Redis] Process exited normally")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Client wraps Redis operations for metadata storage
|
||||
type Client struct {
|
||||
client *redis.Client
|
||||
prefix string
|
||||
connected bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewClient creates a new storage client
|
||||
func NewClient(prefix string) *Client {
|
||||
return &Client{
|
||||
prefix: prefix,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect connects to Redis at the given URL
|
||||
func (c *Client) Connect(url string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Parse Redis URL (redis://host:port)
|
||||
addr := strings.TrimPrefix(url, "redis://")
|
||||
|
||||
c.client = redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
DialTimeout: 5 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
PoolSize: 10,
|
||||
})
|
||||
|
||||
// Test connection
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := c.client.Ping(ctx).Err(); err != nil {
|
||||
return fmt.Errorf("failed to connect to Redis: %w", err)
|
||||
}
|
||||
|
||||
c.connected = true
|
||||
log.Printf("[Storage] Connected to Redis at %s", addr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the Redis connection
|
||||
func (c *Client) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.client != nil {
|
||||
c.connected = false
|
||||
return c.client.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsConnected returns true if connected to Redis
|
||||
func (c *Client) IsConnected() bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.connected
|
||||
}
|
||||
|
||||
// Health checks if Redis is responding
|
||||
func (c *Client) Health() bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.client == nil || !c.connected {
|
||||
return false
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
return c.client.Ping(ctx).Err() == nil
|
||||
}
|
||||
|
||||
// buildKey constructs the full key with prefix
|
||||
func (c *Client) buildKey(key string) string {
|
||||
if c.prefix != "" {
|
||||
return c.prefix + key
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// buildFilePrefix constructs the prefix for a file's metadata keys
|
||||
func (c *Client) buildFilePrefix(hashID string) string {
|
||||
return fmt.Sprintf("/file/%s", hashID)
|
||||
}
|
||||
|
||||
// GetMetadataFlat retrieves all metadata for a file as a flat map
|
||||
// Key format: /file/{hashId}/{property} -> value
|
||||
func (c *Client) GetMetadataFlat(hashID string) (map[string]string, error) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.client == nil {
|
||||
return nil, fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
prefix := c.buildKey(c.buildFilePrefix(hashID))
|
||||
result := make(map[string]string)
|
||||
|
||||
// Use SCAN to find all keys with this prefix
|
||||
var cursor uint64
|
||||
for {
|
||||
keys, nextCursor, err := c.client.Scan(ctx, cursor, prefix+"/*", 1000).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan failed: %w", err)
|
||||
}
|
||||
|
||||
if len(keys) > 0 {
|
||||
// Get values for all keys
|
||||
values, err := c.client.MGet(ctx, keys...).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mget failed: %w", err)
|
||||
}
|
||||
|
||||
for i, key := range keys {
|
||||
if values[i] != nil {
|
||||
// Strip prefix to get property path
|
||||
propPath := strings.TrimPrefix(key, prefix+"/")
|
||||
result[propPath] = values[i].(string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cursor = nextCursor
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SetMetadataFlat stores metadata for a file as flat key-value pairs
|
||||
func (c *Client) SetMetadataFlat(hashID string, metadata map[string]string) error {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.client == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
if len(metadata) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
prefix := c.buildKey(c.buildFilePrefix(hashID))
|
||||
|
||||
// Use pipeline for batch operations
|
||||
pipe := c.client.Pipeline()
|
||||
for prop, value := range metadata {
|
||||
key := prefix + "/" + prop
|
||||
pipe.Set(ctx, key, value, 0)
|
||||
}
|
||||
|
||||
_, err := pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAllHashIDs returns all unique file hash IDs stored
|
||||
func (c *Client) GetAllHashIDs() ([]string, error) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.client == nil {
|
||||
return nil, fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
prefix := c.buildKey("/file/")
|
||||
hashSet := make(map[string]bool)
|
||||
|
||||
// Scan for all file keys
|
||||
var cursor uint64
|
||||
for {
|
||||
keys, nextCursor, err := c.client.Scan(ctx, cursor, prefix+"*", 1000).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan failed: %w", err)
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
// Extract hashID from key /file/{hashID}/property
|
||||
stripped := strings.TrimPrefix(key, prefix)
|
||||
parts := strings.SplitN(stripped, "/", 2)
|
||||
if len(parts) > 0 && parts[0] != "" {
|
||||
hashSet[parts[0]] = true
|
||||
}
|
||||
}
|
||||
|
||||
cursor = nextCursor
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Convert set to slice
|
||||
result := make([]string, 0, len(hashSet))
|
||||
for hashID := range hashSet {
|
||||
result = append(result, hashID)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetProperty retrieves a single property value
|
||||
func (c *Client) GetProperty(hashID, property string) (string, error) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.client == nil {
|
||||
return "", fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
key := c.buildKey(c.buildFilePrefix(hashID) + "/" + property)
|
||||
result, err := c.client.Get(ctx, key).Result()
|
||||
if err == redis.Nil {
|
||||
return "", nil
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// SetProperty sets a single property value
|
||||
func (c *Client) SetProperty(hashID, property, value string) error {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.client == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
key := c.buildKey(c.buildFilePrefix(hashID) + "/" + property)
|
||||
return c.client.Set(ctx, key, value, 0).Err()
|
||||
}
|
||||
|
||||
// DeleteMetadata deletes all metadata for a file
|
||||
func (c *Client) DeleteMetadata(hashID string) (int64, error) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.client == nil {
|
||||
return 0, fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
prefix := c.buildKey(c.buildFilePrefix(hashID))
|
||||
var deleted int64
|
||||
|
||||
// Scan and delete all keys with this prefix
|
||||
var cursor uint64
|
||||
for {
|
||||
keys, nextCursor, err := c.client.Scan(ctx, cursor, prefix+"/*", 1000).Result()
|
||||
if err != nil {
|
||||
return deleted, fmt.Errorf("scan failed: %w", err)
|
||||
}
|
||||
|
||||
if len(keys) > 0 {
|
||||
count, err := c.client.Del(ctx, keys...).Result()
|
||||
if err != nil {
|
||||
return deleted, fmt.Errorf("del failed: %w", err)
|
||||
}
|
||||
deleted += count
|
||||
}
|
||||
|
||||
cursor = nextCursor
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// CountFiles returns the number of unique files stored
|
||||
func (c *Client) CountFiles() (int, error) {
|
||||
hashIDs, err := c.GetAllHashIDs()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(hashIDs), nil
|
||||
}
|
||||
|
||||
// LookupPathByCID searches all file metadata for a matching CID
|
||||
// Returns the file path if found in poster/posterPath or backdrop/backdropPath
|
||||
func (c *Client) LookupPathByCID(cid string) (string, error) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.client == nil {
|
||||
return "", fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
if cid == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Get all hash IDs first
|
||||
hashIDs, err := c.getAllHashIDsInternal(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// For each hash, check poster and backdrop CIDs
|
||||
for _, hashID := range hashIDs {
|
||||
prefix := c.buildKey(c.buildFilePrefix(hashID))
|
||||
|
||||
// Check poster
|
||||
posterCID, err := c.client.Get(ctx, prefix+"/poster").Result()
|
||||
if err == nil && posterCID == cid {
|
||||
posterPath, err := c.client.Get(ctx, prefix+"/posterPath").Result()
|
||||
if err == nil && posterPath != "" {
|
||||
return posterPath, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check backdrop
|
||||
backdropCID, err := c.client.Get(ctx, prefix+"/backdrop").Result()
|
||||
if err == nil && backdropCID == cid {
|
||||
backdropPath, err := c.client.Get(ctx, prefix+"/backdropPath").Result()
|
||||
if err == nil && backdropPath != "" {
|
||||
return backdropPath, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// getAllHashIDsInternal is an internal version that doesn't acquire locks
|
||||
// (caller must hold the lock)
|
||||
func (c *Client) getAllHashIDsInternal(ctx context.Context) ([]string, error) {
|
||||
prefix := c.buildKey("/file/")
|
||||
hashSet := make(map[string]bool)
|
||||
|
||||
var cursor uint64
|
||||
for {
|
||||
keys, nextCursor, err := c.client.Scan(ctx, cursor, prefix+"*", 1000).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan failed: %w", err)
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
stripped := strings.TrimPrefix(key, prefix)
|
||||
parts := strings.SplitN(stripped, "/", 2)
|
||||
if len(parts) > 0 && parts[0] != "" {
|
||||
hashSet[parts[0]] = true
|
||||
}
|
||||
}
|
||||
|
||||
cursor = nextCursor
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(hashSet))
|
||||
for hashID := range hashSet {
|
||||
result = append(result, hashID)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// MergeMetadataFlat merges new metadata into existing (PATCH semantics)
|
||||
// New keys are added, existing keys are updated, missing keys are NOT deleted
|
||||
func (c *Client) MergeMetadataFlat(hashID string, metadata map[string]string) (int, error) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.client == nil {
|
||||
return 0, fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
if len(metadata) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
prefix := c.buildKey(c.buildFilePrefix(hashID))
|
||||
|
||||
// Use pipeline for batch operations
|
||||
pipe := c.client.Pipeline()
|
||||
for prop, value := range metadata {
|
||||
key := prefix + "/" + prop
|
||||
pipe.Set(ctx, key, value, 0)
|
||||
}
|
||||
|
||||
_, err := pipe.Exec(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return len(metadata), nil
|
||||
}
|
||||
|
||||
// DeleteProperty deletes a single property
|
||||
func (c *Client) DeleteProperty(hashID, property string) error {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.client == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
key := c.buildKey(c.buildFilePrefix(hashID) + "/" + property)
|
||||
return c.client.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
// AddToSet adds a value to a set-type field (stored as pipe-delimited string)
|
||||
// Returns true if value was added, false if it already existed
|
||||
func (c *Client) AddToSet(hashID, property, value string) (bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.client == nil {
|
||||
return false, fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
key := c.buildKey(c.buildFilePrefix(hashID) + "/" + property)
|
||||
|
||||
// Get current value
|
||||
current, err := c.client.Get(ctx, key).Result()
|
||||
if err != nil && err != redis.Nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Parse existing values (pipe-delimited)
|
||||
var values []string
|
||||
if current != "" {
|
||||
values = strings.Split(current, "|")
|
||||
}
|
||||
|
||||
// Check if value already exists
|
||||
for _, v := range values {
|
||||
if v == value {
|
||||
return false, nil // Already exists
|
||||
}
|
||||
}
|
||||
|
||||
// Add new value
|
||||
values = append(values, value)
|
||||
newValue := strings.Join(values, "|")
|
||||
|
||||
// Save back
|
||||
err = c.client.Set(ctx, key, newValue, 0).Err()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// RemoveFromSet removes a value from a set-type field
|
||||
// Returns true if value was removed, false if it didn't exist
|
||||
func (c *Client) RemoveFromSet(hashID, property, value string) (bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.client == nil {
|
||||
return false, fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
key := c.buildKey(c.buildFilePrefix(hashID) + "/" + property)
|
||||
|
||||
// Get current value
|
||||
current, err := c.client.Get(ctx, key).Result()
|
||||
if err == redis.Nil {
|
||||
return false, nil // Key doesn't exist
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Parse existing values
|
||||
values := strings.Split(current, "|")
|
||||
|
||||
// Find and remove value
|
||||
found := false
|
||||
newValues := make([]string, 0, len(values))
|
||||
for _, v := range values {
|
||||
if v == value {
|
||||
found = true
|
||||
} else {
|
||||
newValues = append(newValues, v)
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Save back (or delete if empty)
|
||||
if len(newValues) == 0 {
|
||||
return true, c.client.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
newValue := strings.Join(newValues, "|")
|
||||
return true, c.client.Set(ctx, key, newValue, 0).Err()
|
||||
}
|
||||
Reference in New Issue
Block a user