Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Go CI

on:
push:
branches: ["main"]
pull_request:
branches: ["**"]

jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Ensure go.mod and go.sum are tidy
run: |
go mod tidy
git diff --exit-code go.mod
- name: Run Go Vet
run: go vet ./...
- name: Check code formatting
run: |
files=$(gofmt -l .)
if [ -n "$files" ]; then
echo "❌ Code is not formatted in the following files:"
echo "$files"
echo ""
echo "💡 Run 'go fmt ./...' locally to fix formatting."
exit 1
fi
- name: Run Staticcheck
# https://github.com/dominikh/go-tools/releases/tag/2025.1.1
run: go run honnef.co/go/tools/cmd/staticcheck@b8ec13ce4d00445d75da053c47498e6f9ec5d7d6 ./...
Empty file added .gitignore
Empty file.
12 changes: 12 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.PHONY: build run test lint

build:
go build ./...

run:
go run ./cmd/dhtnode --bind=:8080

test:
go test ./...


52 changes: 52 additions & 0 deletions cmd/dhtnode/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package main

import (
"context"
"flag"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"

"github.com/amirderis/DHT/internal/config"
"github.com/amirderis/DHT/internal/server"
)

func main() {
cfg := config.Flags()

flag.StringVar(&cfg.NodeID, "node-id", "", "Unique node identifier")
flag.StringVar(&cfg.BindAddr, "bind", ":8080", "Bind address, e.g. 0.0.0.0:8080")
flag.StringVar(&cfg.SeedsCSV, "seeds", "", "Comma-separated seed addresses for gossip (host:port)")
flag.IntVar(&cfg.ReplicationFactor, "replication-factor", 3, "Replication factor N")
flag.IntVar(&cfg.ReadQuorum, "r", 2, "Read quorum R")
flag.IntVar(&cfg.WriteQuorum, "w", 2, "Write quorum W")
flag.Parse()

if err := cfg.Validate(); err != nil {
log.Fatalf("invalid config: %v", err)
}

srv := server.NewHTTPServer(cfg)

go func() {
if err := srv.Start(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}()

log.Printf("node %s listening on %s", cfg.NodeID, cfg.BindAddr)

// Graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Stop(ctx); err != nil {
log.Printf("graceful shutdown error: %v", err)
}
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/amirderis/DHT

go 1.24.5
39 changes: 39 additions & 0 deletions internal/clock/vectorclock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package clock

// VectorClock is a simple version vector: node-id -> counter.
// Conflict resolution will be implemented in Phase 3.

type VectorClock map[string]uint64

// Compare returns -1 if a < b, 1 if a > b, 0 if concurrent or equal.
func Compare(a, b VectorClock) int {
aDom, bDom := true, true
for k, av := range a {
if bv, ok := b[k]; !ok || av > bv {
bDom = false
}
if bv, ok := b[k]; ok && av < bv {
aDom = false
}
}
for k, bv := range b {
if av, ok := a[k]; !ok || bv > av {
aDom = false
}
if av, ok := a[k]; ok && bv < av {
bDom = false
}
}
if aDom && !bDom {
return 1
}
if bDom && !aDom {
return -1
}
return 0
}

// Bump increments the counter for nodeID in the clock.
func (vc VectorClock) Bump(nodeID string) {
vc[nodeID] = vc[nodeID] + 1
}
70 changes: 70 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package config

import (
"errors"
"fmt"
"os"
"strings"
)

// Config captures node runtime configuration.
type Config struct {
NodeID string
BindAddr string
SeedsCSV string
Seeds []string
ReplicationFactor int
ReadQuorum int
WriteQuorum int
}

// Flags returns a zero-value config for flag binding.
func Flags() *Config {
return &Config{}
}

// Validate finalizes and validates the configuration.
func (c *Config) Validate() error {
if c.NodeID == "" {
// Default node id to hostname if available
c.NodeID = generateDefaultNodeID()
}
if c.BindAddr == "" {
c.BindAddr = ":8080"
}
if c.ReplicationFactor <= 0 {
c.ReplicationFactor = 3
}
if c.ReadQuorum <= 0 {
c.ReadQuorum = 2
}
if c.WriteQuorum <= 0 {
c.WriteQuorum = 2
}
if c.ReadQuorum > c.ReplicationFactor || c.WriteQuorum > c.ReplicationFactor {
return fmt.Errorf("unexpected replication configuration(R=%d W=%d N=%d)", c.ReadQuorum, c.WriteQuorum, c.ReplicationFactor)
}
if c.SeedsCSV != "" {
parts := strings.Split(c.SeedsCSV, ",")
for _, p := range parts {
s := strings.TrimSpace(p)
if s != "" {
c.Seeds = append(c.Seeds, s)
}
}
}
if c.NodeID == "" {
return errors.New("node-id must be set or resolvable from hostname")
}
return nil
}

func generateDefaultNodeID() string {
// For now, hostname is sufficient; later we may compose with a short ID
if h, err := osHostname(); err == nil && h != "" {
return h
}
return "node-unknown"
}

var osHostname = func() (string, error) { return os.Hostname() }
13 changes: 13 additions & 0 deletions internal/membership/membership.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package membership

// Placeholder for gossip-based membership and failure detection.
// Phase 4 will implement SWIM-like or memberlist-based gossip.

type Node struct {
ID string
Addr string
}

type Cluster struct{}

func NewCluster() *Cluster { return &Cluster{} }
10 changes: 10 additions & 0 deletions internal/ring/ring.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package ring

// Placeholder for consistent hashing ring with virtual nodes.
// To be implemented in Phase 2.

type NodeID string

type Ring struct{}

func New() *Ring { return &Ring{} }
60 changes: 60 additions & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package server

import (
"context"
"fmt"
"net/http"
"sync/atomic"
"time"

"github.com/amirderis/DHT/internal/config"
)

type HTTPServer struct {
cfg *config.Config
server *http.Server
readyFlag atomic.Bool
}

func NewHTTPServer(cfg *config.Config) *HTTPServer {
mux := http.NewServeMux()
s := &HTTPServer{cfg: cfg}
mux.HandleFunc("/healthz", s.handleHealth)
mux.HandleFunc("/readyz", s.handleReady)

s.server = &http.Server{
Addr: cfg.BindAddr,
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}

// Set ready true after initialization; in Phase 1.1 we flip immediately
s.readyFlag.Store(true)

return s
}

func (s *HTTPServer) Start() error {
return s.server.ListenAndServe()
}

func (s *HTTPServer) Stop(ctx context.Context) error {
return s.server.Shutdown(ctx)
}

func (s *HTTPServer) handleHealth(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintln(w, "ok")
}

func (s *HTTPServer) handleReady(w http.ResponseWriter, r *http.Request) {
if !s.readyFlag.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = fmt.Fprintln(w, "not ready")
return
}
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintln(w, "ready")
}
48 changes: 48 additions & 0 deletions internal/storage/storage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package storage

import "sync"

type Storer interface {
Get(key string) (value []byte, ok bool)
Put(key string, value []byte) error
Delete(key string) error
}

// InMemory is a simple in-memory map-backed store for development/testing.
type InMemory struct {
mu sync.RWMutex
data map[string][]byte
}

func NewInMemory() *InMemory {
return &InMemory{data: make(map[string][]byte)}
}

func (s *InMemory) Get(key string) ([]byte, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
v, ok := s.data[key]
if !ok {
return nil, false
}
// copy to avoid external mutation
out := make([]byte, len(v))
copy(out, v)
return out, true
}

func (s *InMemory) Put(key string, value []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
v := make([]byte, len(value))
copy(v, value)
s.data[key] = v
return nil
}

func (s *InMemory) Delete(key string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.data, key)
return nil
}
19 changes: 19 additions & 0 deletions pkg/api/types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package api

// Basic request/response types for client API (subject to change).

type PutRequest struct {
Key string `json:"key"`
Value []byte `json:"value"`
}

type PutResponse struct {
Version map[string]uint64 `json:"version,omitempty"`
}

type GetResponse struct {
Key string `json:"key"`
Value []byte `json:"value,omitempty"`
Versions []map[string]uint64 `json:"versions,omitempty"`
Found bool `json:"found"`
}
Loading