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
91 changes: 62 additions & 29 deletions src/main/java/com/redis/commands/CommandRegistry.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,83 +7,116 @@
import java.util.concurrent.ConcurrentHashMap;

/**
* Registry for all available Redis commands.
* Supports dynamic command registration and lookup.
* This is a singleton initialized with built-in commands via ServiceLoader.
*
* Optimizations:
* - Case-insensitive lookups with single toUpperCase() call
* - ConcurrentHashMap with capacity hints for faster lookup
* - Dynamic discovery via ServiceLoader for easy extensibility
* The Central Command Registry.
* <p>
* <b>Role:</b> Acts as the directory for all executable commands. It maps a string (e.g., "SET")
* to the actual Java object capable of executing that logic.
* <p>
* <b>Design Pattern: Singleton</b>
* We use a Singleton because the list of commands is static for the application's lifetime.
* Re-scanning for commands for every client connection would be incredibly slow.
* <p>
* <b>Design Pattern: Strategy / Command</b>
* This registry enables the "Command Pattern". The network layer doesn't need to know how "SET" works;
* it just retrieves the command object and calls .execute().
*/
public class CommandRegistry {
private static CommandRegistry INSTANCE;

// The single instance of this class (Volatile is implied by the memory model of synchronized,
// but usually good practice to mark volatile in double-checked locking to prevent instruction reordering).
private static volatile CommandRegistry INSTANCE;

// The actual storage.
// Key = Command Name (UPPERCASE), Value = Command Object.
// We use ConcurrentHashMap because the registry might be read by multiple Netty threads simultaneously.
// While writes happen mostly at startup, safe reads are critical.
private final Map<String, ICommand> registry = new ConcurrentHashMap<>(32);

/**
* Initialize the singleton registry and register all commands found via ServiceLoader.
* Private constructor to enforce Singleton usage.
* <p>
* <b>Mechanism: ServiceLoader (SPI)</b>
* Instead of hardcoding "new SetCommand()", we ask Java to look at the classpath.
* Java looks for a file: META-INF/services/com.redis.commands.ICommand
* It reads the class names listed there and instantiates them.
*/
private CommandRegistry() {
// Step 1: Initialize the loader for the ICommand interface
ServiceLoader<ICommand> loader = ServiceLoader.load(ICommand.class);

// Step 2: Iterate through found implementations.
// The ServiceLoader lazily instantiates the classes as we iterate.
for (ICommand cmd : loader) {
register(cmd);
}

// Logging is helpful to verify that your META-INF file is set up correctly.
System.out.println("[Redis] Registered " + registry.size() + " commands");
}

/**
* Get the singleton instance of CommandRegistry.
* The Holder Class.
* <p>
* 1. <b>Lazy:</b> This class is NOT loaded when CommandRegistry is loaded.
* It is only loaded when getInstance() is called for the first time.
* 2. <b>Thread-Safe:</b> The JVM guarantees that static field initialization
* (INSTANCE = new ...) happens atomically. No synchronized keyword needed.
*/
private static class RegistryHolder {
private static final CommandRegistry INSTANCE = new CommandRegistry();
}

/**
* Get the singleton instance.
* Triggers the loading of RegistryHolder and the creation of INSTANCE.
*/
public static CommandRegistry getInstance() {
if (INSTANCE == null) {
synchronized (CommandRegistry.class) {
if (INSTANCE == null) {
INSTANCE = new CommandRegistry();
}
}
}
return INSTANCE;
return RegistryHolder.INSTANCE;
}

/**
* Register a command in the registry.
* Command names are stored in uppercase for case-insensitive lookup.
* Registers a command into the map.
* <p>
* <b>Normalization:</b> We store all keys in UPPERCASE. This ensures that
* "set", "Set", and "SET" all resolve to the same entry.
*/
public void register(ICommand cmd) {
String cmdName = cmd.name().toUpperCase();
registry.put(cmdName, cmd);
}

/**
* Look up a command by name (case-insensitive).
* Returns the command instance or null if not found.
* Optimization: Single toUpperCase() call, direct HashMap lookup
* Retrieves the command object for a given name.
* <p>
* <b>Performance:</b> This is a "Hot Path" method called for every single request.
* It must be O(1) and very fast.
*
* @param name The command name (e.g., "set")
* @return The ICommand instance, or null if not found.
*/
public ICommand get(String name) {
if (name == null || name.isEmpty()) {
return null;
}
// Convert input to uppercase to match the storage key format.
return registry.get(name.toUpperCase());
}

/**
* Check if a command is registered.
* Utility to check command existence without retrieving it.
*/
public boolean exists(String name) {
return get(name) != null;
}

/**
* Get all registered command names (unmodifiable).
* Returns a Read-Only view of all available commands.
* Useful for the "COMMAND" command in Redis which lists capabilities.
*/
public Set<String> getRegisteredCommands() {
return Collections.unmodifiableSet(registry.keySet());
}

/**
* Get the number of registered commands.
*/
public int size() {
return registry.size();
}
Expand Down
60 changes: 54 additions & 6 deletions src/main/java/com/redis/commands/stream/XAddCommand.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,105 +13,153 @@
import java.util.concurrent.atomic.AtomicReference;

/**
* XADD key ID field value [field value ...]
* Appends the specified stream entry to the stream at the specified key.
* If the key does not exist, as a side effect the stream is created.
* Implementation of the XADD command.
* <p>
* <b>Syntax:</b> XADD key ID field value [field value ...]
* <p>
* <b>Role:</b> Appends a new entry to a stream. This is the "Writer" command for streams.
* <p>
* <b>Concurrency Strategy:</b>
* This command relies heavily on {@link RedisDatabase#compute} to ensure atomic ID generation.
* Since Stream IDs must be strictly monotonic (always increasing), we must lock the stream
* while we calculate the next ID to prevent two clients from generating the same ID simultaneously.
*/
public class XAddCommand implements ICommand {

// Standard Redis Error Messages
private static final String ERR_WRONG_ARGS = "-ERR wrong number of arguments for 'XADD' command\r\n";
private static final String ERR_WRONG_TYPE = "-WRONGTYPE Operation against a key holding the wrong kind of value\r\n";
private static final String ERR_ID_TOO_SMALL = "-ERR The ID specified in XADD is equal or smaller than the target stream top item\r\n";
private static final String ERR_ID_ZERO = "-ERR The ID specified in XADD must be greater than 0-0\r\n";

@Override
public String execute(List<String> args, ChannelHandlerContext ctx) {
// Minimum args: key, ID, field, value (4 args -> size 4).
// Note: The List<String> args usually includes key at 0.
// Format: XADD <key> <id> <field> <value> ...
if (args.size() < 3) {
return ERR_WRONG_ARGS;
}

String key = args.get(0);
String idArg = args.get(1);

// Validation: Fields and Values must come in pairs
int fieldStart = 2;
// Basic argument validation for field-value pairs
if ((args.size() - fieldStart) % 2 != 0) {
return ERR_WRONG_ARGS;
return ERR_WRONG_ARGS;
}

// Parse fields into a LinkedHashMap to preserve insertion order (Redis convention)
Map<String, String> fields = new LinkedHashMap<>();
for (int i = fieldStart; i < args.size(); i += 2) {
fields.put(args.get(i), args.get(i + 1));
}

RedisDatabase db = RedisDatabase.getInstance();

// We use AtomicReferences to extract results/errors from inside the lambda
AtomicReference<String> error = new AtomicReference<>(null);
AtomicReference<StreamId> addedId = new AtomicReference<>(null);

// CRITICAL SECTION: Atomic Read-Modify-Write
// We lock the key to ensure no one else inserts while we determine the next ID.
db.compute(key, existing -> {
ConcurrentSkipListMap<StreamId, Map<String, String>> streamMap;

// 1. Initialization / Type Check
if (existing == null) {
// New Key: Create a new SkipList (Ordered Thread-Safe Map)
streamMap = new ConcurrentSkipListMap<>();
} else if (existing.getType() != RedisValue.Type.STREAM) {
// Wrong Type: Cannot append stream data to a String/List
error.set(ERR_WRONG_TYPE);
return existing;
} else {
// Existing Key: Cast the raw data
@SuppressWarnings("unchecked")
var data = (Map<StreamId, Map<String, String>>) existing.getData();
// We know it's a SkipList because we created it that way in RedisValue factory
streamMap = (ConcurrentSkipListMap<StreamId, Map<String, String>>) data;
}

// 2. Determine Context (What is the last ID?)
// If stream is empty, assume "0-0" is the predecessor
StreamId lastId = streamMap.isEmpty() ? new StreamId(0, 0) : streamMap.lastKey();
StreamId newId;

try {
// 3. ID Generation Logic
if (idArg.equals("*")) {
// AUTO-GENERATE: "*"
long now = System.currentTimeMillis();
if (now > lastId.time()) {
// Standard case: New millisecond, reset sequence to 0
newId = new StreamId(now, 0);
} else {
// Collision or Clock Skew: Time is the same (or older) than the last entry.
// We must increment the sequence number of the LAST entry's time.
// This handles high-throughput bursts within the same ms.
newId = new StreamId(lastId.time(), lastId.sequence() + 1);
}
} else if (idArg.endsWith("-*")) {
// PARTIAL ID: "123456-*"
long ms = Long.parseLong(idArg.substring(0, idArg.length() - 2));

if (ms < lastId.time()) {
error.set(ERR_ID_TOO_SMALL);
return existing;
}
// If time is the same, increment sequence. If time is new, seq is 0.
long seq = (ms == lastId.time()) ? lastId.sequence() + 1 : 0;
newId = new StreamId(ms, seq);
} else {
// EXPLICIT ID: "123456-0"
newId = StreamId.parse(idArg);
}

// 4. Validation Rules
// Rule A: ID must be > 0-0
if (newId.time() == 0 && newId.sequence() == 0) {
error.set(ERR_ID_ZERO);
return existing;
}

// Rule B: ID must be strictly greater than the last ID
// Note: If stream is empty, lastId is 0-0, so any valid ID passes.
if (!streamMap.isEmpty() && !newId.isGreaterThan(lastId)) {
error.set(ERR_ID_TOO_SMALL);
return existing;
}

// 5. Execution: Insert into map
streamMap.put(newId, fields);
addedId.set(newId);

// Return wrapped value to update DB (or keep existing reference)
return RedisValue.stream(streamMap);

} catch (Exception e) {
// Handle parsing errors (e.g., malformed ID string)
error.set("-ERR " + e.getMessage() + "\r\n");
return existing;
}
});

// 6. Response Handling
// If the lambda set an error, return it.
if (error.get() != null) {
return error.get();
}

// Otherwise return the ID we just generated/inserted.
String idStr = addedId.get().toString();
// Redis Bulk String format: $<len>\r\n<data>\r\n
return "$" + idStr.length() + "\r\n" + idStr + "\r\n";
}

@Override
public String name() {
return "XADD";
}
}
}
Loading