···1package mempool
2+3+import (
4+ "bufio"
5+ "bytes"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "sync"
10+ "time"
11+12+ "github.com/goccy/go-json"
13+ "tangled.org/atscan.net/plcbundle/internal/types"
14+ "tangled.org/atscan.net/plcbundle/plcclient"
15+)
16+17+const MEMPOOL_FILE_PREFIX = "plc_mempool_"
18+19+// Mempool stores operations waiting to be bundled
20+// Operations must be strictly chronological
21+type Mempool struct {
22+ operations []plcclient.PLCOperation
23+ targetBundle int // Which bundle number these operations are for
24+ minTimestamp time.Time // Operations must be after this time
25+ file string
26+ mu sync.RWMutex
27+ logger types.Logger
28+ validated bool // Track if we've validated chronological order
29+ dirty bool // Track if mempool changed
30+}
31+32+// NewMempool creates a new mempool for a specific bundle number
33+func NewMempool(bundleDir string, targetBundle int, minTimestamp time.Time, logger types.Logger) (*Mempool, error) {
34+ filename := fmt.Sprintf("%s%06d.jsonl", MEMPOOL_FILE_PREFIX, targetBundle)
35+36+ m := &Mempool{
37+ file: filepath.Join(bundleDir, filename),
38+ targetBundle: targetBundle,
39+ minTimestamp: minTimestamp,
40+ operations: make([]plcclient.PLCOperation, 0),
41+ logger: logger,
42+ validated: false,
43+ }
44+45+ // Load existing mempool from disk if it exists
46+ if err := m.Load(); err != nil {
47+ // If file doesn't exist, that's OK
48+ if !os.IsNotExist(err) {
49+ return nil, fmt.Errorf("failed to load mempool: %w", err)
50+ }
51+ }
52+53+ return m, nil
54+}
55+56+// Add adds operations to the mempool with strict validation
57+func (m *Mempool) Add(ops []plcclient.PLCOperation) (int, error) {
58+ m.mu.Lock()
59+ defer m.mu.Unlock()
60+61+ if len(ops) == 0 {
62+ return 0, nil
63+ }
64+65+ // Build existing CID set
66+ existingCIDs := make(map[string]bool)
67+ for _, op := range m.operations {
68+ existingCIDs[op.CID] = true
69+ }
70+71+ // Validate and add operations
72+ var newOps []plcclient.PLCOperation
73+ var lastTime time.Time
74+75+ // Start from last operation time if we have any
76+ if len(m.operations) > 0 {
77+ lastTime = m.operations[len(m.operations)-1].CreatedAt
78+ } else {
79+ lastTime = m.minTimestamp
80+ }
81+82+ for _, op := range ops {
83+ // Skip duplicates
84+ if existingCIDs[op.CID] {
85+ continue
86+ }
87+88+ // CRITICAL: Validate chronological order
89+ if !op.CreatedAt.After(lastTime) && !op.CreatedAt.Equal(lastTime) {
90+ return len(newOps), fmt.Errorf(
91+ "chronological violation: operation %s at %s is not after %s",
92+ op.CID, op.CreatedAt.Format(time.RFC3339Nano), lastTime.Format(time.RFC3339Nano),
93+ )
94+ }
95+96+ // Validate operation is after minimum timestamp
97+ if op.CreatedAt.Before(m.minTimestamp) {
98+ return len(newOps), fmt.Errorf(
99+ "operation %s at %s is before minimum timestamp %s (belongs in earlier bundle)",
100+ op.CID, op.CreatedAt.Format(time.RFC3339Nano), m.minTimestamp.Format(time.RFC3339Nano),
101+ )
102+ }
103+104+ newOps = append(newOps, op)
105+ existingCIDs[op.CID] = true
106+ lastTime = op.CreatedAt
107+ }
108+109+ // Add new operations
110+ m.operations = append(m.operations, newOps...)
111+ m.validated = true
112+ m.dirty = true
113+114+ return len(newOps), nil
115+}
116+117+// Validate performs a full chronological validation of all operations
118+func (m *Mempool) Validate() error {
119+ m.mu.RLock()
120+ defer m.mu.RUnlock()
121+122+ if len(m.operations) == 0 {
123+ return nil
124+ }
125+126+ // Check all operations are after minimum timestamp
127+ for i, op := range m.operations {
128+ if op.CreatedAt.Before(m.minTimestamp) {
129+ return fmt.Errorf(
130+ "operation %d (CID: %s) at %s is before minimum timestamp %s",
131+ i, op.CID, op.CreatedAt.Format(time.RFC3339Nano), m.minTimestamp.Format(time.RFC3339Nano),
132+ )
133+ }
134+ }
135+136+ // Check chronological order
137+ for i := 1; i < len(m.operations); i++ {
138+ prev := m.operations[i-1]
139+ curr := m.operations[i]
140+141+ if curr.CreatedAt.Before(prev.CreatedAt) {
142+ return fmt.Errorf(
143+ "chronological violation at index %d: %s (%s) is before %s (%s)",
144+ i, curr.CID, curr.CreatedAt.Format(time.RFC3339Nano),
145+ prev.CID, prev.CreatedAt.Format(time.RFC3339Nano),
146+ )
147+ }
148+ }
149+150+ // Check for duplicate CIDs
151+ cidSet := make(map[string]int)
152+ for i, op := range m.operations {
153+ if prevIdx, exists := cidSet[op.CID]; exists {
154+ return fmt.Errorf(
155+ "duplicate CID %s at indices %d and %d",
156+ op.CID, prevIdx, i,
157+ )
158+ }
159+ cidSet[op.CID] = i
160+ }
161+162+ return nil
163+}
164+165+// Count returns the number of operations in mempool
166+func (m *Mempool) Count() int {
167+ m.mu.RLock()
168+ defer m.mu.RUnlock()
169+ return len(m.operations)
170+}
171+172+// Take removes and returns up to n operations from the front
173+func (m *Mempool) Take(n int) ([]plcclient.PLCOperation, error) {
174+ m.mu.Lock()
175+ defer m.mu.Unlock()
176+177+ // Validate before taking
178+ if err := m.validateLocked(); err != nil {
179+ return nil, fmt.Errorf("mempool validation failed: %w", err)
180+ }
181+182+ if n > len(m.operations) {
183+ n = len(m.operations)
184+ }
185+186+ result := make([]plcclient.PLCOperation, n)
187+ copy(result, m.operations[:n])
188+189+ // Remove taken operations
190+ m.operations = m.operations[n:]
191+192+ return result, nil
193+}
194+195+// validateLocked performs validation with lock already held
196+func (m *Mempool) validateLocked() error {
197+ if m.validated {
198+ return nil
199+ }
200+201+ if len(m.operations) == 0 {
202+ return nil
203+ }
204+205+ // Check chronological order
206+ lastTime := m.minTimestamp
207+ for i, op := range m.operations {
208+ if op.CreatedAt.Before(lastTime) {
209+ return fmt.Errorf(
210+ "chronological violation at index %d: %s is before %s",
211+ i, op.CreatedAt.Format(time.RFC3339Nano), lastTime.Format(time.RFC3339Nano),
212+ )
213+ }
214+ lastTime = op.CreatedAt
215+ }
216+217+ m.validated = true
218+ return nil
219+}
220+221+// Peek returns up to n operations without removing them
222+func (m *Mempool) Peek(n int) []plcclient.PLCOperation {
223+ m.mu.RLock()
224+ defer m.mu.RUnlock()
225+226+ if n > len(m.operations) {
227+ n = len(m.operations)
228+ }
229+230+ result := make([]plcclient.PLCOperation, n)
231+ copy(result, m.operations[:n])
232+233+ return result
234+}
235+236+// Clear removes all operations
237+func (m *Mempool) Clear() {
238+ m.mu.Lock()
239+ defer m.mu.Unlock()
240+ m.operations = make([]plcclient.PLCOperation, 0)
241+ m.validated = false
242+}
243+244+// Save persists mempool to disk
245+func (m *Mempool) Save() error {
246+ m.mu.Lock()
247+ defer m.mu.Unlock()
248+249+ if !m.dirty {
250+ return nil
251+ }
252+253+ if len(m.operations) == 0 {
254+ // Remove file if empty
255+ os.Remove(m.file)
256+ return nil
257+ }
258+259+ // Validate before saving
260+ if err := m.validateLocked(); err != nil {
261+ return fmt.Errorf("mempool validation failed, refusing to save: %w", err)
262+ }
263+264+ // Serialize to JSONL
265+ var buf bytes.Buffer
266+ for _, op := range m.operations {
267+ if len(op.RawJSON) > 0 {
268+ buf.Write(op.RawJSON)
269+ } else {
270+ data, _ := json.Marshal(op)
271+ buf.Write(data)
272+ }
273+ buf.WriteByte('\n')
274+ }
275+276+ // Write atomically
277+ tempFile := m.file + ".tmp"
278+ if err := os.WriteFile(tempFile, buf.Bytes(), 0644); err != nil {
279+ return fmt.Errorf("failed to write mempool: %w", err)
280+ }
281+282+ if err := os.Rename(tempFile, m.file); err != nil {
283+ os.Remove(tempFile)
284+ return fmt.Errorf("failed to rename mempool file: %w", err)
285+ }
286+287+ m.dirty = false
288+ return nil
289+}
290+291+// Load reads mempool from disk and validates it
292+func (m *Mempool) Load() error {
293+ data, err := os.ReadFile(m.file)
294+ if err != nil {
295+ return err
296+ }
297+298+ m.mu.Lock()
299+ defer m.mu.Unlock()
300+301+ // Parse JSONL
302+ scanner := bufio.NewScanner(bytes.NewReader(data))
303+ buf := make([]byte, 0, 64*1024)
304+ scanner.Buffer(buf, 1024*1024)
305+306+ m.operations = make([]plcclient.PLCOperation, 0)
307+308+ for scanner.Scan() {
309+ line := scanner.Bytes()
310+ if len(line) == 0 {
311+ continue
312+ }
313+314+ var op plcclient.PLCOperation
315+ if err := json.Unmarshal(line, &op); err != nil {
316+ return fmt.Errorf("failed to parse mempool operation: %w", err)
317+ }
318+319+ op.RawJSON = make([]byte, len(line))
320+ copy(op.RawJSON, line)
321+322+ m.operations = append(m.operations, op)
323+ }
324+325+ if err := scanner.Err(); err != nil {
326+ return fmt.Errorf("scanner error: %w", err)
327+ }
328+329+ // CRITICAL: Validate loaded data
330+ if err := m.validateLocked(); err != nil {
331+ return fmt.Errorf("loaded mempool failed validation: %w", err)
332+ }
333+334+ if len(m.operations) > 0 {
335+ m.logger.Printf("Loaded %d operations from mempool for bundle %06d", len(m.operations), m.targetBundle)
336+ }
337+338+ return nil
339+}
340+341+// GetFirstTime returns the created_at of the first operation
342+func (m *Mempool) GetFirstTime() string {
343+ m.mu.RLock()
344+ defer m.mu.RUnlock()
345+346+ if len(m.operations) == 0 {
347+ return ""
348+ }
349+350+ return m.operations[0].CreatedAt.Format(time.RFC3339Nano)
351+}
352+353+// GetLastTime returns the created_at of the last operation
354+func (m *Mempool) GetLastTime() string {
355+ m.mu.RLock()
356+ defer m.mu.RUnlock()
357+358+ if len(m.operations) == 0 {
359+ return ""
360+ }
361+362+ return m.operations[len(m.operations)-1].CreatedAt.Format(time.RFC3339Nano)
363+}
364+365+// GetTargetBundle returns the bundle number this mempool is for
366+func (m *Mempool) GetTargetBundle() int {
367+ return m.targetBundle
368+}
369+370+// GetMinTimestamp returns the minimum timestamp for operations
371+func (m *Mempool) GetMinTimestamp() time.Time {
372+ return m.minTimestamp
373+}
374+375+// Stats returns mempool statistics
376+func (m *Mempool) Stats() map[string]interface{} {
377+ m.mu.RLock()
378+ defer m.mu.RUnlock()
379+380+ count := len(m.operations)
381+382+ stats := map[string]interface{}{
383+ "count": count,
384+ "can_create_bundle": count >= types.BUNDLE_SIZE,
385+ "target_bundle": m.targetBundle,
386+ "min_timestamp": m.minTimestamp,
387+ "validated": m.validated,
388+ }
389+390+ if count > 0 {
391+ stats["first_time"] = m.operations[0].CreatedAt
392+ stats["last_time"] = m.operations[len(m.operations)-1].CreatedAt
393+394+ // Calculate size and unique DIDs
395+ totalSize := 0
396+ didSet := make(map[string]bool)
397+ for _, op := range m.operations {
398+ totalSize += len(op.RawJSON)
399+ didSet[op.DID] = true
400+ }
401+ stats["size_bytes"] = totalSize
402+ stats["did_count"] = len(didSet)
403+ }
404+405+ return stats
406+}
407+408+// Delete removes the mempool file
409+func (m *Mempool) Delete() error {
410+ if err := os.Remove(m.file); err != nil && !os.IsNotExist(err) {
411+ return fmt.Errorf("failed to delete mempool file: %w", err)
412+ }
413+ return nil
414+}
415+416+// GetFilename returns the mempool filename
417+func (m *Mempool) GetFilename() string {
418+ return filepath.Base(m.file)
419+}
+18
internal/types/types.go
···000000000000000000
···1+package types
2+3+// Logger is a simple logging interface used throughout plcbundle
4+type Logger interface {
5+ Printf(format string, v ...interface{})
6+ Println(v ...interface{})
7+}
8+9+const (
10+ // BUNDLE_SIZE is the standard number of operations per bundle
11+ BUNDLE_SIZE = 10000
12+13+ // INDEX_FILE is the default index filename
14+ INDEX_FILE = "plc_bundles.json"
15+16+ // INDEX_VERSION is the current index format version
17+ INDEX_VERSION = "1.0"
18+)
-148
plcbundle.go
···1-package plcbundle
2-3-import (
4- "context"
5- "io"
6- "time"
7-8- "tangled.org/atscan.net/plcbundle/bundle"
9- "tangled.org/atscan.net/plcbundle/plcclient"
10-)
11-12-// Re-export commonly used types for convenience
13-type (
14- Bundle = bundle.Bundle
15- BundleMetadata = bundle.BundleMetadata
16- Index = bundle.Index
17- Manager = bundle.Manager
18- Config = bundle.Config
19- VerificationResult = bundle.VerificationResult
20- ChainVerificationResult = bundle.ChainVerificationResult
21- DirectoryScanResult = bundle.DirectoryScanResult
22- Logger = bundle.Logger
23-24- PLCOperation = plcclient.PLCOperation
25- PLCClient = plcclient.Client
26- ExportOptions = plcclient.ExportOptions
27-)
28-29-// Re-export constants
30-const (
31- BUNDLE_SIZE = bundle.BUNDLE_SIZE
32- INDEX_FILE = bundle.INDEX_FILE
33-)
34-35-// NewManager creates a new bundle manager (convenience wrapper)
36-func NewManager(config *Config, plcClient *PLCClient) (*Manager, error) {
37- return bundle.NewManager(config, plcClient)
38-}
39-40-// NewPLCClient creates a new PLC client (convenience wrapper)
41-func NewPLCClient(baseURL string, opts ...plcclient.ClientOption) *PLCClient {
42- return plcclient.NewClient(baseURL, opts...)
43-}
44-45-// DefaultConfig returns default configuration (convenience wrapper)
46-func DefaultConfig(bundleDir string) *Config {
47- return bundle.DefaultConfig(bundleDir)
48-}
49-50-// NewIndex creates a new empty index (convenience wrapper)
51-func NewIndex(origin string) *Index {
52- return bundle.NewIndex(origin)
53-}
54-55-// LoadIndex loads an index from a file (convenience wrapper)
56-func LoadIndex(path string) (*Index, error) {
57- return bundle.LoadIndex(path)
58-}
59-60-// BundleManager provides a high-level API for bundle operations
61-type BundleManager struct {
62- mgr *Manager
63-}
64-65-// New creates a new BundleManager with default settings
66-func New(bundleDir string, plcURL string) (*BundleManager, error) {
67- config := DefaultConfig(bundleDir)
68- var plcClient *PLCClient
69- if plcURL != "" {
70- plcClient = NewPLCClient(plcURL)
71- }
72-73- mgr, err := NewManager(config, plcClient)
74- if err != nil {
75- return nil, err
76- }
77-78- return &BundleManager{mgr: mgr}, nil
79-}
80-81-// Close closes the manager
82-func (bm *BundleManager) Close() {
83- bm.mgr.Close()
84-}
85-86-// FetchNext fetches the next bundle from PLC
87-func (bm *BundleManager) FetchNext(ctx context.Context) (*Bundle, error) {
88- b, err := bm.mgr.FetchNextBundle(ctx, false)
89- if err != nil {
90- return nil, err
91- }
92- return b, bm.mgr.SaveBundle(ctx, b, false)
93-}
94-95-// Load loads a bundle by number
96-func (bm *BundleManager) Load(ctx context.Context, bundleNumber int) (*Bundle, error) {
97- return bm.mgr.LoadBundle(ctx, bundleNumber)
98-}
99-100-// Verify verifies a bundle
101-func (bm *BundleManager) Verify(ctx context.Context, bundleNumber int) (*VerificationResult, error) {
102- return bm.mgr.VerifyBundle(ctx, bundleNumber)
103-}
104-105-// VerifyChain verifies the entire chain
106-func (bm *BundleManager) VerifyChain(ctx context.Context) (*ChainVerificationResult, error) {
107- return bm.mgr.VerifyChain(ctx)
108-}
109-110-// GetIndex returns the index
111-func (bm *BundleManager) GetIndex() *Index {
112- return bm.mgr.GetIndex()
113-}
114-115-// GetInfo returns manager info
116-func (bm *BundleManager) GetInfo() map[string]interface{} {
117- return bm.mgr.GetInfo()
118-}
119-120-// Export exports operations from bundles
121-func (bm *BundleManager) Export(ctx context.Context, afterTime time.Time, count int) ([]PLCOperation, error) {
122- return bm.mgr.ExportOperations(ctx, afterTime, count)
123-}
124-125-// Scan scans the directory and rebuilds the index
126-func (bm *BundleManager) Scan() (*DirectoryScanResult, error) {
127- return bm.mgr.ScanDirectory()
128-}
129-130-// ScanBundle scans a single bundle file
131-func (bm *BundleManager) ScanBundle(path string, bundleNumber int) (*BundleMetadata, error) {
132- return bm.mgr.ScanBundle(path, bundleNumber)
133-}
134-135-// IsBundleIndexed checks if a bundle is in the index
136-func (bm *BundleManager) IsBundleIndexed(bundleNumber int) bool {
137- return bm.mgr.IsBundleIndexed(bundleNumber)
138-}
139-140-// StreamRaw streams raw compressed bundle data
141-func (bm *BundleManager) StreamRaw(ctx context.Context, bundleNumber int) (io.ReadCloser, error) {
142- return bm.mgr.StreamBundleRaw(ctx, bundleNumber)
143-}
144-145-// StreamDecompressed streams decompressed bundle data
146-func (bm *BundleManager) StreamDecompressed(ctx context.Context, bundleNumber int) (io.ReadCloser, error) {
147- return bm.mgr.StreamBundleDecompressed(ctx, bundleNumber)
148-}