MtnMCG

ndisk_api.lua

Dec 20th, 2025 (edited)
78
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 20.94 KB | None | 0 0
  1. -- ndisk_api.lua
  2. -- API for Distributed NDisk System
  3. -- RAID10 Edition: Each chunk stored on primary + mirror node
  4. -- UPDATED: Robustness, Security, and Recovery Features
  5.  
  6. local ndisk = {}
  7.  
  8. local modem = peripheral.find("modem")
  9. if not modem then error("No modem found") end
  10. modem.open(5) -- Channel 5 for NDisk storage traffic
  11.  
  12. local nodes = {}
  13. local chunkMap = {}
  14. local initialized = false
  15. local systemSecret = nil -- Use ndisk.setSecret()
  16. local writeLocks = {} -- Active write locks per path
  17.  
  18. -- ============================================
  19. -- RAID10 HELPER FUNCTIONS
  20. -- ============================================
  21.  
  22. -- Get mirror node index (offset by half the node count for distribution)
  23. local function getMirrorIndex(primaryIdx, nodeCount)
  24. if nodeCount < 2 then return nil end
  25. local offset = math.floor(nodeCount / 2)
  26. return ((primaryIdx - 1 + offset) % nodeCount) + 1
  27. end
  28.  
  29. -- Get sorted node list for consistent ordering
  30. local function getSortedNodes()
  31. local sorted = {}
  32. for idx, id in pairs(nodes) do
  33. table.insert(sorted, {idx = idx, id = id})
  34. end
  35. table.sort(sorted, function(a, b) return a.idx < b.idx end)
  36. return sorted
  37. end
  38.  
  39. -- Path Sanitization (Security)
  40. local function checkPath(path)
  41. if not path or type(path) ~= "string" then return false, "Invalid path" end
  42. if path:find("%.%.") then return false, "Invalid path: '..' not allowed" end
  43. if path:find(":") then return false, "Invalid path: ':' not allowed" end
  44. return true
  45. end
  46.  
  47. -- ============================================
  48. -- NODE PERSISTENCE
  49. -- ============================================
  50.  
  51. local function saveNodeIds()
  52. local file = fs.open("ids.txt", "w")
  53. for hierarchicalNum, nodeId in pairs(nodes) do
  54. file.writeLine(nodeId .. " " .. hierarchicalNum)
  55. end
  56. file.close()
  57. end
  58.  
  59. local function loadNodeIds()
  60. nodes = {}
  61. if fs.exists("ids.txt") then
  62. local file = fs.open("ids.txt", "r")
  63. for line in file.readLine do
  64. local nodeId, hierarchicalNum = line:match("(%d+)%s+(%d+)")
  65. if nodeId and hierarchicalNum then
  66. nodes[tonumber(hierarchicalNum)] = tonumber(nodeId)
  67. end
  68. end
  69. file.close()
  70. end
  71. end
  72.  
  73. -- ============================================
  74. -- CHUNK MAP PERSISTENCE (RAID10 FORMAT)
  75. -- ============================================
  76.  
  77. local function saveChunkMap()
  78. local file = fs.open("chunklocation.txt", "w")
  79. for filename, chunks in pairs(chunkMap) do
  80. for chunkNum, info in pairs(chunks) do
  81. if type(info) == "table" then
  82. -- RAID10 format: path chunk# primary mirror [degraded]
  83. local mirrorId = info.mirror or info.primary
  84. local line = filename .. " chunk" .. chunkNum .. " " .. info.primary .. " " .. mirrorId
  85. if info.degraded then
  86. line = line .. " degraded"
  87. end
  88. file.writeLine(line)
  89. else
  90. -- Legacy single-node format (backward compat)
  91. file.writeLine(filename .. " chunk" .. chunkNum .. " " .. info)
  92. end
  93. end
  94. end
  95. file.close()
  96. end
  97.  
  98. local function loadChunkMap()
  99. chunkMap = {}
  100. if fs.exists("chunklocation.txt") then
  101. local file = fs.open("chunklocation.txt", "r")
  102. for line in file.readLine do
  103. -- Parse line into parts
  104. local parts = {}
  105. for part in line:gmatch("%S+") do
  106. table.insert(parts, part)
  107. end
  108.  
  109. if #parts >= 3 then
  110. local filename = parts[1]
  111. local chunkMatch = parts[2]:match("chunk(%d+)")
  112. local chunkNum = chunkMatch and tonumber(chunkMatch) or nil
  113. local primaryId = tonumber(parts[3])
  114. local mirrorId = parts[4] and tonumber(parts[4]) or nil
  115.  
  116. -- Skip malformed lines
  117. if filename and chunkNum and primaryId then
  118. chunkMap[filename] = chunkMap[filename] or {}
  119.  
  120. -- Check for degraded flag (5th part)
  121. local isDegraded = parts[5] == "degraded"
  122.  
  123. -- Store as RAID10 format if mirror exists and differs
  124. if mirrorId and mirrorId ~= primaryId then
  125. chunkMap[filename][chunkNum] = {primary = primaryId, mirror = mirrorId, degraded = isDegraded}
  126. else
  127. -- Legacy format or single-node: store as table for consistency
  128. chunkMap[filename][chunkNum] = {primary = primaryId, mirror = nil, degraded = isDegraded}
  129. end
  130. end
  131. end
  132. end
  133. file.close()
  134. end
  135. end
  136.  
  137. -- ============================================
  138. -- WRITE JOURNALING (Recovery)
  139. -- ============================================
  140.  
  141. local function logJournal(action, path)
  142. local f = fs.open("ndisk_journal.txt", "a")
  143. f.writeLine(os.epoch("utc") .. "|" .. action .. "|" .. path)
  144. f.close()
  145. end
  146.  
  147. local function clearJournal()
  148. if fs.exists("ndisk_journal.txt") then
  149. fs.delete("ndisk_journal.txt")
  150. end
  151. end
  152.  
  153. local function checkJournal()
  154. if fs.exists("ndisk_journal.txt") then
  155. term.setTextColor(colors.orange)
  156. print("NDisk Alert: Previous session terminated abnormally.")
  157. print("Checking journal for incomplete writes...")
  158. term.setTextColor(colors.white)
  159.  
  160. -- Simple recovery: just warn for now, real rollback is complex
  161. local f = fs.open("ndisk_journal.txt", "r")
  162. local content = f.readAll()
  163. f.close()
  164. print("Found pending operations:\n" .. content)
  165. print("Note: File consistency may need manual verification.")
  166.  
  167. -- Back up the journal and clear active one
  168. local backup = fs.open("ndisk_journal_old.txt", "w")
  169. backup.write(content)
  170. backup.close()
  171. fs.delete("ndisk_journal.txt")
  172. end
  173. end
  174.  
  175. -- ============================================
  176. -- NODE DISCOVERY
  177. -- ============================================
  178.  
  179. local function discoverNodes()
  180. print("NDisk: Discovering storage nodes...")
  181. -- Discovery sends secret if available, but nodes might not require it for discovery?
  182. -- Actually, safer to include it if we have it
  183. modem.transmit(5, 5, {type = "discovery", secret = systemSecret})
  184. local timer = os.startTimer(2)
  185. local nextHierarchicalNum = 1
  186. for num, _ in pairs(nodes) do
  187. if num >= nextHierarchicalNum then
  188. nextHierarchicalNum = num + 1
  189. end
  190. end
  191.  
  192. while true do
  193. local event, param1, param2, param3, param4 = os.pullEvent()
  194. if event == "modem_message" and type(param4) == "table" and param4.type == "discovery_response" then
  195. local nodeId = param4.id
  196. local hierarchicalNum = nil
  197. for num, id in pairs(nodes) do
  198. if id == nodeId then
  199. hierarchicalNum = num
  200. break
  201. end
  202. end
  203. if not hierarchicalNum then
  204. hierarchicalNum = nextHierarchicalNum
  205. nodes[hierarchicalNum] = nodeId
  206. nextHierarchicalNum = nextHierarchicalNum + 1
  207. end
  208. elseif event == "timer" and param1 == timer then
  209. break
  210. end
  211. end
  212. saveNodeIds()
  213. return nodes
  214. end
  215.  
  216. -- ============================================
  217. -- NODE COMMUNICATION (WITH RAID10 FALLBACK)
  218. -- ============================================
  219.  
  220. local function generateRequestId()
  221. return os.getComputerID() .. "_" .. os.clock() .. "_" .. math.random(10000)
  222. end
  223.  
  224. local function sendRequest(nodeId, request, fallbackNodeId)
  225. request.targetNode = nodeId
  226.  
  227. -- AUTHENTICATION: Attach secret
  228. if systemSecret then
  229. request.secret = systemSecret
  230. end
  231.  
  232. modem.transmit(5, 5, request)
  233. local timeout = 2.0 -- UPDATED: Increased from 0.5s for robustness
  234. local timer = os.startTimer(timeout)
  235.  
  236. while true do
  237. local event, p1, p2, p3, p4 = os.pullEvent()
  238. if event == "modem_message" and type(p4) == "table" and p4.id == request.id then
  239. return p4.response
  240. elseif event == "timer" and p1 == timer then
  241. -- Primary timed out, try fallback if available
  242. if fallbackNodeId and fallbackNodeId ~= nodeId then
  243. request.targetNode = fallbackNodeId
  244. modem.transmit(5, 5, request)
  245. local timer2 = os.startTimer(timeout) -- Fallback also gets more time
  246.  
  247. while true do
  248. local ev2, pp1, pp2, pp3, pp4 = os.pullEvent()
  249. if ev2 == "modem_message" and type(pp4) == "table" and pp4.id == request.id then
  250. return pp4.response
  251. elseif ev2 == "timer" and pp1 == timer2 then
  252. return {success = false, error = "Both primary and mirror timeout"}
  253. end
  254. end
  255. end
  256. return {success = false, error = "Timeout waiting for Node " .. nodeId}
  257. end
  258. end
  259. end
  260.  
  261. -- ============================================
  262. -- API FUNCTIONS
  263. -- ============================================
  264.  
  265. function ndisk.setSecret(secret)
  266. systemSecret = secret
  267. end
  268.  
  269. function ndisk.init()
  270. if initialized then return end
  271.  
  272. -- Journal Check
  273. checkJournal()
  274.  
  275. loadNodeIds()
  276. chunkMap = {}
  277. loadChunkMap()
  278.  
  279. if not next(nodes) then
  280. discoverNodes()
  281. end
  282.  
  283. local nodeCount = 0
  284. for _ in pairs(nodes) do nodeCount = nodeCount + 1 end
  285.  
  286. if nodeCount == 0 then
  287. print("NDisk Warning: No storage nodes found/configured.")
  288. else
  289. local raidStatus = nodeCount >= 2 and "RAID10 enabled" or "Single-node mode"
  290. print("NDisk: Initialized with " .. nodeCount .. " nodes. (" .. raidStatus .. ")")
  291. end
  292. initialized = true
  293. end
  294.  
  295. function ndisk.refreshNodes()
  296. discoverNodes()
  297. end
  298.  
  299. function ndisk.write(path, content)
  300. if not initialized then ndisk.init() end
  301.  
  302. local valid, err = checkPath(path)
  303. if not valid then return false, err end
  304.  
  305. -- FILE-LEVEL LOCKING
  306. while writeLocks[path] do
  307. os.sleep(0.1)
  308. end
  309. writeLocks[path] = os.epoch("utc")
  310.  
  311. -- Protected Call to ensure lock release
  312. local function doWrite()
  313. -- Log Intent
  314. logJournal("WRITE", path)
  315.  
  316. local sortedNodes = getSortedNodes()
  317. local nodeCount = #sortedNodes
  318.  
  319. if nodeCount == 0 then
  320. discoverNodes()
  321. sortedNodes = getSortedNodes()
  322. nodeCount = #sortedNodes
  323. if nodeCount == 0 then return false, "No storage nodes available" end
  324. end
  325.  
  326. local chunkSize = math.ceil(#content / nodeCount)
  327. if chunkSize == 0 then chunkSize = 1 end
  328.  
  329. chunkMap[path] = {}
  330.  
  331. local writeSuccess = true
  332. local writeError = nil
  333.  
  334. for i, nodeInfo in ipairs(sortedNodes) do
  335. local start = (i - 1) * chunkSize + 1
  336. if start > #content and i > 1 then break end
  337.  
  338. local chunk = content:sub(start, math.min(start + chunkSize - 1, #content))
  339. if #content == 0 and i == 1 then chunk = "" end
  340.  
  341. local primaryId = nodeInfo.id
  342. local mirrorIdx = getMirrorIndex(i, nodeCount)
  343. local mirrorId = mirrorIdx and sortedNodes[mirrorIdx] and sortedNodes[mirrorIdx].id or nil
  344.  
  345. -- Write to primary
  346. local primaryOk = false
  347. local reqId = generateRequestId()
  348. local response = sendRequest(primaryId, {
  349. id = reqId,
  350. action = "write",
  351. path = path,
  352. chunk = i,
  353. content = chunk
  354. })
  355. if response and response.success then
  356. primaryOk = true
  357. end
  358.  
  359. -- Write to mirror (if available and different)
  360. local mirrorOk = false
  361. if mirrorId and mirrorId ~= primaryId then
  362. local mirrorReqId = generateRequestId()
  363. local mirrorResp = sendRequest(mirrorId, {
  364. id = mirrorReqId,
  365. action = "write",
  366. path = path,
  367. chunk = i,
  368. content = chunk
  369. })
  370. if mirrorResp and mirrorResp.success then
  371. mirrorOk = true
  372. end
  373. else
  374. -- No mirror means primary success is enough
  375. mirrorOk = primaryOk
  376. end
  377.  
  378. -- RESILIENCE: Need at least ONE success, not both
  379. if not primaryOk and not mirrorOk then
  380. writeSuccess = false
  381. writeError = "Failed to write chunk " .. i .. " to both primary and mirror"
  382. break
  383. end
  384.  
  385. -- Mark as degraded if only one node has the data
  386. local isDegraded = not (primaryOk and mirrorOk)
  387. chunkMap[path][i] = {primary = primaryId, mirror = mirrorId, degraded = isDegraded}
  388.  
  389. if start + chunkSize > #content then break end
  390. end
  391.  
  392. if writeSuccess then
  393. saveChunkMap()
  394. clearJournal() -- Operation complete
  395. return true
  396. else
  397. return false, writeError
  398. end
  399. end
  400.  
  401. local status, res, err = pcall(doWrite)
  402.  
  403. -- Always release lock
  404. writeLocks[path] = nil
  405.  
  406. if not status then return false, "Crash during write: " .. tostring(res) end
  407. if not res then return false, err end
  408. return true
  409. end
  410.  
  411. function ndisk.read(path)
  412. if not initialized then ndisk.init() end
  413. local valid, err = checkPath(path)
  414. if not valid then return nil, err end
  415.  
  416. if not chunkMap[path] then
  417. return nil, "File not found"
  418. end
  419.  
  420. local contentChunks = {}
  421. local chunkCount = 0
  422. for _ in pairs(chunkMap[path]) do chunkCount = chunkCount + 1 end
  423.  
  424. for chunkNum = 1, chunkCount do
  425. local chunkInfo = chunkMap[path][chunkNum]
  426. if not chunkInfo then return nil, "Missing chunk info for chunk " .. chunkNum end
  427.  
  428. -- Handle both RAID10 format and legacy format
  429. local primaryId, mirrorId
  430. if type(chunkInfo) == "table" then
  431. primaryId = chunkInfo.primary
  432. mirrorId = chunkInfo.mirror
  433. else
  434. primaryId = chunkInfo
  435. mirrorId = nil
  436. end
  437.  
  438. -- Read with fallback to mirror
  439. local response = sendRequest(primaryId, {
  440. id = generateRequestId(),
  441. action = "read",
  442. path = path,
  443. chunk = chunkNum
  444. }, mirrorId)
  445.  
  446. if response and response.success then
  447. contentChunks[chunkNum] = response.content or ""
  448. else
  449. return nil, "Failed to read chunk " .. chunkNum .. " from nodes"
  450. end
  451. end
  452.  
  453. -- Ensure no nil values in contentChunks before concat
  454. for i = 1, chunkCount do
  455. if contentChunks[i] == nil then
  456. return nil, "Missing content for chunk " .. i
  457. end
  458. end
  459.  
  460. return table.concat(contentChunks)
  461. end
  462.  
  463. function ndisk.delete(path)
  464. if not initialized then ndisk.init() end
  465. local valid, err = checkPath(path)
  466. if not valid then return false, err end
  467.  
  468. if not chunkMap[path] then return false, "File not found" end
  469.  
  470. -- Log Intent
  471. logJournal("DELETE", path)
  472.  
  473. for chunkNum, chunkInfo in pairs(chunkMap[path]) do
  474. local primaryId, mirrorId
  475. if type(chunkInfo) == "table" then
  476. primaryId = chunkInfo.primary
  477. mirrorId = chunkInfo.mirror
  478. else
  479. primaryId = chunkInfo
  480. mirrorId = nil
  481. end
  482.  
  483. -- Delete from primary
  484. sendRequest(primaryId, {
  485. id = generateRequestId(),
  486. action = "delete",
  487. path = path,
  488. chunk = chunkNum
  489. })
  490.  
  491. -- Delete from mirror
  492. if mirrorId and mirrorId ~= primaryId then
  493. sendRequest(mirrorId, {
  494. id = generateRequestId(),
  495. action = "delete",
  496. path = path,
  497. chunk = chunkNum
  498. })
  499. end
  500. end
  501.  
  502. chunkMap[path] = nil
  503. saveChunkMap()
  504. clearJournal()
  505. return true
  506. end
  507.  
  508. function ndisk.exists(path)
  509. if not initialized then ndisk.init() end
  510. if chunkMap[path] then return true end
  511.  
  512. local dirPath = path .. "/"
  513. if path == "" then dirPath = "" end
  514.  
  515. for filename, _ in pairs(chunkMap) do
  516. if filename == path then return true end
  517. if string.sub(filename, 1, #dirPath) == dirPath then
  518. return true
  519. end
  520. end
  521. return false
  522. end
  523.  
  524. function ndisk.isDir(path)
  525. if not initialized then ndisk.init() end
  526. if chunkMap[path] then return false end
  527.  
  528. local dirPath = path .. "/"
  529. if path == "" then return true end
  530.  
  531. for filename, _ in pairs(chunkMap) do
  532. if string.sub(filename, 1, #dirPath) == dirPath then
  533. return true
  534. end
  535. end
  536. return false
  537. end
  538.  
  539. function ndisk.list(path)
  540. if not initialized then ndisk.init() end
  541. local items = {}
  542. local seen = {}
  543.  
  544. local prefix = path
  545. if #prefix > 0 and string.sub(prefix, -1) ~= "/" then
  546. prefix = prefix .. "/"
  547. end
  548. if prefix == "/" then prefix = "" end
  549.  
  550. for filename, _ in pairs(chunkMap) do
  551. if string.find(filename, prefix, 1, true) == 1 then
  552. local remainder = string.sub(filename, #prefix + 1)
  553. if #remainder > 0 then
  554. local firstSlash = string.find(remainder, "/")
  555. local item
  556. if firstSlash then
  557. item = string.sub(remainder, 1, firstSlash - 1)
  558. else
  559. item = remainder
  560. end
  561.  
  562. if not seen[item] then
  563. table.insert(items, item)
  564. seen[item] = true
  565. end
  566. end
  567. end
  568. end
  569. return items
  570. end
  571.  
  572. function ndisk.getFreeSpace()
  573. if not initialized then ndisk.init() end
  574. local total = 0
  575. for _, nodeId in pairs(nodes) do
  576. local response = sendRequest(nodeId, {
  577. id = generateRequestId(),
  578. action = "getSpace"
  579. })
  580. if response and response.success then
  581. total = total + response.space
  582. end
  583. end
  584. -- RAID10 uses 50% for mirroring
  585. local nodeCount = 0
  586. for _ in pairs(nodes) do nodeCount = nodeCount + 1 end
  587. if nodeCount >= 2 then
  588. return math.floor(total / 2)
  589. end
  590. return total
  591. end
  592.  
  593. -- Get RAID status info
  594. function ndisk.getRaidStatus()
  595. if not initialized then ndisk.init() end
  596. local nodeCount = 0
  597. for _ in pairs(nodes) do nodeCount = nodeCount + 1 end
  598.  
  599. return {
  600. enabled = nodeCount >= 2,
  601. nodeCount = nodeCount,
  602. maxFailures = math.floor(nodeCount / 2),
  603. mode = nodeCount >= 2 and "RAID10" or "Single"
  604. }
  605. end
  606.  
  607. -- ============================================
  608. -- BACKGROUND REPAIR
  609. -- ============================================
  610.  
  611. function ndisk.repairDegraded()
  612. if not initialized then ndisk.init() end
  613.  
  614. local degradedCount = 0
  615. local repairedCount = 0
  616.  
  617. print("NDisk Repair: Scanning for degraded chunks...")
  618.  
  619. for path, chunks in pairs(chunkMap) do
  620. for chunkNum, info in pairs(chunks) do
  621. if info.degraded then
  622. degradedCount = degradedCount + 1
  623.  
  624. -- Attempt rewrite to restore redundancy
  625. local content = ndisk.read(path) -- This reads whole file, bit inefficient but safe
  626. if content then
  627. print("Repairing: " .. path)
  628. local success, err = ndisk.write(path, content)
  629. if success then
  630. repairedCount = repairedCount + 1
  631. else
  632. print("Failed to repair " .. path .. ": " .. (err or "unknown"))
  633. end
  634. -- Break inner loop to avoid re-repairing other chunks of same file needlessly if we did full write
  635. break
  636. else
  637. print("Critical: Could not read " .. path .. " to repair it")
  638. end
  639. end
  640. end
  641. end
  642.  
  643. return {found = degradedCount, repaired = repairedCount}
  644. end
  645.  
  646. return ndisk
  647.  
Advertisement
Add Comment
Please, Sign In to add comment