पेश है Pulsora: ट्रेडिंग डेटा के लिए Rust में बनी टाइम-सीरीज़ डेटाबेस
हमारे पास डेटा की एक बौछार थी और उसे रखने के लिए कोई अच्छी जगह नहीं थी।
आंतरिक समस्या बहुत स्पष्ट थी: मार्केट डेटा की एक बड़ी, सतत धारा — कई सिम्बल के टिक्स, कोट्स, बार्स — को इतनी तेज़ी से संग्रहित और क्वेरी करना कि इन्जेशन कभी बाधा न बने और टाइम-रेंज क्वेरीज़ बिना सांस रोके लौट आएं। मार्केट डेटा एक टाइम-सीरीज़ वर्कलोड का सबसे साफ़ और सबसे कठोर रूप है। यह क्रम में आता है, यह लगातार आता है, और यह कभी नहीं रुकता। आयतन इसलिए दिलचस्प नहीं है कि यह एक बार बड़ा है। यह इसलिए दिलचस्प है कि यह हर सेकंड बड़ा है, हमेशा के लिए।
पहले हमने स्पष्ट चीज़ आज़माई: एक मौजूदा टाइम-सीरीज़ डेटाबेस का सहारा लेना। यह आमतौर पर सही फ़ैसला होता है, और कुछ समय तक ठीक रहा। फिर राइट थ्रूपुट की ज़रूरतें बढ़ने लगीं, डिस्क पर फ़ुटप्रिंट उससे भी तेज़ी से बढ़ा, और जो टाइम-रेंज क्वेरीज़ हम वास्तव में चलाते थे उन्हें इंडेक्स का बोझ महसूस होने लगा। किसी मोड़ पर स्टोरेज परत प्रति पंक्ति, पंक्ति पर असली काम से ज़्यादा हिसाब-किताब कर रही थी।
तो हमने Pulsora बनाया — Rust में एक टाइम-सीरीज़ डेटाबेस, जो मार्केट डेटा और समय-क्रमित डेटासेट के लिए अनुकूलित है। यह Apache-2.0 के तहत GitHub पर ओपन सोर्स है। यह पोस्ट इसका परिचय है — यह क्या है, क्यों मौजूद है, और यह वास्तव में डेटा कैसे संग्रहित और क्वेरी करता है — कोड पर आधारित, ब्रोशर पर नहीं।
अपनाने के बजाय बनाना क्यों
डेटाबेस बनाना वह काम नहीं है जो लापरवाही से किया जाए। जो स्तर पार करना था वह यह था: हमें विशेष रूप से क्या चाहिए जो हमें पर्याप्त सस्ते में नहीं मिल रहा था?
तीन चीज़ें बार-बार सामने आती रहीं।
राइट थ्रूपुट जो पंक्तियों के साथ नहीं, ब्लॉक के साथ स्केल करे। एक टिक स्ट्रीम लाखों छोटी, लगभग समान पंक्तियाँ होती हैं। ज़्यादातर सामान्य-उद्देश्यीय स्टोर हर एक को इंडेक्स करना चाहते हैं। यानी प्रति पंक्ति एक इंडेक्स एंट्री, और हमारी पंक्ति संख्याओं पर इंडेक्स ही प्रमुख लागत बन जाता है — राइट एम्प्लिफिकेशन में, डिस्क में, कम्पैक्शन में। हम चाहते थे कि स्टोरेज की प्रति-पंक्ति लागत शून्य की ओर पहुँचे और इंडेक्स उन ब्लॉक की संख्या के साथ स्केल करे जो हम लिखते हैं, न कि पंक्तियों की संख्या के साथ।
कम्प्रेशन जो डेटा को समझे। मार्केट डेटा शानदार ढंग से कम्प्रेस होता है अगर आप उसे एक ज्ञात प्रकार की कॉलमों के रूप में देखें। टाइमस्टैम्प लगभग नियमित अंतराल पर आते हैं। कीमतें धीरे बदलती हैं। वॉल्यूम छोटे पूर्णांक होते हैं। सामान्य पंक्ति-आधारित कम्प्रेशन उसमें से अधिकांश छोड़ देता है। हम टाइप-जागरूक कॉलमनार कम्प्रेशन चाहते थे — उसी तरह का जो Facebook के Gorilla पेपर ने बताया था — अंतर्निहित, बाद में जोड़ा हुआ नहीं।
एक उबाऊ, फ़ॉर्मैट-लचीला इंटरफ़ेस। हम कुछ अलग-अलग उत्पादकों से इन्जेस्ट करते हैं और कुछ अलग-अलग टूल्स में उपभोग करते हैं। हमें कोई कस्टम वायर प्रोटोकॉल नहीं चाहिए था। हम चाहते थे कि एक स्क्रिप्ट से CSV को POST करें, एक पाइपलाइन से Apache Arrow स्ट्रीम करें, और इस पर निर्भर कि कौन पूछ रहा है — JSON, Arrow या CSV में वापस पढ़ें। HTTP और सुप्रसिद्ध कॉलमनार फ़ॉर्मैट, कुछ भी विदेशी नहीं।
इनमें से कोई भी अकेले नई नहीं है। यह संयोजन — हमारे वर्कलोड के लिए, हमारी फ़ुटप्रिंट सीमाओं के साथ — इसे लिखने को सही ठहराने के लिए पर्याप्त था। और जब आप इसे वैसे भी बना ही रहे हैं, तो हॉट पाथ पर कम्पाइल-टाइम शुद्धता पाने के लिए इसे Rust में बनाना आसान हिस्सा है। हम पहले लिख चुके हैं कि हम अपने खुद के टूल क्यों बनाते हैं; Pulsora ठीक उसी वंश में है।
Pulsora आज क्या है
दायरे के बारे में मैं ईमानदार रहूँगा: Pulsora 0.1.0 है। यह एक इंजन है जो अपना काम अच्छे से करता है, न कि क्वेरी प्लानर वाला कोई क्लस्टर्ड, रेप्लिकेटेड, मल्टी-टेनेंट प्लेटफ़ॉर्म। यह REST API के साथ एक सिंगल-नोड, एम्बेडेड-RocksDB, कॉलमनार टाइम-सीरीज़ स्टोर है। बस इतना ही, और बस इतना ही असली बात है।
इसका स्वरूप यह है:
- स्टोरेज बैकएंड: RocksDB, ऊपर एक कस्टम कॉलमनार परत के साथ।
- इन्जेशन फ़ॉर्मैट: CSV, Apache Arrow IPC स्ट्रीम, और Protocol Buffers।
- क्वेरी आउटपुट फ़ॉर्मैट: JSON (डिफ़ॉल्ट), Arrow IPC स्ट्रीम, Protobuf, और CSV —
Acceptहेडर द्वारा तय। - स्कीमा: पहले इन्जेस्ट पर स्वचालित रूप से अनुमानित। आप टेबल घोषित नहीं करते; आप डेटा POST करते हैं और Pulsora कॉलम और प्रकार का पता लगा लेता है।
- इंटरफ़ेस: Tokio पर Axum द्वारा परोसा गया एक HTTP/REST API।
- टिकाऊपन: एक वैकल्पिक राइट-अहेड लॉग ताकि बफ़र की गई पंक्तियाँ क्रैश से बच जाएँ।
पूरी चीज़ pulsora नाम का एक एकल बाइनरी है। एक कमांड-लाइन फ़्लैग है।
# डिफ़ॉल्ट के साथ चलाएँ
cargo run
# या इसे एक कॉन्फ़िग फ़ाइल की ओर इंगित करें
cargo run -- -c pulsora.toml
वह -c/--config फ़्लैग ही पूरी कमांड-लाइन सतह है। बाक़ी सब TOML में रहता है। यह जानबूझकर है — कॉन्फ़िगरेशन इसे चलाने का API है, और रनटाइम API HTTP है।
यह डिस्क पर डेटा कैसे संग्रहित करता है
यही वह हिस्सा है जिसकी हमें सबसे ज़्यादा परवाह थी, इसलिए यही हिस्सा समझाने लायक है।
कॉलमनार ब्लॉक, पंक्तियाँ नहीं
पंक्तियाँ आती हैं, पर वे पंक्तियों के रूप में संग्रहित नहीं होतीं। Pulsora आने वाली पंक्तियों को एक इन-मेमोरी बफ़र में जमा करता है, और जब बफ़र अपनी आकार सीमा (buffer_size, डिफ़ॉल्ट 1000) या समय सीमा (flush_interval_ms) तक पहुँचता है, तो उन्हें एक ColumnBlock में समूहित कर देता है — पंक्तियों का एक खंड जो कॉलम-दर-कॉलम संग्रहित होता है, हर कॉलम अपने प्रकार के लिए चुने गए एल्गोरिथ्म से स्वतंत्र रूप से कम्प्रेस होता है।
ब्लॉक स्टोरेज की इकाई है। एक ColumnBlock मोटे तौर पर ऐसा है:
pub struct ColumnBlock {
pub row_count: usize, // इस ब्लॉक में पंक्तियाँ
pub columns: HashMap<String, Vec<u8>>, // कम्प्रेस्ड कॉलम डेटा
pub null_bitmaps: HashMap<String, Vec<u8>>, // प्रति कॉलम null ट्रैकिंग
}
पंक्ति के बजाय कॉलम से संग्रहित करना ही कम्प्रेशन को काम करने लायक बनाता है, क्योंकि एक कॉलम के हर मान का प्रकार समान और परिमाण मिलता-जुलता होता है। यही पूरी वजह है कि कॉलमनार स्टोर मौजूद हैं, और टाइम-सीरीज़ डेटा वही वर्कलोड है जिसके लिए इन्हें ईजाद किया गया था।
टाइप-विशिष्ट कम्प्रेशन
हर कॉलम प्रकार को उसके आकार के लिए बनी एक कम्प्रेशन रणनीति मिलती है। रिपॉज़िटरी के दस्तावेज़ों के अनुसार:
| डेटा प्रकार | कम्प्रेशन | सामान्य अनुपात (रिपो के आँकड़े) | यह क्यों काम करता है |
|---|---|---|---|
| Timestamp | Delta-of-delta + varint | 5–10x | लगभग नियमित अंतराल |
| Float | XOR (Gorilla) + varfloat | 2–5x | धीरे बदलते मान |
| Integer | Delta + varint | 3–8x | अनुक्रमिक / काउंटर डेटा |
| String | डिक्शनरी एन्कोडिंग | 2–4x | दोहराए गए मान (सिम्बल) |
| Boolean | रन-लेंथ एन्कोडिंग (RLE) | 10–50x | विरल डेटा |
टाइमस्टैम्प का रास्ता पाठ्यपुस्तक वाला मामला है। टिक्स लगभग स्थिर अंतराल पर आते हैं, इसलिए दूसरे क्रम का अंतर (delta-of-delta) आमतौर पर शून्य या बहुत छोटा होता है, और एक varint एक छोटी संख्या को एक बाइट में एन्कोड कर देता है। Float, Gorilla एल्गोरिथ्म से XOR-पिछले-मान-के-विरुद्ध बिट पैकिंग का उपयोग करते हैं — जब कोई कीमत मुश्किल से हिलती है, तो ज़्यादातर XOR किए गए बिट शून्य होते हैं और कुछ नहीं में पैक हो जाते हैं। टिकर सिम्बल जैसी स्ट्रिंग अनंत बार दोहराई जाती हैं, इसलिए एक डिक्शनरी हर अलग सिम्बल को एक बार एक छोटे पूर्णांक से मैप कर देती है।
इन सबके नीचे, RocksDB एक दूसरी परत का सामान्य ब्लॉक कम्प्रेशन लगाता है — डिफ़ॉल्ट रूप से lz4, और none, snappy, zstd उपलब्ध हैं। तो आपको पहले टाइप-जागरूक एन्कोडिंग मिलती है, फिर ऊपर एक सामान्य-उद्देश्यीय पास।
ये अनुपात रिपॉज़िटरी के अपने बेंचमार्क दावे हैं, जो benches/ के तहत Criterion सूट्स से मापे गए हैं। मैं इन्हें परियोजना के आँकड़ों के रूप में बता रहा हूँ, किसी स्वतंत्र फ़ैसले के रूप में नहीं — अपने ख़ुद के डेटा पर cargo bench चलाएँ और उसी पर भरोसा करें।
वह इंडेक्स जो प्रति पंक्ति नहीं बढ़ता
यही वह डिज़ाइन निर्णय है जिस पर सब कुछ टिका है।
ज़्यादातर टाइम-सीरीज़ स्टोर प्रति पंक्ति एक इंडेक्स रखते हैं ताकि किसी भी एकल पंक्ति को समय के अनुसार ढूँढ सकें। Pulsora नहीं रखता। यह केवल ब्लॉक-स्तरीय इंडेक्स रखता है — हर ब्लॉक के बारे में मेटाडेटा, कभी अलग-अलग पंक्तियों के बारे में नहीं। आर्किटेक्चर दस्तावेज़ों के अनुसार डिस्क पर कुंजी का लेआउट ऐसा दिखता है:
Block index: [table_hash:u32]['B'][min_ts:i64][block_id:u64]
value: [block_id][min_ts][max_ts][rows][min_id][max_id] — प्रति ब्लॉक एक एंट्री
Block data: [table_hash:u32]['D'][block_id:u64] — कम्प्रेस्ड कॉलमनार ब्लॉक
Overrides: [table_hash:u32]['O'][block_id:u64] — मृत पंक्तियों की स्थितियाँ
table_hash टेबल नाम का 4-बाइट FNV-1a हैश है, जो कुंजी प्रीफ़िक्स के रूप में उपयोग होता है ताकि हर कुंजी में स्ट्रिंग नाम घसीटे बिना टेबल अलग-थलग रहें। प्रीफ़िक्स के बाद एक बाइट ('B', 'D', 'O') इंडेक्स, डेटा और ओवरराइड सेट को अलग करता है।
परिणाम: स्टोरेज लागत ब्लॉक की संख्या के साथ स्केल करती है, पंक्तियों की संख्या के साथ नहीं। एक अरब पंक्तियाँ लिखें और आप उन ब्लॉकों के अलावा कोई इंडेक्स एंट्री नहीं जोड़ते जो उन्हें रखते हैं। यही वह ज़रूरत थी जिसे हम सस्ते में नहीं ख़रीद सकते थे, और यही Pulsora के अस्तित्व का मूल है।
पंक्ति अपडेट ब्लॉक को दोबारा लिखे बिना संभाले जाते हैं। एक REPLACE प्रतिस्थापित प्रति की स्थिति को पुराने ब्लॉक के ओवरराइड सेट में चिह्नित करता है — प्रति ब्लॉक मृत पंक्ति स्थितियों का एक सेट, जो नए डेटा के समान write batch में RocksDB merge ऑपरेटर (सेट यूनियन) के माध्यम से विलीन होता है। जीवंतता प्रति ब्लॉक एक पॉइंट रीड है, प्रति पंक्ति लुकअप नहीं।
राइट पाथ
इन्जेशन एक POST है। स्कीमा पहले वाले पर अनुमानित होती है।
curl -X POST http://localhost:8080/tables/stocks/ingest \
-H "Content-Type: text/csv" \
--data-binary @ticks.csv
उस CSV की हेडर पंक्ति स्कीमा बन जाती है। Pulsora मानों का सैंपल लेता है, हर कॉलम का प्रकार (Integer, Float, String, Boolean, Timestamp) पहचानता है, और टाइमस्टैम्प कॉलम को स्वचालित रूप से पहचानता है — यह RFC 3339, YYYY-MM-DD HH:MM:SS, केवल-तारीख़, और सेकंड व मिलीसेकंड दोनों में Unix टाइमस्टैम्प समझता है। उसके बाद, उस टेबल की स्कीमा निश्चित हो जाती है और आने वाला डेटा उसके विरुद्ध मान्य किया जाता है।
ज़्यादा थ्रूपुट वाली पाइपलाइनों के लिए, CSV पार्सिंग को पूरी तरह छोड़ दें और Apache Arrow स्ट्रीम करें:
curl -X POST http://localhost:8080/tables/stocks/ingest \
-H "Content-Type: application/vnd.apache.arrow.stream" \
--data-binary @ticks.arrow
Arrow अपनी स्कीमा पहले से संलग्न लेकर आता है, इसलिए कोई अनुमान चरण और कोई टेक्स्ट पार्सिंग नहीं — कॉलम पहले से ही कॉलम हैं। जो उत्पादक इसे पहले से बोलते हैं उनके लिए Protobuf इन्जेशन वैसे ही काम करता है।
अंदरूनी तौर पर, प्रवाह यह है: इनपुट पार्स करें, स्कीमा मान्य करें या अनुमान लगाएँ, पंक्तियों को मेमोरी में बफ़र करें (आते समय डीडुप्लिकेट करते हुए, अंतिम-राइट जीतता है), और — यदि WAL सक्षम है — मेमोरी को छूने से पहले हर पंक्ति को एक .wal फ़ाइल में जोड़ें ताकि क्रैश बफ़र किए गए डेटा को न खोए। जब बफ़र फ़्लश होता है, पंक्तियाँ एक कम्प्रेस्ड ColumnBlock बन जाती हैं, ब्लॉक और उसकी इंडेक्स एंट्री एक ही batch में RocksDB में लिखी जाती हैं, और WAL छोटा (truncate) कर दिया जाता है।
रीड पाथ
क्वेरीज़ एक समय-रेंज और पेजिनेशन के साथ GET होती हैं।
# सब कुछ (डिफ़ॉल्ट सीमा द्वारा सीमित)
curl "http://localhost:8080/tables/stocks/query"
# एक समय खिड़की
curl "http://localhost:8080/tables/stocks/query?start=2024-01-01T09:30:00&end=2024-01-01T16:00:00"
# पेजिनेटेड
curl "http://localhost:8080/tables/stocks/query?limit=1000&offset=0"
start और end इन्जेशन के समान लचीले टाइमस्टैम्प फ़ॉर्मैट का सेट स्वीकार करते हैं। limit डिफ़ॉल्ट 1000 है, कोई अधिकतम सीमा लागू नहीं होती; offset पेजिनेशन के लिए पंक्तियाँ छोड़ता है।
क्वेरी इंजन अनुरोध को पहले ब्लॉक इंडेक्स के विरुद्ध हल करता है: यह समय सीमाओं से एक बाइनरी कुंजी रेंज बनाता है और केवल उन्हीं ब्लॉकों पर इटरेट करता है जिनका [min_ts, max_ts] उस खिड़की से ओवरलैप करता है। यह उम्मीदवार ब्लॉक इकट्ठा करता है, हर अद्वितीय ब्लॉक को एक बार लाता है, उसे डीकम्प्रेस करता है, डीकम्प्रेस्ड परिणाम को कैश करता है ताकि एक क्वेरी के भीतर बार-बार पहुँच दोबारा डीकम्प्रेस न करे, मृत पंक्तियों को छोड़ने के लिए ओवरराइड सेट लागू करता है, अपनी ज़रूरत की पंक्तियाँ निकालता है, और उन्हें उस फ़ॉर्मैट में सीरियलाइज़ करता है जो Accept हेडर ने माँगा।
परिणाम JSON के बजाय किसी आगे की पाइपलाइन के लिए Arrow के रूप में वापस चाहिए? एक हेडर बदलें:
curl "http://localhost:8080/tables/stocks/query?limit=10000" \
-H "Accept: application/vnd.apache.arrow.stream" > out.arrow
curl "http://localhost:8080/tables/stocks/query?limit=10000" \
-H "Accept: text/csv" > out.csv
वही क्वेरी, चार संभावित आउटपुट फ़ॉर्मैट, आपकी ओर से कोई कन्वर्ज़न चरण नहीं। कुछ और रीड एंडपॉइंट हैं — टेबल सूचीबद्ध करने के लिए GET /tables, अनुमानित स्कीमा के लिए GET /tables/{name}/schema, पंक्ति गिनती के लिए GET /tables/{name}/count, id से एकल पंक्ति लाने के लिए GET /tables/{name}/row/{id}, और GET /health।
इसे आज़माएँ
Pulsora एक वर्तमान Rust टूलचेन से बिल्ड होता है और एक एकल बाइनरी के रूप में चलता है।
# सोर्स से क्लोन और रन करें
git clone https://github.com/muvon/pulsora.git
cd pulsora
cargo run
# या बाइनरी को सीधे इंस्टॉल करें
cargo install --git https://github.com/muvon/pulsora.git
# एक CSV इन्जेस्ट करें
curl -X POST http://localhost:8080/tables/stocks/ingest \
-H "Content-Type: text/csv" \
-d "timestamp,symbol,price,volume
2024-01-01 09:30:00,AAPL,150.00,1000
2024-01-01 09:31:00,AAPL,150.25,1500
2024-01-01 09:32:00,AAPL,149.75,2000"
# उसे वापस पढ़ें
curl "http://localhost:8080/tables/stocks/query?limit=10"
# अनुमानित स्कीमा देखें
curl "http://localhost:8080/tables/stocks/schema"
ट्यूनिंग pulsora.toml में रहती है। सबसे पहले जानने लायक सेटिंग्स:
[storage]
data_dir = "./data"
buffer_size = 1000 # ब्लॉक लिखे जाने से पहले बफ़र की गई पंक्तियाँ
flush_interval_ms = 1000 # बफ़र में एक पंक्ति का अधिकतम प्रतीक्षा समय
wal_enabled = true # बफ़र की गई पंक्तियाँ क्रैश से बच जाती हैं
[ingestion]
batch_size = 10000 # प्रति डेटाबेस राइट batch पंक्तियाँ
[performance]
compression = "lz4" # none | snappy | lz4 | zstd
cache_size_mb = 256 # पढ़ने के लिए ब्लॉक कैश
यदि आपको लेटेंसी के मुक़ाबले अधिकतम कम्प्रेशन की परवाह है, तो flush_interval_ms = 0 सेट करें — एक बैच-ओनली मोड जो बफ़र भरने तक पंक्तियाँ रोकता है, जिससे बड़े और बेहतर कम्प्रेस्ड ब्लॉक बनते हैं। यदि आपको हॉट डेटा पर रीड लेटेंसी की परवाह है, तो cache_size_mb बढ़ाएँ। पूरे नॉब्स का सेट doc/CONFIGURATION.md में प्रलेखित है।
यह क्या नहीं है, और आगे क्या
मैं आपको इन्हें कठिन तरीक़े से खोजने की निराशा से बचाऊँगा। Pulsora आज सिंगल-नोड है। कोई क्लस्टरिंग नहीं, कोई रेप्लिकेशन नहीं, कोई SQL नहीं, कोई सामान्य क्वेरी प्लानर नहीं, समय-रेंज से परे कोई प्रेडिकेट पुशडाउन नहीं, कोई रेट लिमिटिंग नहीं। क्वेरीज़ समय से फ़िल्टर करती हैं और पेजिनेट करती हैं; वे GROUP BY नहीं करतीं। doc/ARCHITECTURE.md फ़ाइल उन दिशाओं को सूचीबद्ध करती है जिन पर हम देख रहे हैं — कॉलम प्रूनिंग और प्रेडिकेट पुशडाउन, एक डिस्क-बैक्ड ब्लॉक कैश, टियर्ड हॉट/वार्म/कोल्ड स्टोरेज, और क्लस्टरिंग — पर वे रोडमैप हैं, हक़ीक़त नहीं। मैं चाहूँगा कि आप किनारों को जानें, उनसे टकराएँ नहीं।
यह आज जो है, वह एक केंद्रित इंजन है जो समय-क्रमित डेटा की बौछार को इन्जेस्ट करता है, उसे ऐसे एल्गोरिथ्म से कसकर कम्प्रेस करता है जो उसे समझते हैं, और बिना ऐसे इंडेक्स के समय-रेंज क्वेरीज़ परोसता है जो प्रति पंक्ति फूलता हो। यही वह समस्या थी जो हमारे पास थी। यही वह समस्या है जिसे Pulsora हल करता है।
ओपन सोर्स
Pulsora Apache-2.0 के तहत GitHub पर है। यह Rust है — स्टोरेज के लिए RocksDB, API के लिए Axum और Tokio, कॉलमनार और protobuf फ़ॉर्मैट के लिए Arrow और Prost — और एक सादे cargo build से बिल्ड होता है। बेंचमार्क सूट benches/ में हैं, अगर आप हमारी मानने के बजाय अपने ख़ुद के डेटा पर इन्जेशन और क्वेरी प्रदर्शन मापना चाहें।
यदि आप समय-क्रमित पंक्तियों में डूब रहे हैं — मार्केट टिक्स, सेंसर रीडिंग, कुछ भी जो क्रम में आता है और कभी नहीं रुकता — इसे क्लोन करें, इस पर एक स्ट्रीम इंगित करें, और हमें बताएँ कि यह कहाँ टूटता है। यही इसे बेहतर बनाने का सबसे तेज़ रास्ता है।
— Don
Pulsora Apache-2.0 लाइसेंस के तहत GitHub पर github.com/muvon/pulsora पर ओपन सोर्स है। कोई बग मिला या कोई फ़ीचर चाहिए? एक issue खोलें.



