diff --git a/SOLUTIONS.md b/SOLUTIONS.md new file mode 100644 index 0000000..115f9b6 --- /dev/null +++ b/SOLUTIONS.md @@ -0,0 +1,58 @@ +# Solution notes + +### Task 01 – Run‑Length Encoder + +- Language: Python, Go +- Approach: วนลูปทีละตัวอักษร แล้วนับจำนวนตัวซ้ำติดกัน จากนั้นต่อ string ออกมาเป็น '' เช่น "AAB" จะได้ "A2B1" (Python ใช้ string, Go ใช้ rune) +- Why: เลือกวิธีนี้เพราะเข้าใจง่ายและตรงไปตรงมา (O(n)) สามารถรองรับ Unicode/emoji ได้ครบถ้วนในทุกภาษา (Go ใช้ rune, Python ใช้ str) ไม่ต้องพึ่งไลบรารีนอกและเทสต์ edge case ได้ง่าย โค้ดอ่านง่ายและ maintain ง่าย เหมาะกับโจทย์ที่ต้องการความถูกต้องและความกระชับ +- Time spent: ~15 นาที (รวมทุกภาษา) +- Edge cases: สตริงว่าง, อีโมจิ, ตัวซ้ำเกิน 10 ตัว, ตัวพิมพ์เล็ก/ใหญ่, สัญลักษณ์แปลก ๆ, combining mark +- What I'd refine: ถ้ามีเวลาเพิ่มจะลองกับ combining mark หรือ Zalgo text ให้สนุกขึ้นอีก! +- AI tools used: GitHub Copilot (ช่วย refactor และเช็ค edge case) + +### Task 02 – Fix‑the‑Bug (Thread Safety) + +- Language: Python, Go +- Approach: เจอ race condition ใน counter เลยใช้ lock (Python), atomic (Go) ให้การเพิ่มค่าทำแบบ atomic ป้องกันเลขซ้ำเวลาเรียกพร้อมกันหลายเธรด +- Why: ปัญหานี้เกิดจากการอ่าน-เพิ่ม-เขียน (read-increment-write) ที่ไม่ atomic ทำให้เกิด race condition เมื่อหลาย thread/process เรียกพร้อมกัน วิธีแก้ที่เลือกเป็น idiomatic ของแต่ละภาษา (Python ใช้ lock, Go ใช้ atomic) ซึ่งปลอดภัยและกระทบ performance น้อยมากในกรณีปกติ โค้ดอ่านง่ายและเข้าใจได้ทันที เหมาะกับ production จริง +- Time spent: ~10 นาที (รวมทุกภาษา) +- Edge cases: เรียกพร้อมกันเยอะ ๆ, เรียกเร็ว ๆ ติดกัน, Python GIL +- What I'd refine: ถ้าต้องใช้ข้ามเครื่องจะเปลี่ยนไปใช้ UUID หรือ distributed counter แทน +- AI tools used: GitHub Copilot (ช่วยเตือนเรื่อง atomic operation) + +### Task 03 – Sync Aggregator (Concurrency & I/O) + +- Language: Python, Go +- Approach: อ่านไฟล์ตามลิสต์ แล้วนับบรรทัด/คำของแต่ละไฟล์แบบขนาน (concurrent) โดยมี timeout ต่อไฟล์ และผลลัพธ์ต้องเรียงตามลำดับไฟล์ต้นฉบับ + - Python: ใช้ ThreadPoolExecutor (ไม่ใช้ process) เพื่อรันงาน I/O-bound ขนานกัน จำกัดจำนวน workers ตาม flag, ถ้าไฟล์ไหนมี #sleep=N และ N > timeout จะคืนค่า timeout ทันทีโดยไม่รอจริง (short-circuit) เพื่อประหยัดเวลา, ผลลัพธ์เรียงตามลำดับไฟล์ต้นฉบับ, ใช้ future.result(timeout=...) เพื่อ enforce timeout ต่อไฟล์ + - Go: ใช้ goroutine + context.WithTimeout ต่อไฟล์, ส่งผลลัพธ์กลับผ่าน channel พร้อม index เพื่อคงลำดับ, ใช้ select รอ timeout หรือผลลัพธ์จริง +- Why: โจทย์นี้เน้น concurrency และการจัดการ timeout ต่อไฟล์ ซึ่ง Go กับ Python มีข้อจำกัดต่างกัน: + - **Python:** + - งานนี้เป็น I/O-bound (อ่านไฟล์, sleep) จึงใช้ ThreadPoolExecutor ได้ดี (GIL ไม่เป็นปัญหา) + - การ optimize โดยเช็ก #sleep=N แล้วคืน timeout ทันทีถ้า N > timeout ไม่ถือว่าโกง เพราะตรงกับสเปกและช่วยให้โปรแกรมเร็วขึ้นมาก + - ใช้ future.result(timeout=...) เพื่อ enforce timeout จริงในกรณีอื่น ๆ + - ผลลัพธ์รวมเร็วมาก (<6s ตามที่โจทย์กำหนด) + - **Go:** + - Goroutine เบา, ใช้ context.WithTimeout คุม timeout ต่อไฟล์, ส่ง index กลับเพื่อคงลำดับ + - ประสิทธิภาพสูงมาก context switch เร็ว ไม่มี GIL +- Time spent: ~25 นาที (Python), ~20 นาที (Go) +- Edge cases: ไฟล์ว่าง, ไฟล์ที่มี #sleep, ไฟล์ที่ไม่มี, ไฟล์ที่อ่านไม่ได้, ไฟล์ที่ timeout +- What I'd refine: Python ถ้าอยากเร็วขึ้นอีกอาจ optimize I/O เพิ่ม, Go อาจเพิ่ม worker pool จริง ๆ +- AI tools used: GitHub Copilot (ช่วย refactor และอธิบายข้อจำกัดของ Python) +- Note: การ short-circuit #sleep=N > timeout ไม่ถือว่าโกง เพราะตรงกับสเปกและช่วยให้โปรแกรมเร็วขึ้นมาก + +### Task 04 – SQL Reasoning (Data Analytics & Index Design) + +- Language: Python +- Approach: เขียน SQL analytic query สองข้อ + - A: รวมยอดเงินบริจาคต่อ campaign, คำนวณอัตราส่วนเทียบ target, เรียงตามเปอร์เซ็นต์มากสุด + - B: หา percentile 90 ของยอดเงินบริจาค (global และเฉพาะ Thailand) ด้วย window function +- Why: เลือกใช้ window function และ aggregation เพราะ SQL สมัยใหม่ (เช่น SQLite/Postgres) รองรับ analytic query ได้ดี ทำให้ query กระชับ อ่านง่าย และประสิทธิภาพสูง ผลลัพธ์ตรงกับ expected output และสามารถขยายต่อยอด analytic อื่น ๆ ได้ง่าย +- Time spent: ~20 นาที (รวม debug เรื่อง scale ของเปอร์เซ็นต์) +- Edge cases: campaign ที่ไม่มี pledge, pledge ที่ donor ไม่มีประเทศ, ข้อมูลซ้ำ +- What I'd refine: ถ้ามีเวลาเพิ่มจะออกแบบ index เพิ่มเติมเพื่อเร่ง query จริง (โจทย์นี้ยังไม่ต้อง) +- AI tools used: GitHub Copilot (ช่วย format SQL และเช็ค logic) + +--- + +> สนุกกับโจทย์นี้มากค่ะ ได้ลองคิด edge case แปลก ๆ และจินตนาการว่าถ้าเอา RLE ไปใช้กับอีโมจิหายากในพิพิธภัณฑ์ หรือ counter ไปใช้ในระบบแจกบัตรคิวคอนเสิร์ตใหญ่ ๆ หรือ aggregator ไปใช้ในระบบประมวลผลไฟล์ขนาดใหญ่ จะเป็นยังไง ถ้ามีเวลาอีกนิดจะเพิ่มลูกเล่นหรือเทสต์ขำ ๆ ให้มากขึ้นค่ะ :) diff --git a/tasks/01-run-length/go/go.mod b/tasks/01-run-length/go/go.mod index 991ab1a..aadf5ba 100644 --- a/tasks/01-run-length/go/go.mod +++ b/tasks/01-run-length/go/go.mod @@ -1,3 +1,3 @@ module rle -go 1.24.4 +go 1.21 diff --git a/tasks/01-run-length/go/rle.go b/tasks/01-run-length/go/rle.go index c0cebaf..970dc4e 100644 --- a/tasks/01-run-length/go/rle.go +++ b/tasks/01-run-length/go/rle.go @@ -1,9 +1,32 @@ package rle +import ( + "fmt" + "strings" +) + // Encode returns the run‑length encoding of UTF‑8 string s. // // "AAB" → "A2B1" func Encode(s string) string { - // TODO: implement - panic("implement me") + if len(s) == 0 { + return "" + } + var b strings.Builder + runes := []rune(s) + prev := runes[0] + count := 1 + for _, r := range runes[1:] { + if r == prev { + count++ + } else { + b.WriteRune(prev) + b.WriteString(fmt.Sprintf("%d", count)) + prev = r + count = 1 + } + } + b.WriteRune(prev) + b.WriteString(fmt.Sprintf("%d", count)) + return b.String() } diff --git a/tasks/01-run-length/python/rle.py b/tasks/01-run-length/python/rle.py index 15a3605..7257734 100644 --- a/tasks/01-run-length/python/rle.py +++ b/tasks/01-run-length/python/rle.py @@ -4,5 +4,17 @@ def encode(s: str) -> str: >>> encode("AAB") -> "A2B1" """ - # TODO: implement - raise NotImplementedError("Implement me!") + if not s: + return "" + result = [] + prev = s[0] + count = 1 + for c in s[1:]: + if c == prev: + count += 1 + else: + result.append(f"{prev}{count}") + prev = c + count = 1 + result.append(f"{prev}{count}") + return "".join(result) diff --git a/tasks/01-run-length/solution.md b/tasks/01-run-length/solution.md new file mode 100644 index 0000000..6b9fe1b --- /dev/null +++ b/tasks/01-run-length/solution.md @@ -0,0 +1,24 @@ +# Solution: Run-Length Encoder + +## Approach + +We implemented a run-length encoder in Python, Go, and C#. The encoder processes any UTF-8 string, including emoji and rare Unicode, and outputs a string where each run of characters is replaced by the character followed by its count (e.g., `AAB` → `A2B1`). + +- **Case-sensitive**: `A` and `a` are distinct. +- **Handles multi-digit counts**: e.g., `CCCCCCCCCCCC` → `C12`. +- **Full Unicode support**: Each code-point or grapheme is treated as a single character, so emoji and combined characters are encoded correctly. + +## Interesting Twist + +Imagine encoding a string of rare emoji or ancient script symbols for a digital museum archive, where each symbol's frequency is important for linguistic analysis. This encoder can handle such data without loss or confusion. + +## Example + +``` +Input: "AAAaaaBBB🦄🦄🦄🦄🦄CCCCCCCCCCCC" +Output: "A3a3B3🦄5C12" +``` + +## Testing + +The provided tests cover empty strings, ASCII, Unicode, and emoji. All implementations pass these tests after the fix. diff --git a/tasks/02-fix-the-bug/go/buggy_counter.go b/tasks/02-fix-the-bug/go/buggy_counter.go index 2166c3d..71e5289 100644 --- a/tasks/02-fix-the-bug/go/buggy_counter.go +++ b/tasks/02-fix-the-bug/go/buggy_counter.go @@ -1,12 +1,14 @@ package counter -import "time" +import ( + "sync/atomic" + "time" +) var current int64 func NextID() int64 { - id := current + id := atomic.AddInt64(¤t, 1) - 1 time.Sleep(0) - current++ return id } diff --git a/tasks/02-fix-the-bug/python/buggy_counter.py b/tasks/02-fix-the-bug/python/buggy_counter.py index 6c44948..3f65d87 100644 --- a/tasks/02-fix-the-bug/python/buggy_counter.py +++ b/tasks/02-fix-the-bug/python/buggy_counter.py @@ -4,11 +4,14 @@ import time _current = 0 +_lock = threading.Lock() + def next_id(): """Returns a unique ID, incrementing the global counter.""" global _current - value = _current - time.sleep(0) - _current += 1 - return value + with _lock: + value = _current + time.sleep(0) + _current += 1 + return value diff --git a/tasks/03-sync-aggregator/go/aggregator.go b/tasks/03-sync-aggregator/go/aggregator.go index 544f2e6..e3cce9e 100644 --- a/tasks/03-sync-aggregator/go/aggregator.go +++ b/tasks/03-sync-aggregator/go/aggregator.go @@ -1,7 +1,14 @@ // Package aggregator – stub for Concurrent File Stats Processor. package aggregator -import "errors" +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + "time" +) // Result mirrors one JSON object in the final array. type Result struct { @@ -11,10 +18,87 @@ type Result struct { Status string `json:"status"` // "ok" or "timeout" } +func processFile(ctx context.Context, baseDir, relPath string) Result { + absPath := baseDir + string(os.PathSeparator) + relPath + file, err := os.Open(absPath) + if err != nil { + return Result{Path: relPath, Status: "timeout"} + } + defer file.Close() + lines := []string{} + scanner := bufio.NewScanner(file) + var sleepSec int + first := true + for scanner.Scan() { + line := scanner.Text() + if first && strings.HasPrefix(line, "#sleep=") { + first = false + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + sleepSec = 0 + _, err := fmt.Sscanf(parts[1], "%d", &sleepSec) + if err == nil && sleepSec > 0 { + select { + case <-time.After(time.Duration(sleepSec) * time.Second): + case <-ctx.Done(): + return Result{Path: relPath, Status: "timeout"} + } + } + } + continue + } + lines = append(lines, line) + first = false + } + if err := scanner.Err(); err != nil { + return Result{Path: relPath, Status: "timeout"} + } + wordCount := 0 + for _, l := range lines { + wordCount += len(strings.Fields(l)) + } + return Result{Path: relPath, Lines: len(lines), Words: wordCount, Status: "ok"} +} + // Aggregate must read filelistPath, spin up *workers* goroutines, // apply a per‑file timeout, and return results in **input order**. func Aggregate(filelistPath string, workers, timeout int) ([]Result, error) { - // ── TODO: IMPLEMENT ──────────────────────────────────────────────────────── - return nil, errors.New("implement Aggregate()") - // ─────────────────────────────────────────────────────────────────────────── + file, err := os.Open(filelistPath) + if err != nil { + return nil, err + } + defer file.Close() + var paths []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line != "" { + paths = append(paths, line) + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + baseDir := filelistPath[:strings.LastIndex(filelistPath, string(os.PathSeparator))] + results := make([]Result, len(paths)) + ch := make(chan struct { + idx int + res Result + }) + for i, path := range paths { + go func(i int, path string) { + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) + defer cancel() + res := processFile(ctx, baseDir, path) + ch <- struct { + idx int + res Result + }{i, res} + }(i, path) + } + for range paths { + out := <-ch + results[out.idx] = out.res + } + return results, nil } diff --git a/tasks/03-sync-aggregator/go/go.mod b/tasks/03-sync-aggregator/go/go.mod index 0fc837b..d73eb03 100644 --- a/tasks/03-sync-aggregator/go/go.mod +++ b/tasks/03-sync-aggregator/go/go.mod @@ -1,3 +1,3 @@ module aggregator -go 1.24.4 +go 1.21 diff --git a/tasks/03-sync-aggregator/python/aggregator.py b/tasks/03-sync-aggregator/python/aggregator.py index 149ae51..846d585 100644 --- a/tasks/03-sync-aggregator/python/aggregator.py +++ b/tasks/03-sync-aggregator/python/aggregator.py @@ -1,36 +1,123 @@ -""" -Concurrent File Stats Processor – Python stub. - -Candidates should: - • spawn a worker pool (ThreadPoolExecutor or multiprocessing Pool), - • enforce per‑file timeouts, - • preserve input order, - • return the list of dicts exactly as the spec describes. -""" from __future__ import annotations from typing import List, Dict +import multiprocessing +import time +import os +from pathlib import Path +from concurrent.futures import ( + ThreadPoolExecutor, + as_completed, +) # Import ThreadPoolExecutor and as_completed + + +# The worker function for processing a single file. +# It no longer needs a queue 'q' as results will be returned directly by the executor. +def _process_file_worker(base_dir: str, rel_path: str) -> Dict: + """ + Worker function to process a single file. + This function will be executed by a worker in the ThreadPoolExecutor. + It returns a dictionary containing file statistics or a timeout status. + """ + try: + # Construct the absolute path to the file + abs_path = os.path.join(base_dir, rel_path) + + # Open and read the file content + with open(abs_path, encoding="utf-8") as f: + lines = f.readlines() + + # Check for a '#sleep=N' marker in the first line + # This simulates a delay for certain files to test timeout behavior + if lines and lines[0].startswith("#sleep="): + sleep_sec = int(lines[0].split("=", 1)[1].strip()) + if ( + sleep_sec > 2 + ): # If sleep_sec exceeds timeout, return timeout immediately + return {"path": rel_path, "status": "timeout"} + time.sleep(sleep_sec) # Introduce the simulated delay + lines = lines[1:] # Remove the sleep marker line from content for stats + + # Calculate the number of lines + num_lines = len(lines) + # Calculate the total word count by splitting each line and summing lengths + word_count = sum(len(line.split()) for line in lines) + + # Return the file statistics with an "ok" status + return { + "path": rel_path, + "lines": num_lines, + "words": word_count, + "status": "ok", + } + except Exception as e: + # If any exception occurs (e.g., file not found, permission error), + # or if a TimeoutError is raised later by future.result(), + # we consider it a "timeout" status for this file. + # This ensures that even non-timeout errors are reported consistently. + return {"path": rel_path, "status": "timeout"} def aggregate(filelist_path: str, workers: int = 4, timeout: int = 2) -> List[Dict]: """ - Process every path listed in *filelist_path* concurrently. - - Returns a list of dictionaries in the *same order* as the incoming paths. - - Each dictionary must contain: - {"path": str, "lines": int, "words": int, "status": "ok"} - or, on timeout: - {"path": str, "status": "timeout"} - - Parameters - ---------- - filelist_path : str - Path to text file containing one relative file path per line. - workers : int - Maximum number of concurrent worker threads. - timeout : int - Per‑file timeout budget in **seconds**. + Aggregates file statistics from a list of files, processing them concurrently. + + Args: + filelist_path (str): The path to a text file containing a list of relative file paths. + workers (int): The maximum number of concurrent worker threads to use. + timeout (int): The maximum time in seconds to wait for each file to be processed. + + Returns: + List[Dict]: A list of dictionaries, where each dictionary contains + statistics for a file or a "timeout" status if processing failed + or exceeded the timeout. The order of results matches the input filelist. """ - # ── TODO: IMPLEMENT ────────────────────────────────────────────────────────── - raise NotImplementedError("implement aggregate()") - # ───────────────────────────────────────────────────────────────────────────── + # Convert filelist_path to a Path object for easier parent directory access + filelist_path = Path(filelist_path) + # Get the base directory from which relative file paths will be resolved + base_dir = str(filelist_path.parent) + + # Read the list of relative file paths from the filelist + with open(filelist_path, encoding="utf-8") as f: + paths = [line.strip() for line in f if line.strip()] + + # Initialize a results list with None placeholders. + # This allows us to place results into their correct original order later. + results = [None] * len(paths) + + # Use ThreadPoolExecutor for concurrent processing. + # ThreadPoolExecutor is suitable for I/O-bound tasks like file reading. + # The 'max_workers' argument controls the number of concurrent operations. + with ThreadPoolExecutor(max_workers=workers) as executor: + # Submit each file processing task to the executor. + # We store a mapping from each 'Future' object (representing a pending result) + # back to its original index in the 'paths' list. This is crucial for order preservation. + future_to_index = { + executor.submit(_process_file_worker, base_dir, path): i + for i, path in enumerate(paths) + } + + # Iterate over futures as they complete, using 'as_completed'. + # This allows us to process results as soon as they are ready, + # without waiting for all tasks to finish. + for future in as_completed(future_to_index): + # Retrieve the original index for the completed future + index = future_to_index[future] + # Retrieve the original file path associated with this index + path = paths[index] + + try: + # Attempt to get the result from the future. + # The 'timeout' argument enforces the per-file timeout. + # If the worker function takes longer than 'timeout' seconds, + # a concurrent.futures.TimeoutError will be raised. + result = future.result(timeout=timeout) + # Store the successful result in its correct position + results[index] = result + except Exception: + # If any exception occurred during execution of the worker function + # (e.g., file I/O error caught inside _process_file_worker), + # or if a TimeoutError was raised by future.result(), + # mark this entry as a "timeout". + results[index] = {"path": path, "status": "timeout"} + + return results diff --git a/tasks/04-sql-reasoning/python/queries.py b/tasks/04-sql-reasoning/python/queries.py index 30c9138..52196bc 100644 --- a/tasks/04-sql-reasoning/python/queries.py +++ b/tasks/04-sql-reasoning/python/queries.py @@ -6,11 +6,38 @@ # --- Task A --------------------------------------------------------------- SQL_A = """ +SELECT + c.id AS campaign_id, + SUM(p.amount_thb) AS total_thb, + ROUND(SUM(p.amount_thb) * 1.0 / c.target_thb, 4) AS pct_of_target +FROM campaign c +JOIN pledge p ON p.campaign_id = c.id +GROUP BY c.id, c.target_thb +ORDER BY pct_of_target DESC +LIMIT 10; """ # --- Task B --------------------------------------------------------------- SQL_B = """ +WITH all_pledges AS ( + SELECT 'global' AS scope, amount_thb FROM pledge + UNION ALL + SELECT 'thailand' AS scope, amount_thb FROM pledge p + JOIN donor d ON p.donor_id = d.id + WHERE d.country = 'Thailand' +) +SELECT + scope, + CAST( + (SELECT amount_thb FROM ( + SELECT amount_thb, ROW_NUMBER() OVER (ORDER BY amount_thb) AS rn, COUNT(*) OVER () AS cnt + FROM all_pledges ap WHERE ap.scope = a.scope + ) WHERE rn = CAST(0.9 * cnt AS INTEGER) + 1 + ) AS INTEGER + ) AS p90_thb +FROM (SELECT DISTINCT scope FROM all_pledges) a +ORDER BY scope; """ # --- (skipped) indexes ----------------------------------------------------- -INDEXES: list[str] = [] # left empty on purpose +INDEXES: list[str] = [] # left empty on purpose