• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

realm / realm-core / 2545

06 Aug 2024 04:05PM UTC coverage: 91.089% (-0.02%) from 91.108%
2545

push

Evergreen

web-flow
RCORE-2223 reduce unnecessary table selections in replication (#7899)

* reduce unnecessary table selections in replication

fix collection notifications after embedded object creations

cleanup state, and avoid excessive collection selections

fix trace logging

* optimize

* changelog

102750 of 181570 branches covered (56.59%)

304 of 359 new or added lines in 3 files covered. (84.68%)

65 existing lines in 19 files now uncovered.

216969 of 238195 relevant lines covered (91.09%)

5832026.01 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

80.55
/src/realm/replication.cpp
1
/*************************************************************************
2
 *
3
 * Copyright 2016 Realm Inc.
4
 *
5
 * Licensed under the Apache License, Version 2.0 (the "License");
6
 * you may not use this file except in compliance with the License.
7
 * You may obtain a copy of the License at
8
 *
9
 * http://www.apache.org/licenses/LICENSE-2.0
10
 *
11
 * Unless required by applicable law or agreed to in writing, software
12
 * distributed under the License is distributed on an "AS IS" BASIS,
13
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
 * See the License for the specific language governing permissions and
15
 * limitations under the License.
16
 *
17
 **************************************************************************/
18

19
#include <realm/replication.hpp>
20

21
#include <realm/list.hpp>
22
#include <realm/path.hpp>
23
#include <iostream>
24

25
using namespace realm;
26
using namespace realm::util;
27
using LogLevel = util::Logger::Level;
28

29
const char* Replication::history_type_name(int type)
30
{
18✔
31
    switch (type) {
18✔
32
        case hist_None:
✔
33
            return "None";
×
34
        case hist_OutOfRealm:
✔
35
            return "Local out of Realm";
×
36
        case hist_InRealm:
12✔
37
            return "Local in-Realm";
12✔
38
        case hist_SyncClient:
6✔
39
            return "SyncClient";
6✔
40
        case hist_SyncServer:
✔
41
            return "SyncServer";
×
42
        default:
✔
43
            return "Unknown";
×
44
    }
18✔
45
}
18✔
46

47
void Replication::initialize(DB&)
48
{
72,000✔
49
    // Nothing needs to be done here
50
}
72,000✔
51

52
void Replication::do_initiate_transact(Group&, version_type, bool)
53
{
617,193✔
54
    char* data = m_stream.get_data();
617,193✔
55
    size_t size = m_stream.get_size();
617,193✔
56
    m_encoder.set_buffer(data, data + size);
617,193✔
57
    m_most_recently_created_object.clear();
617,193✔
58
}
617,193✔
59

60
Replication::version_type Replication::prepare_commit(version_type orig_version)
61
{
591,249✔
62
    char* data = m_stream.get_data();
591,249✔
63
    size_t size = m_encoder.write_position() - data;
591,249✔
64
    version_type new_version = prepare_changeset(data, size, orig_version); // Throws
591,249✔
65
    return new_version;
591,249✔
66
}
591,249✔
67

68
void Replication::add_class(TableKey table_key, StringData name, Table::Type type)
69
{
177,513✔
70
    if (auto logger = would_log(LogLevel::debug)) {
177,513✔
71
        if (type == Table::Type::Embedded) {
39,012✔
72
            logger->log(LogCategory::object, LogLevel::debug, "Add %1 class '%2'", type, name);
3,306✔
73
        }
3,306✔
74
        else {
35,706✔
75
            logger->log(LogCategory::object, LogLevel::debug, "Add class '%1'", name);
35,706✔
76
        }
35,706✔
77
    }
39,012✔
78
    unselect_all();
177,513✔
79
    m_encoder.insert_group_level_table(table_key); // Throws
177,513✔
80
}
177,513✔
81

82
void Replication::add_class_with_primary_key(TableKey tk, StringData name, DataType pk_type, StringData pk_name, bool,
83
                                             Table::Type table_type)
84
{
86,892✔
85
    if (auto logger = would_log(LogLevel::debug)) {
86,892✔
86
        logger->log(LogCategory::object, LogLevel::debug, "Add %1 class '%2' with primary key property '%3' of %4",
52,092✔
87
                    table_type, Group::table_name_to_class_name(name), pk_name, pk_type);
52,092✔
88
    }
52,092✔
89
    REALM_ASSERT(table_type != Table::Type::Embedded);
86,892✔
90
    unselect_all();
86,892✔
91
    m_encoder.insert_group_level_table(tk); // Throws
86,892✔
92
}
86,892✔
93

94
void Replication::erase_class(TableKey tk, StringData table_name, size_t)
95
{
1,398✔
96
    if (auto logger = would_log(LogLevel::debug)) {
1,398✔
97
        logger->log(LogCategory::object, LogLevel::debug, "Remove class '%1'",
×
98
                    Group::table_name_to_class_name(table_name));
×
99
    }
×
100
    unselect_all();
1,398✔
101
    m_encoder.erase_class(tk); // Throws
1,398✔
102
}
1,398✔
103

104
void Replication::insert_column(const Table* t, ColKey col_key, DataType type, StringData col_name,
105
                                Table* target_table)
106
{
575,253✔
107
    if (auto logger = would_log(LogLevel::debug)) {
575,253✔
108
        const char* collection_type = "";
286,863✔
109
        if (col_key.is_collection()) {
286,863✔
110
            if (col_key.is_list()) {
107,874✔
111
                collection_type = "list ";
39,114✔
112
            }
39,114✔
113
            else if (col_key.is_dictionary()) {
68,760✔
114
                collection_type = "dictionary ";
35,298✔
115
            }
35,298✔
116
            else {
33,462✔
117
                collection_type = "set ";
33,462✔
118
            }
33,462✔
119
        }
107,874✔
120
        if (target_table) {
286,863✔
121
            logger->log(LogCategory::object, LogLevel::debug, "On class '%1': Add property '%2' %3linking '%4'",
15,138✔
122
                        t->get_class_name(), col_name, collection_type, target_table->get_class_name());
15,138✔
123
        }
15,138✔
124
        else {
271,725✔
125
            logger->log(LogCategory::object, LogLevel::debug, "On class '%1': Add property '%2' %3of %4",
271,725✔
126
                        t->get_class_name(), col_name, collection_type, type);
271,725✔
127
        }
271,725✔
128
    }
286,863✔
129
    select_table(t);                  // Throws
575,253✔
130
    m_encoder.insert_column(col_key); // Throws
575,253✔
131
}
575,253✔
132

133
void Replication::erase_column(const Table* t, ColKey col_key)
134
{
4,236✔
135
    if (auto logger = would_log(LogLevel::debug)) {
4,236✔
136
        logger->log(LogCategory::object, LogLevel::debug, "On class '%1': Remove property '%2'", t->get_class_name(),
60✔
137
                    t->get_column_name(col_key));
60✔
138
    }
60✔
139
    select_table(t);                 // Throws
4,236✔
140
    m_encoder.erase_column(col_key); // Throws
4,236✔
141
}
4,236✔
142

143
void Replication::track_new_object(const Table* table, ObjKey key)
144
{
4,694,859✔
145
    if (table == m_selected_table) {
4,694,859✔
146
        m_selected_obj = key;
4,651,491✔
147
        m_selected_obj_is_newly_created = true;
4,651,491✔
148
    }
4,651,491✔
149

150
    auto table_index = table->get_index_in_group();
4,694,859✔
151
    if (table_index >= m_most_recently_created_object.size()) {
4,694,859✔
152
        if (table_index >= m_most_recently_created_object.capacity())
270,414✔
153
            m_most_recently_created_object.reserve(table_index * 2);
140,595✔
154
        m_most_recently_created_object.resize(table_index + 1);
270,414✔
155
    }
270,414✔
156
    m_most_recently_created_object[table_index] = key;
4,694,859✔
157
}
4,694,859✔
158

159
void Replication::create_object(const Table* t, GlobalKey id)
160
{
4,310,508✔
161
    if (auto logger = would_log(LogLevel::debug)) {
4,310,508✔
162
        logger->log(LogCategory::object, LogLevel::debug, "Create object '%1'", t->get_class_name());
36,966✔
163
    }
36,966✔
164
    select_table(t);                              // Throws
4,310,508✔
165
    m_encoder.create_object(id.get_local_key(0)); // Throws
4,310,508✔
166
    track_new_object(t, id.get_local_key(0));     // Throws
4,310,508✔
167
}
4,310,508✔
168

169
void Replication::create_object_with_primary_key(const Table* t, ObjKey key, Mixed pk)
170
{
337,203✔
171
    if (auto logger = would_log(LogLevel::debug)) {
337,203✔
172
        logger->log(LogCategory::object, LogLevel::debug, "Create object '%1' with primary key %2",
96,969✔
173
                    t->get_class_name(), pk);
96,969✔
174
    }
96,969✔
175
    select_table(t);              // Throws
337,203✔
176
    m_encoder.create_object(key); // Throws
337,203✔
177
    track_new_object(t, key);
337,203✔
178
}
337,203✔
179

180
void Replication::create_linked_object(const Table* t, ObjKey key)
181
{
46,608✔
182
    track_new_object(t, key); // Throws
46,608✔
183
    // Does not need to encode anything as embedded tables can't be observed
184
}
46,608✔
185

186
void Replication::remove_object(const Table* t, ObjKey key)
187
{
4,938,186✔
188
    if (auto logger = would_log(LogLevel::debug)) {
4,938,186✔
189
        if (t->is_embedded()) {
7,512✔
190
            logger->log(LogCategory::object, LogLevel::debug, "Remove embedded object '%1'", t->get_class_name());
3,657✔
191
        }
3,657✔
192
        else if (t->get_primary_key_column()) {
3,855✔
193
            logger->log(LogCategory::object, LogLevel::debug, "Remove object '%1' with primary key %2",
3,672✔
194
                        t->get_class_name(), t->get_primary_key(key));
3,672✔
195
        }
3,672✔
196
        else {
183✔
197
            logger->log(LogCategory::object, LogLevel::debug, "Remove object '%1'[%2]", t->get_class_name(), key);
183✔
198
        }
183✔
199
    }
7,512✔
200
    select_table(t);              // Throws
4,938,186✔
201
    m_encoder.remove_object(key); // Throws
4,938,186✔
202
}
4,938,186✔
203

204
void Replication::do_select_table(const Table* table)
205
{
796,311✔
206
    m_encoder.select_table(table->get_key()); // Throws
796,311✔
207
    m_selected_table = table;
796,311✔
208
    m_selected_collection = CollectionId();
796,311✔
209
    m_selected_obj = ObjKey();
796,311✔
210
    m_selected_obj_is_newly_created = false;
796,311✔
211
}
796,311✔
212

213
bool Replication::check_for_newly_created_object(ObjKey key, const Table* table)
214
{
10,964,856✔
215
    auto table_index = table->get_index_in_group();
10,964,856✔
216
    if (table_index < m_most_recently_created_object.size()) {
10,964,856✔
217
        return m_most_recently_created_object[table_index] == key;
2,244,021✔
218
    }
2,244,021✔
219
    return false;
8,720,835✔
220
}
10,964,856✔
221

222
bool Replication::do_select_obj(ObjKey key, const Table* table)
223
{
9,239,178✔
224
    bool newly_created = check_for_newly_created_object(key, table);
9,239,178✔
225
    if (!newly_created) {
9,239,178✔
226
        select_table(table);
8,574,744✔
227
        m_selected_obj = key;
8,574,744✔
228
        m_selected_obj_is_newly_created = false;
8,574,744✔
229
        m_selected_collection = CollectionId();
8,574,744✔
230
    }
8,574,744✔
231

232
    if (auto logger = would_log(LogLevel::debug)) {
9,239,178✔
233
        auto class_name = table->get_class_name();
179,448✔
234
        if (table->get_primary_key_column()) {
179,448✔
235
            auto pk = table->get_primary_key(key);
61,980✔
236
            logger->log(LogCategory::object, LogLevel::debug, "Mutating object '%1' with primary key %2", class_name,
61,980✔
237
                        pk);
61,980✔
238
        }
61,980✔
239
        else if (table->is_embedded()) {
117,468✔
240
            auto obj = table->get_object(key);
91,755✔
241
            logger->log(LogCategory::object, LogLevel::debug, "Mutating object '%1' with path '%2'", class_name,
91,755✔
242
                        obj.get_id());
91,755✔
243
        }
91,755✔
244
        else {
25,713✔
245
            logger->log(LogCategory::object, LogLevel::debug, "Mutating anonymous object '%1'[%2]", class_name, key);
25,713✔
246
        }
25,713✔
247
    }
179,448✔
248
    return newly_created;
9,239,178✔
249
}
9,239,178✔
250

251
void Replication::do_select_collection(const CollectionBase& coll)
252
{
1,276,431✔
253
    if (select_obj(coll.get_owner_key(), coll.get_table().unchecked_ptr())) {
1,276,431✔
254
        m_encoder.select_collection(coll.get_col_key(), coll.get_owner_key(), coll.get_stable_path()); // Throws
136,905✔
255
        m_selected_collection = CollectionId(coll);
136,905✔
256
    }
136,905✔
257
}
1,276,431✔
258

259
void Replication::do_set(const Table* t, ColKey col_key, ObjKey key, _impl::Instruction variant)
260
{
16,613,760✔
261
    if (variant != _impl::Instruction::instr_SetDefault) {
16,613,760✔
262
        if (select_obj(key, t)) {                  // Throws
16,613,406✔
263
            m_encoder.modify_object(col_key, key); // Throws
11,335,620✔
264
        }
11,335,620✔
265
    }
16,613,406✔
266
}
16,613,760✔
267

268
void Replication::set(const Table* t, ColKey col_key, ObjKey key, Mixed value, _impl::Instruction variant)
269
{
16,604,226✔
270
    do_set(t, col_key, key, variant); // Throws
16,604,226✔
271
    if (auto logger = would_log(LogLevel::trace)) {
16,604,226✔
272
        if (col_key.get_type() == col_type_Link && value.is_type(type_Link)) {
30!
273
            auto target_table = t->get_opposite_table(col_key);
×
274
            if (target_table->is_embedded()) {
×
275
                logger->log(LogCategory::object, LogLevel::trace, "   Creating embedded object '%1' in '%2'",
×
276
                            target_table->get_class_name(), t->get_column_name(col_key));
×
277
            }
×
278
            else if (target_table->get_primary_key_column()) {
×
279
                auto link = value.get<ObjKey>();
×
280
                auto pk = target_table->get_primary_key(link);
×
281
                logger->log(LogCategory::object, LogLevel::trace,
×
282
                            "   Linking object '%1' with primary key %2 from '%3'", target_table->get_class_name(),
×
283
                            pk, t->get_column_name(col_key));
×
284
            }
×
285
            else {
×
286
                logger->log(LogCategory::object, LogLevel::trace, "   Linking object '%1'[%2] from '%3'",
×
287
                            target_table->get_class_name(), key, t->get_column_name(col_key));
×
288
            }
×
289
        }
×
290
        else {
30✔
291
            logger->log(LogCategory::object, LogLevel::trace, "   Set '%1' to %2", t->get_column_name(col_key),
30✔
292
                        value.to_string(util::Logger::max_width_of_value));
30✔
293
        }
30✔
294
    }
30✔
295
}
16,604,226✔
296

297
void Replication::nullify_link(const Table* t, ColKey col_key, ObjKey key)
298
{
771✔
299
    if (select_obj(key, t)) {                  // Throws
771✔
300
        m_encoder.modify_object(col_key, key); // Throws
753✔
301
    }
753✔
302
    if (auto logger = would_log(LogLevel::trace)) {
771✔
303
        logger->log(LogCategory::object, LogLevel::trace, "   Nullify '%1'", t->get_column_name(col_key));
×
304
    }
×
305
}
771✔
306

307
void Replication::add_int(const Table* t, ColKey col_key, ObjKey key, int_fast64_t value)
308
{
8,460✔
309
    do_set(t, col_key, key); // Throws
8,460✔
310
    if (auto logger = get_logger()) {
8,460✔
311
        logger->log(LogCategory::object, LogLevel::trace, "   Adding %1 to '%2'", value, t->get_column_name(col_key));
96✔
312
    }
96✔
313
}
8,460✔
314

315
Path Replication::get_prop_name(ConstTableRef table, Path&& path) const
316
{
×
317
    auto col_key = path[0].get_col_key();
×
NEW
318
    auto prop_name = table->get_column_name(col_key);
×
319
    path[0] = PathElement(prop_name);
×
320
    return std::move(path);
×
321
}
×
322

323
void Replication::log_collection_operation(const char* operation, const CollectionBase& collection, Mixed value,
324
                                           Mixed index) const
325
{
1,485,465✔
326
    auto logger = would_log(LogLevel::trace);
1,485,465✔
327
    if (REALM_LIKELY(!logger))
1,485,465✔
328
        return;
1,485,456✔
329

330
    auto path = collection.get_short_path();
9✔
331
    auto col_key = path[0].get_col_key();
9✔
332
    ConstTableRef table = collection.get_table();
9✔
333
    auto prop_name = table->get_column_name(col_key);
9✔
334
    path[0] = PathElement(prop_name);
9✔
335
    std::string position;
9✔
336
    if (!index.is_null()) {
12✔
337
        position = util::format(" at position %1", index);
12✔
338
    }
12✔
339
    if (Table::is_link_type(col_key.get_type()) && value.is_type(type_Link)) {
9!
NEW
340
        auto target_table = table->get_opposite_table(col_key);
×
341
        if (target_table->is_embedded()) {
×
342
            logger->log(LogCategory::object, LogLevel::trace, "   %1 embedded object '%2' in %3%4 ", operation,
×
343
                        target_table->get_class_name(), path, position);
×
344
        }
×
345
        else if (target_table->get_primary_key_column()) {
×
346
            auto link = value.get<ObjKey>();
×
347
            auto pk = target_table->get_primary_key(link);
×
348
            logger->log(LogCategory::object, LogLevel::trace, "   %1 object '%2' with primary key %3 in %4%5",
×
349
                        operation, target_table->get_class_name(), pk, path, position);
×
350
        }
×
351
        else {
×
352
            auto link = value.get<ObjKey>();
×
353
            logger->log(LogCategory::object, LogLevel::trace, "   %1 object '%2'[%3] in %4%5", operation,
×
354
                        target_table->get_class_name(), link, path, position);
×
355
        }
×
356
    }
×
357
    else {
9✔
358
        logger->log(LogCategory::object, LogLevel::trace, "   %1 %2 in %3%4", operation,
9✔
359
                    value.to_string(util::Logger::max_width_of_value), path, position);
9✔
360
    }
9✔
361
}
9✔
362

363
void Replication::list_insert(const CollectionBase& list, size_t list_ndx, Mixed value, size_t)
364
{
1,336,026✔
365
    if (select_collection(list)) {                                   // Throws
1,336,026✔
366
        m_encoder.collection_insert(list.translate_index(list_ndx)); // Throws
397,743✔
367
    }
397,743✔
368
    log_collection_operation("Insert", list, value, int64_t(list_ndx));
1,336,026✔
369
}
1,336,026✔
370

371
void Replication::list_set(const CollectionBase& list, size_t list_ndx, Mixed value)
372
{
49,440✔
373
    if (select_collection(list)) {                                // Throws
49,440✔
374
        m_encoder.collection_set(list.translate_index(list_ndx)); // Throws
33,870✔
375
    }
33,870✔
376
    log_collection_operation("Set", list, value, int64_t(list_ndx));
49,440✔
377
}
49,440✔
378

379
void Replication::list_erase(const CollectionBase& list, size_t link_ndx)
380
{
212,451✔
381
    if (select_collection(list)) {                                  // Throws
212,451✔
382
        m_encoder.collection_erase(list.translate_index(link_ndx)); // Throws
76,053✔
383
    }
76,053✔
384
    if (auto logger = would_log(LogLevel::trace)) {
212,451✔
385
        logger->log(LogCategory::object, LogLevel::trace, "   Erase '%1' at position %2",
×
NEW
386
                    get_prop_name(list.get_table(), list.get_short_path()), link_ndx);
×
387
    }
×
388
}
212,451✔
389

390
void Replication::list_move(const CollectionBase& list, size_t from_link_ndx, size_t to_link_ndx)
391
{
1,746✔
392
    if (select_collection(list)) {                                                                         // Throws
1,746✔
393
        m_encoder.collection_move(list.translate_index(from_link_ndx), list.translate_index(to_link_ndx)); // Throws
960✔
394
    }
960✔
395
    if (auto logger = would_log(LogLevel::trace)) {
1,746✔
396
        logger->log(LogCategory::object, LogLevel::trace, "   Move %1 to %2 in '%3'", from_link_ndx, to_link_ndx,
×
NEW
397
                    get_prop_name(list.get_table(), list.get_short_path()));
×
398
    }
×
399
}
1,746✔
400

401
void Replication::set_insert(const CollectionBase& set, size_t set_ndx, Mixed value)
402
{
63,339✔
403
    Replication::list_insert(set, set_ndx, value, 0); // Throws
63,339✔
404
}
63,339✔
405

406
void Replication::set_erase(const CollectionBase& set, size_t set_ndx, Mixed)
407
{
12,522✔
408
    Replication::list_erase(set, set_ndx); // Throws
12,522✔
409
}
12,522✔
410

411
void Replication::set_clear(const CollectionBase& set)
412
{
2,418✔
413
    Replication::list_clear(set); // Throws
2,418✔
414
}
2,418✔
415

416
void Replication::list_clear(const CollectionBase& list)
417
{
9,180✔
418
    if (select_collection(list)) {               // Throws
9,180✔
419
        m_encoder.collection_clear(list.size()); // Throws
7,650✔
420
    }
7,650✔
421
    if (auto logger = would_log(LogLevel::trace)) {
9,180✔
NEW
422
        logger->log(LogCategory::object, LogLevel::trace, "   Clear '%1'",
×
NEW
423
                    get_prop_name(list.get_table(), list.get_short_path()));
×
UNCOV
424
    }
×
425
}
9,180✔
426

427
void Replication::link_list_nullify(const Lst<ObjKey>& list, size_t link_ndx)
428
{
2,136✔
429
    if (select_collection(list)) { // Throws
2,136✔
430
        m_encoder.collection_erase(link_ndx);
1,956✔
431
    }
1,956✔
432
    if (auto logger = would_log(LogLevel::trace)) {
2,136✔
433
        logger->log(LogCategory::object, LogLevel::trace, "   Nullify '%1' position %2",
×
NEW
434
                    list.get_table()->get_column_name(list.get_col_key()), link_ndx);
×
435
    }
×
436
}
2,136✔
437

438
void Replication::dictionary_insert(const CollectionBase& dict, size_t ndx, Mixed key, Mixed value)
439
{
90,159✔
440
    if (select_collection(dict)) { // Throws
90,159✔
441
        m_encoder.collection_insert(ndx);
46,161✔
442
    }
46,161✔
443
    log_collection_operation("Insert", dict, value, key);
90,159✔
444
}
90,159✔
445

446
void Replication::dictionary_set(const CollectionBase& dict, size_t ndx, Mixed key, Mixed value)
447
{
9,846✔
448
    if (select_collection(dict)) { // Throws
9,846✔
449
        m_encoder.collection_set(ndx);
8,286✔
450
    }
8,286✔
451
    log_collection_operation("Set", dict, value, key);
9,846✔
452
}
9,846✔
453

454
void Replication::dictionary_erase(const CollectionBase& dict, size_t ndx, Mixed key)
455
{
12,276✔
456
    if (select_collection(dict)) { // Throws
12,276✔
457
        m_encoder.collection_erase(ndx);
11,310✔
458
    }
11,310✔
459
    if (auto logger = would_log(LogLevel::trace)) {
12,276✔
460
        logger->log(LogCategory::object, LogLevel::trace, "   Erase %1 from '%2'", key,
×
NEW
461
                    get_prop_name(dict.get_table(), dict.get_short_path()));
×
462
    }
×
463
}
12,276✔
464

465
void Replication::dictionary_clear(const CollectionBase& dict)
466
{
2,856✔
467
    if (select_collection(dict)) { // Throws
2,856✔
468
        m_encoder.collection_clear(dict.size());
2,604✔
469
    }
2,604✔
470
    if (auto logger = would_log(LogLevel::trace)) {
2,856✔
NEW
471
        logger->log(LogCategory::object, LogLevel::trace, "   Clear '%1'",
×
NEW
472
                    get_prop_name(dict.get_table(), dict.get_short_path()));
×
UNCOV
473
    }
×
474
}
2,856✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc